实现账号绑定、持久登录与跨设备对局恢复
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -28,6 +28,13 @@ __pycache__/
|
||||
# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) -----------
|
||||
packs/index.json
|
||||
|
||||
# --- 森空岛 account binding (docs/skland.md): the local credential database — a secret, never committed ------------
|
||||
# SP_SKLAND_DB default (server/skland/store.js) plus the SQLite WAL side files; skland-data/ is the Docker volume path
|
||||
data/skland.db
|
||||
data/skland.db-wal
|
||||
data/skland.db-shm
|
||||
skland-data/
|
||||
|
||||
# --- tests & logs ----------------------------------------------------------------------------------
|
||||
test/e2e/out/
|
||||
logs/
|
||||
|
||||
+14
-1
@@ -12,8 +12,18 @@
|
||||
# (public/fonts, data/assets.json and data/local-assets.json are copied from the build context when present)
|
||||
# Without any art the game still runs with placeholder visuals.
|
||||
#
|
||||
# Data: the opt-in Skland account binding (SP_SKLAND=1, docs/skland.md) is the only runtime state: one SQLite file of
|
||||
# account credentials (SP_SKLAND_DB). It must survive a container restart, so it belongs on a volume — this image
|
||||
# creates /app/skland-data and hands it to `node` below, and docker-compose.yml mounts the named volume `skland-data`
|
||||
# there with SP_SKLAND_DB=/app/skland-data/skland.db. There is deliberately no `VOLUME` instruction: an anonymous
|
||||
# volume would make `docker run` silently accumulate credentials a host cannot find, so the mount is the caller's
|
||||
# explicit choice (`-v skland-data:/app/skland-data`). The default (data/skland.db, inside the container) is only for
|
||||
# a throwaway run. The database is a secret: it holds account credentials, it sits outside every static mount, and it
|
||||
# must never be committed, served over HTTP or copied around unencrypted.
|
||||
#
|
||||
# Run: docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
|
||||
# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG
|
||||
# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG,
|
||||
# SP_SKLAND (off; 1 turns the Skland account binding on), SP_SKLAND_DB (data/skland.db)
|
||||
|
||||
ARG NODE_IMAGE=node:22-alpine
|
||||
|
||||
@@ -53,6 +63,9 @@ COPY --from=build /app/data ./data
|
||||
COPY --from=build /app/public ./public
|
||||
# research tables: read by server/sim/nodeData.js as a fallback
|
||||
COPY --from=build /app/docs/research ./docs/research
|
||||
# the Skland credential database's directory (SP_SKLAND_DB): created before USER node and owned by it, so a fresh named
|
||||
# volume inherits a writable mount point and the unprivileged user can create the .db and its -wal / -shm side files
|
||||
RUN mkdir -p /app/skland-data && chown node:node /app/skland-data
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -17,6 +17,7 @@ them (with `node_modules/`, which keeps each package's own licence file).
|
||||
| [Preact](https://github.com/preactjs/preact) | 10.29.8 | MIT | UI — `public/vendor/preact.module.js`, `hooks.module.js` | no (npm) | yes |
|
||||
| [htm](https://github.com/developit/htm) | 3.1.1 | Apache-2.0 | UI templates — `public/vendor/htm.module.js` | no (npm) | yes |
|
||||
| [three.js](https://github.com/mrdoob/three.js) | 0.186.1 | MIT | official 3D board — `public/vendor/three.core.js`, `three.module.js` | no (npm) | yes |
|
||||
| [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) | 2.0.4 | MIT | the 森空岛 binding panel renders the scan-login QR client-side — `public/vendor/qrcode.js` (a UMD build loaded as a classic script, so the global `qrcode`) | no (npm) | yes |
|
||||
| [ws](https://github.com/websockets/ws) | 8.22.0 | MIT | WebSocket server (`server/`) | no (npm) | yes (`node_modules/`) |
|
||||
| [Node.js](https://nodejs.org/) | v22.23.3 (pinned in `scripts/make-windows-bundle.mjs`) | MIT | the portable `node\node.exe` (**Windows portable package only** — the integration bundle in [Releases](../../releases/latest) ships no `node.exe`) | no (downloaded from nodejs.org at package time, sha256 verified) | **Windows portable package only** (`node\node.exe`, with `node\LICENSE-node.txt`) |
|
||||
| [puppeteer-core](https://github.com/puppeteer/puppeteer) | 25.12.0 | Apache-2.0 | optional browser tests (dev dependency) | no (npm) | no |
|
||||
@@ -177,6 +178,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
### qrcode-generator — MIT
|
||||
|
||||
```text
|
||||
QR Code Generator for JavaScript
|
||||
|
||||
Copyright (c) 2009 Kazuhiko Arase
|
||||
|
||||
URL: http://www.d-project.com/
|
||||
|
||||
Licensed under the MIT license:
|
||||
http://www.opensource.org/licenses/mit-license.php
|
||||
|
||||
The word 'QR Code' is registered trademark of
|
||||
DENSO WAVE INCORPORATED
|
||||
http://www.denso-wave.com/qrcode/faqpatent-e.html
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
### ws — MIT
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 卫戍协议:盟约 · one command: `docker compose up -d` (docs/DEPLOY.md「Docker」).
|
||||
# The service builds the repository's own Dockerfile and tags the image; no other service is needed.
|
||||
services:
|
||||
stronghold:
|
||||
build:
|
||||
context: .
|
||||
# A) download the art while building (~550 MB, needs internet): uncomment
|
||||
# args: { FETCH_ASSETS: "1" }
|
||||
image: stronghold-protocol
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Skland (森空岛) account binding: SP_SKLAND=1 turns the module on (docs/skland.md) and SP_SKLAND_DB points its
|
||||
# SQLite credential store at the named volume below. To run without the module, comment out (or delete) both
|
||||
# lines — the server then keeps no account credentials at all and the volume is never written.
|
||||
SP_SKLAND: "1"
|
||||
SP_SKLAND_DB: /app/skland-data/skland.db
|
||||
volumes:
|
||||
# The credential database must survive `docker compose down`, `docker compose up --build` and image rebuilds, so
|
||||
# it lives in a named volume instead of the container's writable layer. A named volume behaves the same on Windows,
|
||||
# macOS and Linux and needs no host path or permission setup; `docker compose down -v` is what deletes it.
|
||||
# The database is a secret: never publish or serve it over HTTP (it is outside every static mount), and back it
|
||||
# up carefully (docs/DEPLOY.md「Docker」).
|
||||
- skland-data:/app/skland-data
|
||||
# B) the host's art instead of the downloaded copy, read-only (prepare it with `node tools/setup.mjs`): uncomment
|
||||
# - ./public/assets:/app/public/assets:ro
|
||||
|
||||
# One named volume, one runtime database. `docker volume inspect` shows where the host keeps it; it is private to the
|
||||
# container user, so a bind mount (`./skland-data:/app/skland-data`) is only needed to read the file from the host.
|
||||
volumes:
|
||||
skland-data:
|
||||
@@ -25,7 +25,9 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a
|
||||
|
||||
- **The server** is one Node process: plain `node:http` plus `ws`, no framework (`server/index.js` wires
|
||||
`server/http/`). It serves the static client, the generated data and the sim's source files, and runs every room and
|
||||
match in memory — rounds, shop, economy, bots, validation. Nothing is written to disk.
|
||||
match in memory — rounds, shop, economy, bots, validation. Optional account binding (`SP_SKLAND=1`) persists
|
||||
credentials and JWT login records in SQLite ([skland.md](skland.md)); a selected role can resume its retained seat
|
||||
across devices through authenticated account recovery. Match state remains in memory, not in SQLite.
|
||||
- **The browser** loads native ES modules with no bundler and no build step; the libraries are vendored into
|
||||
`public/vendor/` by `tools/vendor.mjs` on `npm install`.
|
||||
- **The battle simulation** (`server/sim/`) is pure ESM without any Node API, served read-only at `/sim/`. Both sides
|
||||
@@ -79,6 +81,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~<hash>` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
|
||||
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
|
||||
| `server/skland/` | opt-in provider binding and SQLite login records; `auth.js` verifies account ownership, `accountHttp.js` handles login/entry/recovery, `recovery.js` describes only the selected role's retained room or match |
|
||||
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
|
||||
| `server/data.js` | loads `data/*.json` once (frozen) |
|
||||
| `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change |
|
||||
@@ -102,6 +105,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
|---|---|
|
||||
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
|
||||
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
|
||||
| `public/js/skland/` | the account dialog, role selection, entry gate and server-backed cross-device recovery picker; no local match-token list is used for account recovery |
|
||||
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
|
||||
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
|
||||
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
|
||||
|
||||
+53
-13
@@ -13,7 +13,7 @@
|
||||
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 790 MB。 |
|
||||
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
|
||||
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。
|
||||
|
||||
## 1. Windows 小主机:一步步
|
||||
|
||||
@@ -180,7 +180,7 @@ cloudflared tunnel --url http://localhost:3000
|
||||
2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。
|
||||
3. 朋友访问 `http://<你的公网 IP>:外部端口`。
|
||||
|
||||
注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。
|
||||
本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。
|
||||
|
||||
### 2.4 反向代理与 HTTPS(有域名时)
|
||||
|
||||
@@ -231,8 +231,20 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
|
||||
|
||||
## 3. Docker
|
||||
|
||||
### 3.1 一条命令(docker compose)
|
||||
|
||||
仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# A) 构建时下载素材(需要联网,约 550 MB)
|
||||
docker compose up -d # 构建镜像并后台启动(首次会构建;HTTP 服务在 http://<主机>:3000)
|
||||
docker compose logs -f # 跟随日志;docker compose ps 看状态
|
||||
docker compose down # 停止并删除容器(数据卷 skland-data 保留)
|
||||
```
|
||||
|
||||
不用 compose 时等价的原始命令(`FETCH_ASSETS=1` 在构建时下载素材,约 550 MB,需要联网;不下载则用占位图,或改用 B 挂载宿主机素材):
|
||||
|
||||
```bash
|
||||
# A) 构建时下载素材
|
||||
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
|
||||
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
|
||||
|
||||
@@ -242,22 +254,50 @@ docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \
|
||||
-v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol
|
||||
```
|
||||
|
||||
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。
|
||||
镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`,也不含 `docker-compose.yml`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等),另有第 3.2 节的森空岛两项。健康检查:`GET /healthz`。
|
||||
|
||||
docker compose 示例:
|
||||
### 3.2 森空岛账号绑定与数据卷
|
||||
|
||||
可选的**账号绑定**(`server/skland/`,见 [skland.md](skland.md))把账号凭据、JWT 签名密钥和可撤销的登录记录存进同一个 SQLite 文件;这是服务器需要跨重启保留的账号数据(房间和对局仍然只在内存里,第 0 节)。JWT 以 HttpOnly Cookie 保存 30 天,刷新、重新开页面或重启服务不用重新绑定;解绑会使旧 JWT 失效。不设置 `SP_SKLAND` 时模块不启用、不建库,`/skland/*` 统一返回 JSON 404;本分支的标题入口仍要求绑定,因此开服需要启用它。需要含 `node:sqlite` 的 Node 22.5 或更新版本。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `SP_SKLAND` | 空(关闭) | 设为 `1` 时打开账号绑定模块 |
|
||||
| `SP_SKLAND_DB` | `data/skland.db` | SQLite 凭据库的路径(相对路径相对项目根目录);父目录不存在时自己创建 |
|
||||
| `SP_AUTH_SECRET` | 自动生成并存入数据库 | 可选的 JWT 签名密钥,至少 32 字节;不要每次启动随机改变,轮换会使现有登录失效 |
|
||||
| `SP_PUBLIC_ORIGIN` | 按请求来源判定 | 可选的固定访问地址,例如 `https://game.example.com`,用于同源校验和 HTTPS Cookie |
|
||||
| `SP_SKLAND_DIAGNOSTIC_KEY` | 空(关闭) | 仅管理员诊断接口使用;调用需 `X-SP-Diagnostic-Key`,不要填进前端或发给玩家 |
|
||||
|
||||
`docker-compose.yml` 里这两个变量都指向挂载的具名卷:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stronghold:
|
||||
build:
|
||||
context: .
|
||||
args: { FETCH_ASSETS: "1" }
|
||||
ports: ["3000:3000"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SP_VERIFY: "off"
|
||||
SP_SKLAND: "1"
|
||||
SP_SKLAND_DB: /app/skland-data/skland.db
|
||||
volumes:
|
||||
- skland-data:/app/skland-data
|
||||
```
|
||||
|
||||
- **为什么用卷**:容器以 `USER node` 运行,删除容器、`docker compose down` 或重建镜像都会丢掉镜像层里的文件;数据库放在具名卷 `skland-data` 上,重启、`docker compose down` 和 `docker compose up --build` 之后绑定关系都还在。`docker compose down -v`(或 `docker volume rm`)才会删除它。
|
||||
- **为什么用具名卷而不是绑定挂载**:Windows / macOS / Linux 上行为一致,不需要先建宿主机目录、也不用管容器内 `node`(uid 1000)对宿主机目录的权限。要在宿主机上直接读这个文件时,把挂载换成 `- ./skland-data:/app/skland-data`(目录已在 `.gitignore` 里)。
|
||||
- **关掉模块**:把 `SP_SKLAND` 和 `SP_SKLAND_DB` 两行删掉或注释掉即可(卷可以留着不用),服务器就不再保存任何账号凭据。手工 `docker run` 时对应地去掉 `-e SP_SKLAND=1 -e SP_SKLAND_DB=…` 两个参数。
|
||||
- **改用别的路径**:`SP_SKLAND_DB` 换成卷里的其它位置(例如 `/app/skland-data/skland-2.db`)。内置静态服务会拒绝下载当前配置的库及 WAL/SHM 文件,但仍建议放在所有静态目录之外,避免其它文件服务器或代理规则泄露。
|
||||
|
||||
公网使用 HTTPS;代理要保留 Host 并传递 `X-Forwarded-Proto`(2.4),也可显式设置 `SP_PUBLIC_ORIGIN`。
|
||||
HTTPS 下 Cookie 带 Secure;本地 `127.0.0.1` 开发使用 HTTP。游戏内用角色 UID + 区服标识账号,昵称可以重复。
|
||||
登录记录不保存密码,JWT 也不携带鹰角/森空岛凭据。
|
||||
|
||||
**随账号跨设备恢复**:新设备登录同一账号并选择同一区服角色后,可在标题页点击「恢复账号对局」,无需复制
|
||||
本机缓存。查询对局或自动恢复登录不会挤掉旧设备;点击「开始」或确认恢复后才接续原席位,旧设备断开且不会
|
||||
自动抢回连接。恢复仍受原有断线期限限制(独立模拟 24 小时、同盟对局 10 分钟,等候房间另受 60 秒宽限期限制),
|
||||
**不包含服务器重启后的对局存档**。只会查询当前角色,昵称相同或其他区服不会串号。
|
||||
|
||||
> **这个数据库是凭据,按密钥对待。** `skland.db` 里是明文保存的 `access_token`、长期 `cred` 和签名密钥 `cred_token`,拿到文件等于拿到对应账号的接口权限(skland.md §9):
|
||||
> - **限制权限**:只让运行服务器的账号可读,例如 `chmod 700 skland-data && chmod 600 skland-data/skland.db*`(WAL 模式下还会有 `-wal` / `-shm` 两个附属文件,同样处理)。在宿主机上运行而非容器里时默认路径是 `data/skland.db`,权限同理。
|
||||
> - **绝不通过 HTTP 暴露**:卷挂在 `/app/skland-data`,不在 `public/`、`data/`、`shared/` 任何静态挂载之下;反向代理也不要为它开规则。
|
||||
> - **不提交、不进镜像**:`.gitignore` 忽略了 `data/skland.db`、`-wal` / `-shm` 和 `skland-data/`;构建上下文只会把 `data/` 复制进镜像,所以容器部署要把库放在 `data/` 之外(compose 已经这么做)。
|
||||
> - **备份要当密码处理**:`docker compose stop` 后再从卷里拷贝(WAL 写入中直接拷可能拿到不一致的副本),备份文件加密保存、限权,不要放进网盘 / 仓库 / 聊天记录;不再使用时删除卷(`docker compose down -v`)。
|
||||
|
||||
## 4. macOS / Linux 常驻
|
||||
|
||||
- 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。
|
||||
|
||||
@@ -175,6 +175,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
|
||||
A `choice:<effectId>` registry handler overrides the default application (§2.4).
|
||||
|
||||
### 1.3 Disconnects, AI takeover
|
||||
* Account-required fork (§31.13, [skland.md](skland.md) §5): the selected UID + region can resume the same retained
|
||||
seat on another device after explicit authenticated entry. Login/lookup alone does not replace a socket. The
|
||||
transport rotates its token on successful attachment, while the match and its private state stay the same.
|
||||
Account recovery does not extend the deadlines below or survive a server restart; an expired role's old seat goes
|
||||
through the normal lobby onExpire/onLeave cleanup before a new session can reuse its stable playerId.
|
||||
* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token,
|
||||
and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a
|
||||
connected seat when no replacement was allowed or the request has a later stamp than the attached holder
|
||||
|
||||
@@ -73,6 +73,37 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
|
||||
---
|
||||
|
||||
### Required account binding at the callsign field (fork integration)
|
||||
|
||||
The title hides 博士代号 until binding, then displays the role nickname as plain text without an input
|
||||
(`public/js/skland/entry.js`;
|
||||
`SP_SKLAND=1`, [skland.md](../skland.md)). The primary button shows 绑定账号 until a named role is fetched, then 开始.
|
||||
Multiple roles start with the default role and can be switched. Unbinding or an empty role list clears the callsign
|
||||
and restores 绑定账号. The nickname retains its person icon. The scan tab fetches a QR automatically and refreshes it
|
||||
every 10 s; the first awaiting-confirmation response starts a 60 s window. Refresh keeps the current image visible
|
||||
until its replacement is ready and updates the image source in place. Manual refresh remains available, with
|
||||
serial 2 s polling and paused timers outside the scan tab/visible page. Saved callsigns/entered flags and account recovery cannot skip the binding lookup. Skland QR
|
||||
login supports official/Bilibili channels; phone/password uses the Hypergryph account. Every game-entry HTTP request
|
||||
uses the existing game session token and a 30-day HttpOnly JWT cookie, without entering the lobby automatically.
|
||||
The cookie restores the role in a new tab or after a server restart; role choice persists on the server. The callsign
|
||||
never becomes an identity key: UID plus server region determine a stable game player id, allowing duplicate names.
|
||||
Account UI, styles and the entry policy stay in independent module files; title, boot and recovery have small hooks
|
||||
to reduce upstream merge conflicts. An optional server account policy also checks hello and room/game requests
|
||||
against the JWT-owned role; editing the client's UUID, region or name cannot select another player's binding.
|
||||
Unbind revokes remembered login. Owner request, 2026-10-14; §31.12 and [skland.md](../skland.md).
|
||||
|
||||
The title's 恢复账号对局 picker queries the selected role's live server-side seat, even on a device with no local match
|
||||
history. It replaces the visible 本机 recovery buttons (including the obsolete lobby shortcut). Opening it or restoring
|
||||
the login cookie does not disconnect an active device. 开始 or an explicit recovery confirmation asks for the role's
|
||||
current token, reconnects with the JWT, and enters only after welcome confirms the expected playerId. The existing
|
||||
socket is then replaced (4001, no auto-reconnect), and its old token cannot take the role back on reload. A duplicated
|
||||
tab or stale saved entered flag stays at the title unless its own current role token really resumed. Any anonymous
|
||||
welcome also clears an already-entered shell; the connection banner's 在此页面继续 uses checked account entry.
|
||||
The picker shows
|
||||
empty, loading and error states, a takeover notice and a refresh action; an expired/replaced selector cannot create a
|
||||
new match. Room/match state is still memory-only with the existing disconnect windows; server restart ends it.
|
||||
The UI and entry adapter remain in public/js/skland/ (§31.13).
|
||||
|
||||
## 13. Local-client art (optional, host-side)
|
||||
|
||||
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
|
||||
|
||||
@@ -4,6 +4,26 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 8. Network protocol (shared/protocol.js is normative)
|
||||
|
||||
**Fork account policy (opt-in `SP_SKLAND=1`, owner request 2026-10-14).** The existing WebSocket messages are
|
||||
unchanged. Hello may establish anonymous transport for account login, but room/game requests require the role
|
||||
owned by a persistent, server-verified JWT cookie. The server ignores a client's claimed nickname/UUID for account
|
||||
identity; it derives stable `playerId` from UID plus provider-reported server region (`channelName`). Nicknames
|
||||
may repeat, and optional provider `gameId` metadata does not affect identity. The public id uses 128 SHA-256 digest
|
||||
bits encoded as base64url after `a_` (24 characters), so normal field ids and per-player battle reports fit the
|
||||
existing protocol limits. A reconnect token alone cannot resume
|
||||
an account identity. A valid login plus the role's current reconnect token can resume its previous socket; each
|
||||
game frame checks the current login record and selected role, so logout or a role switch invalidates its authority.
|
||||
Cookie restoration and read-only recovery lookup never take over another device. An explicit authenticated
|
||||
enter/resume POST returns the selected role's token; the existing hello then attaches it and resends room/match state.
|
||||
Only after successful attachment is the account token rotated, so a stale device cannot silently retake the seat.
|
||||
The client stages the entry token in memory until welcome; local multi-tab claims still protect normal reconnects.
|
||||
Login lasts 30 days, survives process restart and is revoked on unbind. Room/match data remain in memory.
|
||||
Recovery selectors are scoped to the selected role and the particular room/match; stale selectors return 409 without
|
||||
creating a new game. The existing 10-minute / solo 24-hour disconnect windows are unchanged, and an expired seat is
|
||||
removed through the lobby before its stable playerId can be reused. Account API ownership and private DB handling
|
||||
are specified in [skland.md](../skland.md) §5/§9. The optional policy hooks leave module-off transport unchanged;
|
||||
no simulation rule, WebSocket message or protocol-version change is involved.
|
||||
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire.
|
||||
|
||||
### 8.1 Session & lobby
|
||||
@@ -137,6 +157,8 @@ voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META
|
||||
|
||||
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
|
||||
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
|
||||
The account-required fork replaces the visible local recovery entry with authenticated account recovery (§31.13);
|
||||
the generic local helper and its module-off handshake behavior are retained.
|
||||
|
||||
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
|
||||
|
||||
|
||||
@@ -341,3 +341,68 @@ every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) ha
|
||||
`test/render/board3d-scene.test.js` (an official scene on 战场#03 keeps the slabs in the board material and nothing else of
|
||||
the procedural board; dropping the scene restores the board; act2 m02 adds no slab mesh). Screenshots of the five
|
||||
affected stages before / after / metal board were compared by hand. Golden: unchanged (client only).
|
||||
|
||||
### 31.12 Account binding before entry (fork integration, 2026-10-14)
|
||||
|
||||
Owner request: integrate the working account module at 博士代号, automatically use the bound role's nickname, retain
|
||||
Skland QR for official/Bilibili channels, and keep later upstream merges small. The callsign label and nickname are
|
||||
hidden until binding, then shown as plain text without an input; 绑定账号
|
||||
replaces 开始 until a named role is obtained, and returns after unbinding or an empty role list. Boot and local-match
|
||||
recovery recheck the binding before accepting a cached entry. The dialog, styles, entry policy and session resolver
|
||||
live in the module's own directories, with small title/boot/recovery hooks. Game-entry requests use the
|
||||
existing reconnect token and a reserved namespace; the independent diagnostic routes require an administrator key
|
||||
and cannot read or mutate these rows. No new WebSocket messages or simulation rules. Multiple roles select the default, retain a
|
||||
still-valid choice on refresh/reopen, and immediately update the callsign when switched. No manual callsign entry.
|
||||
The official account-page icons are referenced remotely; authorization redirects remain provider-operated.
|
||||
The nickname retains its person icon. Opening the scan tab automatically fetches a QR: replacement is every 10 s,
|
||||
switching to 60 s from the first status 101 (waiting for phone confirmation), with manual refresh retained. Refresh
|
||||
keeps the old QR visible until the new image is encoded, then replaces its source in place without a blank frame. Requests
|
||||
are serial and check confirmation before replacement; switching tabs or hiding the page pauses timers, and closing
|
||||
the dialog or binding stops them. The provider's 101 is exposed as `awaitingConfirmation` without changing `waiting`.
|
||||
|
||||
Persistent login uses a 30-day HS256 JWT in an HttpOnly, SameSite=Strict cookie (Secure over HTTPS), with signing
|
||||
key and revocable login/role records in the same SQLite database. New tabs and restarted servers restore the
|
||||
selected role without another provider login. The JWT contains no provider credentials. Account APIs derive
|
||||
ownership on the server, reject cross-site requests and foreign role selection, and default-close diagnostic access.
|
||||
The optional WebSocket account policy resolves hello and every room/game request to the owned role; a copied game
|
||||
reconnect token alone cannot claim it. UID plus server region determines stable game identity, allowing duplicate
|
||||
nicknames; optional `gameId` and name changes do not change that identity. Unbind clears credentials and revokes
|
||||
old JWTs/open-socket authority; late provider replies cannot restore a cancelled binding. Static/media/pack paths
|
||||
deny the configured database and its WAL/SHM files, including aliases and Windows casing. These changes follow the
|
||||
owner's explicit persistence/access-control request; rooms/matches remain memory-only.
|
||||
|
||||
Reference: [skland.md](../skland.md). Tests cover token rejection/player isolation, nickname selection, authenticated
|
||||
requests and the real title/modal in a browser with a stub account provider (QR, SMS and password/CAPTCHA continuation,
|
||||
landscape-phone layout, hidden/plain-text callsign with person icon, empty roles, unbinding, fresh-tab JWT restoration
|
||||
and cached-entry checks). Server tests cover forged JWT/claims, foreign roles, logout/replay, restart/expiry,
|
||||
protected database files, cancelled provider replies and same-name/different-UID/region identities.
|
||||
Deterministic timer tests cover automatic QR generation, exact 10/60 s boundaries, pause/resume, late responses,
|
||||
manual refresh versus confirmation and retry after failure. Golden: unchanged
|
||||
(no simulation changes).
|
||||
|
||||
### 31.13 Account-scoped cross-device recovery (fork integration, 2026-10-14)
|
||||
|
||||
Owner request: continue implementing recovery with the account across devices. The title now offers 恢复账号对局
|
||||
from authenticated server state, replacing the visible local-token recovery shortcuts. A device with no local save
|
||||
can sign in independently, select the same UID + provider region, inspect its retained seat and continue it. Login,
|
||||
cookie restoration and lookup alone do not disconnect another device; Start or recovery confirmation explicitly
|
||||
obtains the role's reconnect token. Only a successful welcome enters the UI, rotates the token and replaces the old
|
||||
socket. Old-device reloads and duplicated tabs cannot silently take the role back; the connection banner can still
|
||||
explicitly continue through checked account entry, and anonymous welcomes return to the title. The UI handles empty, loading,
|
||||
stale/error and refresh states and warns about taking over a live connection.
|
||||
|
||||
The server checks both credentials and the selected role, never a supplied player/name/UID/region. Public account
|
||||
ids encode 128 digest bits as base64url after a_ (24 characters): the previous full hexadecimal digest exceeded
|
||||
player-id and normal-field limits, rejecting watch/kick/battle-result messages. No wire limit needs to change. Recovery selectors
|
||||
belong to a particular room/match; stale or foreign selectors fail without minting a fresh game. Looking up a seat
|
||||
does not extend its lifetime. The normal lobby cleanup runs before an expired stable role id is reused, preventing
|
||||
an orphaned old match. The same Match/PlayerState is resumed, including funds, shop and lineup; no simulation rule,
|
||||
protocol message or generated data changes. Solo 24-hour/co-op 10-minute disconnect windows and waiting-room grace
|
||||
are unchanged. Matches remain memory-only: this is not save recovery after a server restart, nor synchronization of
|
||||
local preferences/statistics. These are owner-requested account policies, not deviations from official gameplay.
|
||||
|
||||
Normative updates: design/network.md §8 and its recovery note, design/client.md required-account entry, META.md §1.3;
|
||||
references: skland.md §5 and ARCHITECTURE.md / DEPLOY.md. Tests: test/skland/recovery.test.js exercises actual Match
|
||||
objects through independent HTTP/WS clients; test/skland/recovery.browser.test.js runs two isolated browser contexts
|
||||
with a stub provider, plus old-device and duplicated-tab checks and desktop/phone layout. Existing account, local
|
||||
identity, i18n and docs checks are rerun. Golden moves: none (no match/sim rules changed).
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# skland.md — the opt-in Skland account binding
|
||||
|
||||
`server/skland/` reads a player's Arknights account bindings and associates the roles with their game session.
|
||||
The title hides the 博士代号 label and nickname until binding, then displays them as plain text without an input.
|
||||
Logging in fetches the callsign from the default role's `nickName`; choosing another role updates it immediately.
|
||||
The primary button shows 绑定账号 until a named role is available, then 开始. Unbinding
|
||||
or an empty role list clears the callsign and restores 绑定账号. Boot and account-match recovery recheck the current
|
||||
server binding, so a cached manual callsign/entered flag cannot skip it. This fork requires binding for browser entry;
|
||||
an operator must enable the module to let players enter. When enabled, the server also requires the authenticated
|
||||
role for room/game requests. Login persists for 30 days through an HttpOnly JWT cookie, including across server restarts.
|
||||
Password login uses the official Hypergryph account binding service directly; the existing QR/SMS paths use the
|
||||
signed Skland API. The historical module name, URL paths and enable switch are retained for compatibility.
|
||||
It is **off by default** and stays off unless an operator turns it on
|
||||
(§2); an unconfigured server keeps no account credentials at all. It uses the public website's account endpoints and
|
||||
the community-documented Skland endpoints (§10); enabling it and storing credentials is the operator's decision, not a
|
||||
feature every server gets. The module reaches the network only when the server does; the tests inject a stub `fetchImpl`.
|
||||
|
||||
## 1. What it is, and what it is not
|
||||
|
||||
It is a per-player credential store plus a login client and a small JSON HTTP surface. It does: run the three account
|
||||
login paths (扫码 / 验证码 / 密码), fetch the account's bound roles, refresh an expired credential once, and answer the
|
||||
binding state to the game. It does **not** solve 人机验证 (§4), spend any credential, or post anything on the player's
|
||||
behalf. The game entry uses a reconnect token for transport and a server-verified JWT for account ownership (§5).
|
||||
The independent diagnostic routes are closed by default; an administrator key enables their caller-supplied ids,
|
||||
excluding the game's reserved pending/account namespaces.
|
||||
|
||||
## 2. Turning it on
|
||||
|
||||
| Switch | Effect |
|
||||
|---|---|
|
||||
| `SP_SKLAND=1` | enables the module (exactly `1`; any other value leaves it off) |
|
||||
| `SP_SKLAND_DB=<path>` | the SQLite file (default `<repo>/data/skland.db`) |
|
||||
| `SP_AUTH_SECRET=<secret>` | optional HS256 signing secret, at least 32 bytes; otherwise generated once and persisted in the database |
|
||||
| `SP_PUBLIC_ORIGIN=https://game.example.com` | optional canonical browser origin, useful behind a reverse proxy |
|
||||
| `SP_SKLAND_DIAGNOSTIC_KEY=<secret>` | enables administrator diagnostic routes; callers must supply `X-SP-Diagnostic-Key` |
|
||||
| `startServer({ skland: true })` | same as `SP_SKLAND=1` |
|
||||
| `startServer({ skland: {…} })` | enables it and takes `storeFile`, `fetchImpl` (tests), `client`, `scanTimeoutMs`, `authSecret`, `publicOrigin`, `diagnosticKey`; `authNow` injects the test clock |
|
||||
|
||||
`startServer` returns the live service as `srv.skland`, or `null` when the module is off; it logs
|
||||
`[skland] account binding on — <file>` once. On an unconfigured server no store is created and no credential is read or
|
||||
written. The off state still owns its paths: `routes.js` mounts `createSklandOffHandler`, so **every** `/skland/*`
|
||||
request answers one uniform JSON `404 { ok: false, error }` — without it a GET would fall through to the static mount
|
||||
(a 404) and a POST to the GET/HEAD gate (a 405 HTML page), two shapes for one API.
|
||||
|
||||
## 3. The three login paths
|
||||
|
||||
QR/SMS converge on `grant → cred` (`user/oauth2/v2/grant` then `zonai .../generate_cred_by_code`), then
|
||||
`finishLogin` persists the row (`server/skland/service.js`). Password login instead queries the official account
|
||||
binding service and stores its result without obtaining a Skland credential.
|
||||
|
||||
The Skland QR path is retained for both official-server and Bilibili-server bindings; role extraction preserves
|
||||
every Arknights channel returned by Skland. The phone/password form authenticates a Hypergryph account. Bilibili
|
||||
players use Skland QR login and complete any authorization redirect in the provider's interface, as reported by the
|
||||
user; the panel continues polling the same scan session. This integration does not assume that Bilibili offers a
|
||||
phone/password endpoint, or that authorization is its only possible login method.
|
||||
|
||||
| Path | The player | The server calls | Comes back |
|
||||
|---|---|---|---|
|
||||
| 扫码 | scans the QR (`scanUrl`, a `hypergryph://scan_login?…` deep link), confirms on the phone | `gen_scan/login` → `scan_status` (polled) → `token_by_scan_code` | `token`, `hgId`, **`deviceToken`** |
|
||||
| 验证码 | receives the SMS code (`send_phone_code`, type 2) and types it | `token_by_phone_code` | `token`, `hgId`, **`deviceToken`** |
|
||||
| 密码 | types phone + password | `token_by_phone_password` → binding-service grant (type 1) → `binding_list` | HG `token` and Arknights bindings; **no Skland cred required** |
|
||||
|
||||
Password lookup follows the [official account SDK](https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js),
|
||||
checked on 2026-10-14: `Account.API.grantApp('binding')` posts `{ token, appCode: 'be36d44aa36bfb5b', type: 1 }`
|
||||
to `https://as.hypergryph.com/user/oauth2/v2/grant`, receives a service **token** (not a Skland grant code), then
|
||||
GETs `https://binding-api-account-prod.hypergryph.com/account/binding/v1/binding_list` with that token and
|
||||
`appCode=arknights`. A previous local QR/SMS login or stored `deviceToken` is not a prerequisite.
|
||||
Only a real upstream status **109** (`NEED_DEVICE_VERIFY`) becomes `{ ok: false, error, needDevice: true }`;
|
||||
an incorrect password remains its own error. The 密码 endpoint takes `phone` + `password` only; passwords are never stored.
|
||||
|
||||
The [Arknights account page](https://ak.hypergryph.com/user/home) also uses this SDK. Its `/user/api/role/login`
|
||||
request consumes an opaque role token issued by `/account/binding/v1/u8_token_by_uid`; the website does not locally
|
||||
encrypt the password into that token. Reading the binding list already supplies the requested account information,
|
||||
so this module does not need that later website-session login request or its analytics fields.
|
||||
|
||||
`send_phone_code` uses **2** (`LOGIN_OR_REGISTER`) for both the initial send and a captcha retry, following the
|
||||
[official account center login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on
|
||||
2026-10-14. The previous type 24 came from an Endfield-specific example rather than this web login flow.
|
||||
The endpoint may answer status 102 (发送频率太高) — a readable error, not a captcha.
|
||||
|
||||
For `general/v1/scan_status`, both status **100** (未扫码) and **101** (已扫码待确认) return
|
||||
`state: 'waiting'`, with `awaitingConfirmation: false/true` respectively. The title's scan tab automatically fetches
|
||||
a QR and replaces it every 10 s. The first 101 starts a 60 s refresh window; repeated 101 responses do not extend it.
|
||||
The manual 重新获取二维码 action stays available. Automatic/manual replacement keeps the existing image visible
|
||||
until the new QR is encoded, then updates its source without removing the image. The same QR session keeps polling every 2 s, including while the phone confirmation propagates;
|
||||
only the response carrying `data.scanCode` advances to the token exchange. Treating 101 as an error stopped the panel
|
||||
before it could observe the user's confirmation. This mapping follows the [official account center](https://user.hypergryph.com/)
|
||||
and its [login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on 2026-10-14.
|
||||
Other failure statuses (including 102, expired on this endpoint) still surface as errors rather than waiting forever.
|
||||
QR generation and polling are serial: confirmation wins over a queued refresh, and the latest status is checked
|
||||
before automatic replacement. Leaving the scan tab or hiding the page pauses the timers; returning resumes the
|
||||
same QR and checks its status before replacing it. Closing the dialog or binding successfully stops the timers.
|
||||
Failures remain visible and retry QR generation after 10 s. The independent diagnostic page retains manual QR
|
||||
generation; its compatible `waiting` state is unchanged.
|
||||
|
||||
## 4. 人机验证 (geetest)
|
||||
|
||||
SMS sending, password authentication and the official account grant can request verification as `status: 1` with
|
||||
`data.captcha`. The client raises `SklandCaptchaError`; the service returns `{ ok: false, needCaptcha: true, captcha }`,
|
||||
plus `captchaStep: 'password' | 'accountGrant'` for password login.
|
||||
|
||||
The panel displays a verification button. After the user clicks it, `public/js/skland/captcha.js` lazily loads the
|
||||
Geetest provider's widget. The user completes the challenge in that widget; cancellation and load errors leave a
|
||||
retry button. No phone, password or account token is passed to Geetest by this module. Geetest v4 challenges use
|
||||
`geetestId` and `riskType`; the provider's validation fields plus the original `challenge` are returned exactly as
|
||||
the official HG SDK does. Legacy v3 challenges use `gt`, `challenge`, `new_captcha` and return the provider's three
|
||||
`geetest_*` fields. The proof is sent in `captcha` on the original request, never generated by the server.
|
||||
|
||||
Password retries include `captchaStep`. A challenge on `accountGrant` temporarily retains the authenticated HG token
|
||||
in server memory, usable for 120 seconds, so its retry uses the same token without repeating the password exchange.
|
||||
Pending tokens are never sent to the browser or persisted before login completes; unbind discards them.
|
||||
|
||||
## 5. The HTTP surface
|
||||
|
||||
### Game entry
|
||||
|
||||
The title uses `/skland/session/<endpoint>` with `Authorization: Bearer <game reconnect token>` and same-origin
|
||||
cookies on every request, including reads and SMS sending. It establishes the existing `hello`/`welcome` transport
|
||||
without entering the lobby. Invalid or expired transport tokens receive JSON 401. Tokens never appear in URLs,
|
||||
QR codes or provider requests. Supplied `player`, UUID, UID or server fields cannot choose another account's row.
|
||||
Before login, the server uses a reserved `@session/<playerId>` pending row. After provider confirmation it copies
|
||||
credentials to a random `@account/<subject>` row, clears the pending credentials and issues `sp_account`, a signed
|
||||
HS256 JWT cookie: HttpOnly, SameSite=Strict, Path=/, Max-Age=2592000; HTTPS uses Secure. JWT contents are signed,
|
||||
not encrypted, and contain only issuer/audience, random subject/login id, expiry and the selected role key.
|
||||
Provider tokens, passwords and signing keys never enter it. Verification requires the fixed algorithm, signature,
|
||||
issuer, audience, time bounds, matching server login record and an active binding.
|
||||
|
||||
The following endpoints have the same request/response shapes as the diagnostic table below, with no `player` needed:
|
||||
`status`, `roles`, `scan/start`, `scan/poll`, `phone/send`, `phone/login`, `password/login`, `unbind`.
|
||||
The additional POST `role/select` accepts `{ roleKey }` and selects only an owned, named role with a nonempty UID
|
||||
and region. `status`/selection also return `loginId`, `selectedRoleKey` and `gamePlayerId`. Selection is persisted
|
||||
in the server login record and JWT. On login the default valid role is selected, otherwise the first valid role;
|
||||
refresh preserves a still-valid selection or chooses a replacement. An empty list cannot start the game.
|
||||
Changing roles or unbinding while in a room is rejected; leave first. The selected nickname uses the existing
|
||||
callsign sanitizer, but never determines ownership. Binding itself does not press Start.
|
||||
|
||||
`shared/accountIdentity.js` defines identity as the provider-reported server region (`channelName`) plus normalized
|
||||
string UID. Optional `gameId` and mutable nickname are excluded, so QR and password answers may omit `gameId`
|
||||
without changing identity. The game exposes a stable `a_` + base64url of the first 16 SHA-256 digest bytes of that tuple
|
||||
as `playerId` (24 characters; `n:<playerId>` fits the existing 32-character field id limit), uses the existing
|
||||
registry/lobby maps, and permits duplicate nicknames. Different regions with the same UID remain distinct.
|
||||
Authenticated WebSocket hello ignores client-claimed name/identity and verifies that the supplied reconnect token
|
||||
belongs to this role; every room/game frame rechecks the live login and selected role. A bare reconnect token cannot
|
||||
resume an account identity, and a remembered cookie alone establishes only login transport. An anonymous socket can
|
||||
establish login transport and ping, but cannot issue room/game requests. Explicit entry is described below.
|
||||
|
||||
New tabs and restarted servers restore through the persistent cookie/database without another provider login.
|
||||
Unbind revokes the server login, clears its credentials/cookie and invalidates old JWTs, including open sockets;
|
||||
expired cookies require binding again. Login/refresh replies arriving after unbind cannot restore cleared secrets.
|
||||
Deleting the database or rotating the signing secret invalidates remembered logins. Room and match state remain
|
||||
in memory and do not survive a server restart.
|
||||
|
||||
### Cross-device match recovery
|
||||
|
||||
The title's **恢复账号对局** reads server state for the selected role instead of the browser's recent-token ring.
|
||||
A new device signs in independently and selects the same provider region + UID; it need not copy a reconnect token,
|
||||
JWT or localStorage. Restoring the login cookie, opening the title and querying recovery do not take over the seat.
|
||||
|
||||
These additional account routes use the same live transport header, JWT, same-origin checks and rate limit:
|
||||
|
||||
| Method | Session endpoint | Request | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `recovery` | none | `{ ok: true, recovery: null \| { id, name, code, mode, difficulty, inMatch, round, connected, spectating } }` |
|
||||
| POST | `enter` | `{}` | `{ ok: true, gamePlayerId, token, name }`; starts a game transport or resumes the retained seat |
|
||||
| POST | `resume` | `{ id }` from the recovery response | same as enter, but only while the selected role still owns that room/match |
|
||||
|
||||
The recovery id is a selector, not a credential. It is unique to the in-memory room and match count; a new room or
|
||||
later match invalidates it. Supplied player/UID/region/nickname fields never select another role. A missing, foreign
|
||||
or stale recovery returns JSON 409 with `code: 'resume_expired'`, without creating a game session. A read does not
|
||||
extend a disconnect deadline and contains no token or private match view. Entry/recovery credentials stay in the
|
||||
POST response body, never in a URL or persistent browser storage before the server acknowledges them.
|
||||
|
||||
After an explicit Start or recovery confirmation, the client stages that token, reconnects with the cookie and waits
|
||||
for welcome to confirm the expected gamePlayerId before entering. The server keeps the same Match/PlayerState and
|
||||
reuses the existing room/state resync. A successfully attached account socket rotates the reconnect token and closes
|
||||
the old socket with 4001; obtaining the token without attaching does not kick the active device. Old tokens cannot
|
||||
silently retake the seat. Automatic boot entry requires the tab's current token to have resumed the selected role;
|
||||
a stale or duplicated tab stays at the title. Its user may still explicitly start/recover with a valid account login.
|
||||
The connection banner's 在此页面继续 uses the same checked account entry, not an obsolete token. Any anonymous
|
||||
welcome clears the entered flag, including a connection reset on an already open page.
|
||||
Ordinary reconnects retain the existing live-tab claim checks. Module-off sessions do not rotate their tokens.
|
||||
|
||||
The existing lifetimes still apply: solo matches keep a disconnected seat for 24 hours; co-op matches for 10 minutes;
|
||||
a waiting room also has its 60-second lobby grace. Actual expiration cleans the old seat through lobby.onExpire
|
||||
before the stable role id can be reused. Changing devices does not save the match to SQLite, synchronize local UI
|
||||
preferences/statistics, or make it survive server restart. The UI supplies loading, empty, stale/error and refresh
|
||||
states and warns when continuing may disconnect another device.
|
||||
|
||||
### Independent diagnostic page
|
||||
|
||||
These routes require `SP_SKLAND_DIAGNOSTIC_KEY` and the matching `X-SP-Diagnostic-Key` header; otherwise they
|
||||
return 403. Do not embed this administrator key in browser code. `skland.html` is a development diagnostic page;
|
||||
the main title uses the account-scoped endpoints above. Reserved `@session/` and `@account/` ids are forbidden.
|
||||
|
||||
Every answer is JSON, including errors (`{ ok: false, error }`), so a client is never handed the module-level HTML
|
||||
error page. Bodies are capped at 64 KiB. The handler is handed requests before the GET/HEAD gate, so it owns POST.
|
||||
|
||||
| Method | Path | Request | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `/skland/status` | `?player=` | `{ playerId, status, bound, nickname, hgId, roles, error? }` |
|
||||
| GET | `/skland/roles` | `?player=&refresh=1` | `{ ok, roles }` (200 even on failure) |
|
||||
| POST | `/skland/scan/start` | `{ player }` | `{ ok, scanId, scanUrl, expiresInMs }` |
|
||||
| POST | `/skland/scan/poll` | `{ player }` | `{ ok, state: none \| waiting \| confirmed \| expired \| error, scanUrl?, awaitingConfirmation?, status?, nickname?, error? }` |
|
||||
| POST | `/skland/phone/send` | `{ player?, phone, captcha? }` | `{ ok }` or `{ ok: false, needCaptcha, captcha }` or `{ ok: false, error }` |
|
||||
| POST | `/skland/phone/login` | `{ player, phone, code }` | `{ ok, nickname, roles }` or `{ ok: false, error }` |
|
||||
| POST | `/skland/password/login` | `{ player, phone, password, captcha?, captchaStep? }` | `{ ok, nickname, roles }`, `{ ok: false, needCaptcha, captcha, captchaStep }` or `{ ok: false, error, needDevice? }` |
|
||||
| POST | `/skland/unbind` | `{ player }` | `{ ok: true }` |
|
||||
|
||||
`status` is `unbound | scanning | bound | error`; `bound` is `status === 'bound'`. A missing `player` (or phone /
|
||||
code / password, per endpoint) is a 400, an unknown `/skland/*` path a 404, a non-POST method on a POST path a 405, and a
|
||||
malformed body a 400. One 扫码 poll both reports `confirmed` and completes the login; a poll with no live session is
|
||||
`state: 'none'`.
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:3000/skland/status?player=p1' -H 'X-SP-Diagnostic-Key: <admin-secret>'
|
||||
curl -s -X POST http://127.0.0.1:3000/skland/phone/login \
|
||||
-H 'X-SP-Diagnostic-Key: <admin-secret>' -H 'content-type: application/json' -d '{"player":"p1","phone":"13800000000","code":"123456"}'
|
||||
```
|
||||
|
||||
## 6. The database
|
||||
|
||||
One row per player in `skland_players` (`node:sqlite`, `journal_mode = WAL`, `synchronous = NORMAL`), file from
|
||||
`SP_SKLAND_DB` / `storeFile` (default `data/skland.db`; the parent directory is created). The store is synchronous;
|
||||
callers never await. The enabled module requires Node with `node:sqlite` (22.5 or newer); it does not silently
|
||||
replace persistent authentication with an in-memory fallback.
|
||||
|
||||
| Column | Holds |
|
||||
|---|---|
|
||||
| `player_id` | reserved pending/account subject key, or administrator diagnostic id — primary key |
|
||||
| `access_token` | the HG login token (refreshes official bindings or rebuilds a Skland `cred`) |
|
||||
| `device_token` | the device token of a 扫码/验证码 login (optional on Skland grants; unused by official account lookup) |
|
||||
| `hg_id` | the Hypergryph account id (`hgId`) |
|
||||
| `cred` | the long-term API credential |
|
||||
| `cred_token` | the HMAC signing key — never leaves the server |
|
||||
| `user_id` | the Skland internal user id (the column exists; no code path writes it) |
|
||||
| `d_id` | the device fingerprint the credentials are pinned to (`B` + 32 hex, per player) |
|
||||
| `nickname` | the profile name from the binding answer |
|
||||
| `binding` | the raw binding list from the selected provider (cache; parsed back on read) |
|
||||
| `binding_source` | `skland` for QR/SMS, `hypergryph` for official password lookup; selects the refresh path |
|
||||
| `status` | `unbound \| scanning \| bound \| error` |
|
||||
| `error` | the last failure message, if any |
|
||||
| `created_at`, `updated_at` | epoch ms |
|
||||
|
||||
`update()` merges only the named fields; `remove()` drops the row. **解绑 (`unbind`) clears every credential field —
|
||||
including `device_token` and `d_id` — and marks the row `unbound`.** The row itself stays. Password login can be
|
||||
attempted again directly. Existing databases receive the `binding_source` column automatically, defaulting their
|
||||
rows to `skland` so the already working QR/SMS bindings continue to refresh through their original provider.
|
||||
Successful password lookup clears any previous Skland credentials and stores only the HG account token and bindings.
|
||||
|
||||
The same SQLite file holds `account_meta` (the generated JWT signing secret) and `account_logins`
|
||||
(`id`, `subject`, `role_key`, `expires`, `revoked`). These tables enforce expiry, selection and logout revocation.
|
||||
Back up the file consistently with its WAL state and treat backups as credentials too.
|
||||
|
||||
## 7. Credential refresh
|
||||
|
||||
Official password bindings refresh by obtaining a new binding-service token from the stored HG account token and
|
||||
querying the official binding list again. An expired HG token requires login again. QR/SMS `getBinding` retries
|
||||
once on the two expiry codes, otherwise the player rebinds:
|
||||
|
||||
- **10000** — `cred_token` expired → `POST zonai /api/v1/auth/refresh` (header `cred`), retry with the new `token`.
|
||||
- **10002** — `cred` expired → re-run `getGrantCode(access_token, device_token)` then `getCred`, retry. Needs a stored
|
||||
`access_token` or `device_token`; without either it falls through to the error below.
|
||||
- Any other code, or a failed retry: the upstream message is raised; one matching 未登录 reads as
|
||||
`登录已过期,请重新绑定`. `service.roles(player, true)` re-queries only while `status === 'bound'` and persists the
|
||||
refreshed credentials.
|
||||
|
||||
## 8. Signing
|
||||
|
||||
`server/skland/crypto.js` is pure and fully unit-tested against pinned vectors (`test/skland/crypto.test.js`) — the
|
||||
composition is the part a refactor breaks silently. With `header_ca = {"platform":"3","timestamp":<ts>,"dId":<dId>,
|
||||
"vName":"1.0.0"}` in that key order and `ts = floor(now/1000) - 2` (2 s behind):
|
||||
|
||||
```
|
||||
GET: raw = path + query + ts + compact(header_ca)
|
||||
POST: raw = path + compact(body ?? {}) + ts + compact(header_ca)
|
||||
sign = md5( hmacSha256_hex(cred_token, raw) ) // SHA-256 HMAC, lowercase hex, then MD5 of that hex
|
||||
```
|
||||
|
||||
The headers are `cred`, `dId`, `platform: 3`, `timestamp`, `vName: 1.0.0`, `sign` plus the Skland mobile client
|
||||
identity (`User-Agent: Skland/…`, `X-Requested-With: com.hypergryph.skland`, `Accept-Encoding: gzip`). Only
|
||||
`getBinding` / the exposed `signedGet` / `signedPost` are signed; `generate_cred_by_code` is not. The login endpoints
|
||||
send `deviceHeaders()` instead — a `X-DeviceId` (10 hex chars), `X-DeviceModel` and `X-DeviceType: 2`, cached for the
|
||||
process so the upstream sees one device.
|
||||
|
||||
## 9. 安全模型 (security model)
|
||||
|
||||
- **Ownership comes from server-verified credentials.** Account routes need a live transport token plus the JWT for
|
||||
account operations. UUID/UID/region edits do not confer ownership; role selection is checked against stored
|
||||
provider bindings. WebSocket identity and authorization are server-enforced too. Administrator diagnostic routes
|
||||
are closed by default and cannot access reserved rows. No arbitrary upstream URL/proxy endpoint is exposed.
|
||||
- **Same-origin requests and bounded traffic.** Cross-site Origin/Fetch Metadata requests are rejected. Account HTTP
|
||||
requests share a per-address token bucket (3/s, burst 120); request bodies are capped at 64 KiB. Use HTTPS for
|
||||
remote deployments. A fully stolen valid bearer/cookie remains a credential until expiry/revocation; JWT signing
|
||||
prevents modification, not copying. HttpOnly keeps the JWT out of JavaScript storage.
|
||||
- **A `cred` (with `cred_token`) is equivalent to account access.** It is a long-term API credential, not a session
|
||||
cookie. Treat it as a secret.
|
||||
- **The store keeps credentials in plaintext SQLite.** A deployment must treat the database file as a secret: restrict
|
||||
its permissions to the server account, keep it out of backups and repositories, and **never serve or expose it over
|
||||
HTTP**. The built-in static/media/pack handlers deny the configured DB, WAL and SHM paths, including realpath
|
||||
aliases and Windows filename casing. A reverse proxy or other file server needs its own protection. This is why
|
||||
the module is opt-in.
|
||||
- A player can **解绑** after leaving their room; that clears the stored credentials. Changing the Hypergryph password invalidates
|
||||
the stored credentials upstream, so the player rebinds.
|
||||
|
||||
## 10. Legal and project rules
|
||||
|
||||
The account flow follows the public official SDK; the legacy Skland flow also follows community descriptions.
|
||||
These website endpoints have no compatibility guarantee and may change without notice. The `appCode` in `crypto.js` is a public SDK constant visible in a
|
||||
decompiled client — it is **not** an authorisation or a permission. This project is non-commercial
|
||||
([AGENTS.md](../AGENTS.md) forbids ads, payments and other monetisation) and stays that way. Enabling the module, and
|
||||
the handling of the credentials it stores, is the operator's decision and their responsibility.
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Account test files (`node --test 'test/skland/*.test.js'`), against a stub upstream or widget — no account network.
|
||||
|
||||
| File | Pins |
|
||||
|---|---|
|
||||
| `test/skland/crypto.test.js` (10) | the signature composition (GET/POST, with and without query/body), the primitives, stability under a single changed input, the client identity headers, `makeDeviceId`, the cached `deviceHeaders` |
|
||||
| `test/skland/store.test.js` (10) | one row per player, full credential round-trip, merge-only update, unbind clearing credentials, WAL persistence, parent-dir creation, and migration preserving old QR credentials while persisting the official binding source |
|
||||
| `test/skland/client.test.js` (21) | QR/SMS chains, 100→101→confirmed polling, captcha retry, password → official binding-service grant/list without Skland, real device challenge 109, malformed grant/list and upstream errors, signed Skland requests and credential refresh |
|
||||
| `test/skland/http.test.js` (24) | server wiring, QR polling with official/Bilibili channel preservation, SMS/captcha, fresh password login without Skland credentials, password/grant captcha continuation on the same token, pending-login invalidation on unbind, actual device challenge and incorrect password, provider-aware role refresh and switching, player isolation and error responses |
|
||||
| `test/skland/captcha.test.js` (4) | fake Geetest v3/v4 widgets: exact proof/challenge hand-off, cancellation, missing proof, unsupported challenge and provider load errors; no real challenge or network |
|
||||
| `test/skland/docker.test.js` (8) | the one-command packaging: the compose service, the port, `SP_SKLAND`/`SP_SKLAND_DB`, the database path inside the mounted named volume (and outside every static mount), the Dockerfile's prepared+owned directory before `USER node` |
|
||||
| `test/skland/panel.test.js` (10) | `public/skland.html`: every endpoint it calls, the QR encoding `scanUrl` (not the scanId), the 2 s poll with its cleanup, explicit poll errors, the `needCaptcha` path and the "not enabled" answer |
|
||||
| `test/skland/api.test.js` (5) | the title adapter's token-only authorization header, error mapping, hello reuse and default/selected role choice |
|
||||
| `test/skland/scan.test.js` (7) | deterministic 10/60 s refresh boundaries, serial polling, manual refresh, pause/resume, late responses, completion retry and failure recovery |
|
||||
| `test/skland/session.test.js` (5) | JWT signature/claims, role ownership, cross-origin rejection, logout/replay, restart/expiry, DB download denial, late provider cancellation, same-name UID/region isolation and authenticated game transport |
|
||||
| `test/skland/entry.browser.test.js` (1, opt-in) | the real title/modal with a stub provider: QR, role/channel selection, password CAPTCHA, SMS, hidden/plain-text callsign, empty roles, unbinding, cached-entry checks, reload persistence and short landscape layout; skipped unless `SP_E2E=1` |
|
||||
| `test/skland/recovery.test.js` | independent logins restoring the same live Match/private state, UID/region and role-selection isolation, read-only lookup, explicit takeover/token rotation, stale selectors, unchanged solo/co-op windows, expired-seat cleanup and account-id compatibility with watch/kick/battle reports |
|
||||
| `test/skland/recovery.browser.test.js` (1, opt-in) | two isolated browser contexts with no shared saves: real title/login/recovery, exact state restoration, manual continue, empty/expired recovery, old-device reload and duplicated-tab safety, desktop/landscape-phone layout; `SP_E2E=1` |
|
||||
|
||||
## 12. Files
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `server/skland/crypto.js` | the signature, the appCodes, the device id and the login device headers |
|
||||
| `server/skland/client.js` | the three login chains, captcha, `getGrantCode`/`getCred`, the signed client, `getBinding` refresh |
|
||||
| `server/skland/store.js` | the SQLite credential schema, migrations and merge-only store |
|
||||
| `server/skland/service.js` | per-player orchestration, scan sessions, status/roles, `finishLogin`, `unbind` |
|
||||
| `server/skland/http.js` | administrator diagnostic JSON routes and bounded body reader |
|
||||
| `server/skland/auth.js`, `accountHttp.js` | persistent JWT/account ownership, revocation, stable game identity and scoped HTTP entry/recovery |
|
||||
| `server/skland/recovery.js` | read-only selected-role seat metadata and room/match recovery selectors |
|
||||
| `shared/accountIdentity.js` | shared UID + server-region key, independent of nickname and optional provider metadata |
|
||||
| `public/js/skland/captcha.js` | user-operated Geetest widget, loaded only when verification is needed |
|
||||
| `public/js/skland/entry.js`, `api.js` | title binding button/dialog, token-authenticated client, role selection and QR loading |
|
||||
| `public/js/skland/gate.js`, `resume.js` | checked account entry, safe automatic reconnect and the cross-device recovery picker |
|
||||
| `public/js/skland/scan.js` | automatic QR generation, 10/60 s refresh and serial 2 s confirmation polling |
|
||||
| `public/css/skland/binding.css` | feature-scoped styles, loaded by the entry without editing global stylesheets |
|
||||
| `server/skland/session.js` | reconnect-token resolver and the reserved game-binding namespace |
|
||||
|
||||
## 13. Keeping upstream merges small
|
||||
|
||||
Login logic, the callsign display, entry validation and CSS live under the module's own directories. The title
|
||||
renders these components, owns the dialog-open state and chooses 绑定账号/开始 from the verified nickname. `main.js`
|
||||
checks cached entry through `restoreBoundEntry`. The title mounts the account recovery picker; the obsolete local
|
||||
shortcut is removed from the lobby (the generic `ui/resumeMatch.js` helper remains available upstream). The identity
|
||||
helper stages explicit account tokens without persisting a claim before welcome. The server factory installs an
|
||||
optional account policy: small hooks in `server/net.js` cover connection, hello, successful attachment/token rotation
|
||||
and per-frame authorization; static handlers accept a private-file denylist. With no policy the upstream transport
|
||||
behaves as before. No server lobby, match, simulation or WebSocket message changes are needed.
|
||||
Language packs and this reference hold translations/documentation.
|
||||
Keep future account changes in these module files and preserve the small title/boot/recovery hooks when merging
|
||||
upstream. This reduces the conflict surface; edits to the same hooks/translations may still need a merge.
|
||||
|
||||
The Hypergryph and Bilibili brand images are referenced directly from the
|
||||
[official account page bundle](https://web.hycdn.cn/arknights/user/index.842215.js):
|
||||
[`hypergryph.d69547.png`](https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png) and
|
||||
[`bilibili.11810a.png`](https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png), checked on 2026-10-14.
|
||||
Images are decorative beside text labels, so a failed image request leaves the controls usable. No game art is added
|
||||
to git. The human-operated CAPTCHA temporarily releases the game's modal focus trap for the provider widget.
|
||||
Generated
+947
-940
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,7 @@
|
||||
"pixi-spine": "^4.0.6",
|
||||
"pixi.js": "^7.4.2",
|
||||
"preact": "^10.29.8",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"three": "^0.186.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Feature-local styles: keep upstream title/components styles untouched. */
|
||||
.sp-binding-callsign { display: grid; gap: .1rem; margin: 0; }
|
||||
.sp-binding-callsign__label { display: flex; align-items: baseline; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); font-weight: 500; letter-spacing: .04em; }
|
||||
.sp-binding-callsign__name { display: flex; align-items: center; gap: .12rem; margin: 0; color: var(--text-hi); font-size: calc(.26 * var(--t)); font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.sp-binding-callsign__icon { flex: none; color: var(--text-lo); }
|
||||
.sp-binding-callsign__name > span { min-width: 0; }
|
||||
.sp-binding-entry { display: flex; justify-content: flex-end; width: 4.9rem; margin-top: -.08rem; }
|
||||
.sp-binding-entry:empty { display: none; }
|
||||
.sp-binding-entry > .btn { max-width: 100%; white-space: normal; text-align: left; }
|
||||
.sp-binding .modal__body { display: grid; gap: .2rem; font-size: calc(.18 * var(--t)); }
|
||||
.sp-binding__intro { color: var(--text-lo); line-height: 1.6; margin: 0; }
|
||||
.sp-binding__tabs, .sp-binding__channels, .sp-binding__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; }
|
||||
.sp-binding__channels { color: var(--text-lo); font-size: calc(.15 * var(--t)); }
|
||||
.sp-binding__channels > span { display: inline-flex; align-items: center; gap: .08rem; }
|
||||
.sp-binding__brand { width: .26rem; height: .26rem; object-fit: contain; flex: none; }
|
||||
.sp-binding__scan { display: grid; justify-items: center; gap: .16rem; text-align: center; }
|
||||
.sp-binding__scan p { margin: 0; }
|
||||
.sp-binding__qr { width: 2.4rem; height: 2.4rem; max-width: 100%; image-rendering: pixelated; background: #fff; }
|
||||
.sp-binding__form, .sp-binding__roles { display: grid; gap: .16rem; }
|
||||
.sp-binding__form p, .sp-binding__roles p { margin: 0; line-height: 1.6; }
|
||||
.sp-binding__code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: .12rem; }
|
||||
.sp-binding__field { min-width: 0; }
|
||||
.sp-binding__role { justify-content: flex-start; gap: .14rem; height: auto; min-height: .65rem; text-align: left; white-space: normal; }
|
||||
.sp-binding__role > span { display: grid; gap: .04rem; overflow-wrap: anywhere; }
|
||||
.sp-binding__role small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
|
||||
.sp-binding__error { color: var(--red); overflow-wrap: anywhere; }
|
||||
.sp-binding__notice { color: var(--mint-500); }
|
||||
.sp-binding__captcha { border-top: 1px solid var(--line-2); padding-top: .16rem; }
|
||||
.sp-binding__verifying { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; background: rgba(0, 0, 0, .75); color: var(--text-hi); font-size: calc(.2 * var(--t)); }
|
||||
|
||||
/* Short landscape screens: the title scene can be taller than the browser's visual viewport. Anchor the account box
|
||||
to the top of the overlay and let its body scroll, so the close action and role list remain reachable. */
|
||||
@media (max-height: 600px) {
|
||||
.sp-binding { align-self: start; max-height: calc(100dvh - .3rem); margin-top: .15rem; }
|
||||
.sp-binding .modal__head { padding-top: .12rem; }
|
||||
.sp-binding .modal__body { padding-top: .1rem; padding-bottom: .14rem; }
|
||||
.sp-binding .modal__actions { padding-top: .1rem; padding-bottom: .1rem; }
|
||||
}
|
||||
|
||||
/* Recovery remains readable on another device even at the game's smallest landscape root scale. */
|
||||
.sp-account-resume { width: min(94vw, max(6.8rem, 420px)); max-height: calc(100dvh - 24px); }
|
||||
.sp-account-resume .modal__title { font-size: max(18px, calc(.26 * var(--t))); }
|
||||
.sp-account-resume .modal__body { display: grid; gap: max(.16rem, 10px); font-size: max(14px, calc(.18 * var(--t))); line-height: 1.7; }
|
||||
.sp-account-resume .modal__body p { margin: 0; }
|
||||
.sp-account-resume .btn { min-height: 44px; font-size: max(14px, calc(.17 * var(--t))); }
|
||||
+54
-1
@@ -1218,5 +1218,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
|
||||
"库存不足": "Out of stock"
|
||||
"库存不足": "Out of stock",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "Human verification is required. Use the button below to continue.",
|
||||
"开始人机验证": "Start verification",
|
||||
"正在加载验证组件…": "Loading verification…",
|
||||
"验证已取消,请点击按钮重试。": "Verification cancelled. Use the button to try again.",
|
||||
"验证通过,正在继续…": "Verified. Continuing…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "This verification type is not supported. Use SMS or QR login.",
|
||||
"验证组件加载失败,请重试。": "Could not load verification. Please try again.",
|
||||
"验证未完成,请重试。": "Verification was not completed. Please try again.",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "For the official server, you can log in with your phone number. For the Bilibili server, scan with Skland and follow the authorization prompts.",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "Account binding is disabled on this server. Ask the host to enable it before entering.",
|
||||
"游戏会话已失效,请关闭后重试。": "Your game session expired. Close this dialog and try again.",
|
||||
"连接失败,请重试。": "Connection failed. Please try again.",
|
||||
"二维码加载失败,请重试。": "Could not load the QR code. Please try again.",
|
||||
"服务器响应异常,请重试。": "Unexpected server response. Please try again.",
|
||||
"已绑定:{name}": "Bound: {name}",
|
||||
"绑定明日方舟账号": "Bind Arknights account",
|
||||
"登录成功,未找到明日方舟角色。": "Logged in, but no Arknights characters were found.",
|
||||
"登录失败,请重试。": "Login failed. Please try again.",
|
||||
"验证码已发送,请查看短信。": "Code sent. Check your text messages.",
|
||||
"二维码已失效,请重新获取。": "The QR code expired. Get a new code.",
|
||||
"请在验证窗口中完成人机验证。": "Complete human verification in the verification window.",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "Your character nickname becomes your callsign after binding. You can start once it is retrieved.",
|
||||
"绑定账号后自动获取": "Retrieved after binding your account",
|
||||
"绑定账号": "Bind account",
|
||||
"· 绑定账号并开始后将自动加入": "· Join automatically after binding your account and starting",
|
||||
"选择明日方舟角色": "Select an Arknights character",
|
||||
"刷新角色": "Refresh characters",
|
||||
"解绑账号": "Unbind account",
|
||||
"登录方式": "Login method",
|
||||
"森空岛扫码": "Skland QR login",
|
||||
"验证码": "Verification code",
|
||||
"密码": "Password",
|
||||
"官服": "Official server",
|
||||
"B服:扫码后按提示完成授权": "Bilibili: scan and follow the authorization prompts",
|
||||
"使用森空岛 App 扫描二维码": "Scan the QR code with the Skland app",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "Scan and confirm with the Skland app. Supports the official and Bilibili servers.",
|
||||
"重新获取二维码": "Get a new QR code",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "The QR code refreshes every 10 seconds. You can also refresh it manually.",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "Scanned. Waiting for confirmation; the QR code will refresh automatically after 1 minute.",
|
||||
"获取二维码": "Get QR code",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "Use a Hypergryph account for the official server. For Bilibili, choose Skland QR login.",
|
||||
"手机号": "Phone number",
|
||||
"{n} 秒后重发": "Resend in {n} seconds",
|
||||
"发送验证码": "Send code",
|
||||
"登录并绑定": "Log in and bind",
|
||||
"恢复账号对局": "Resume Account Match",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "Sign in to the same account and select the same server and character to continue an unexpired match on another device. Matches cannot be restored after a server restart.",
|
||||
"正在查询账号对局…": "Checking for account matches…",
|
||||
"此对局已结束或过期,请刷新后重试。": "This match has ended or expired. Refresh and try again.",
|
||||
"当前角色没有可恢复的对局。": "There is no match to resume for the selected character.",
|
||||
"对局进行中": "Match in progress",
|
||||
"同盟等候中": "Waiting in the alliance",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "If this character is still online on another device, resuming will disconnect that device."
|
||||
}
|
||||
+54
-1
@@ -1224,5 +1224,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
|
||||
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
|
||||
"库存不足": "在庫不足"
|
||||
"库存不足": "在庫不足",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "本人確認が必要です。下のボタンから認証を完了してください。",
|
||||
"开始人机验证": "認証を開始",
|
||||
"正在加载验证组件…": "認証を読み込み中…",
|
||||
"验证已取消,请点击按钮重试。": "認証をキャンセルしました。ボタンから再試行してください。",
|
||||
"验证通过,正在继续…": "認証が完了しました。処理を続けています…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "この認証形式には対応していません。SMSまたはQRコードでログインしてください。",
|
||||
"验证组件加载失败,请重试。": "認証を読み込めませんでした。再試行してください。",
|
||||
"验证未完成,请重试。": "認証が完了していません。再試行してください。",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "公式サーバーは電話番号でログインできます。Bilibiliサーバーは森空島でQRコードを読み取り、画面の案内に従って連携を許可してください。",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "このサーバーではアカウント連携が無効です。管理者に有効化を依頼してから入場してください。",
|
||||
"游戏会话已失效,请关闭后重试。": "ゲームセッションが無効です。閉じてから再試行してください。",
|
||||
"连接失败,请重试。": "接続に失敗しました。再試行してください。",
|
||||
"二维码加载失败,请重试。": "QRコードを読み込めませんでした。再試行してください。",
|
||||
"服务器响应异常,请重试。": "サーバーの応答が異常です。再試行してください。",
|
||||
"已绑定:{name}": "連携済み:{name}",
|
||||
"绑定明日方舟账号": "アークナイツのアカウントを連携",
|
||||
"登录成功,未找到明日方舟角色。": "ログインしましたが、アークナイツのキャラクターが見つかりません。",
|
||||
"登录失败,请重试。": "ログインに失敗しました。再試行してください。",
|
||||
"验证码已发送,请查看短信。": "認証コードを送信しました。SMSを確認してください。",
|
||||
"二维码已失效,请重新获取。": "QRコードの有効期限が切れました。再取得してください。",
|
||||
"请在验证窗口中完成人机验证。": "認証ウィンドウで認証を完了してください。",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "連携後、キャラクター名をコードネームとして自動取得します。取得できたら開始できます。",
|
||||
"绑定账号后自动获取": "アカウント連携後に自動取得",
|
||||
"绑定账号": "アカウント連携",
|
||||
"· 绑定账号并开始后将自动加入": "· アカウント連携後、開始すると自動で参加します",
|
||||
"选择明日方舟角色": "アークナイツのキャラクターを選択",
|
||||
"刷新角色": "キャラクターを更新",
|
||||
"解绑账号": "アカウント連携を解除",
|
||||
"登录方式": "ログイン方法",
|
||||
"森空岛扫码": "森空島でQRログイン",
|
||||
"验证码": "認証コード",
|
||||
"密码": "パスワード",
|
||||
"官服": "公式サーバー",
|
||||
"B服:扫码后按提示完成授权": "Bilibili:QR読取後に案内に従って連携を許可",
|
||||
"使用森空岛 App 扫描二维码": "森空島アプリでQRコードを読み取る",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "森空島アプリでQRコードを読み取り、確認してください。公式・Bilibiliサーバーに対応しています。",
|
||||
"重新获取二维码": "QRコードを再取得",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "QRコードは10秒ごとに自動更新されます。手動でも更新できます。",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "読み取り済み、確認待ちです。QRコードは1分後に自動更新されます。",
|
||||
"获取二维码": "QRコードを取得",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "公式サーバーはHypergryphアカウントでログインしてください。Bilibiliは森空島のQRログインを選択してください。",
|
||||
"手机号": "電話番号",
|
||||
"{n} 秒后重发": "{n} 秒後に再送信",
|
||||
"发送验证码": "認証コードを送信",
|
||||
"登录并绑定": "ログインして連携",
|
||||
"恢复账号对局": "アカウントの対局を再開",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "同じアカウントでログインし、同じサーバーのキャラクターを選択すると、有効期限内の対局を別の端末で続行できます。サーバー再起動後は復元できません。",
|
||||
"正在查询账号对局…": "アカウントの対局を確認中…",
|
||||
"此对局已结束或过期,请刷新后重试。": "この対局は終了したか、有効期限が切れています。更新して再試行してください。",
|
||||
"当前角色没有可恢复的对局。": "選択中のキャラクターには再開できる対局がありません。",
|
||||
"对局进行中": "対局進行中",
|
||||
"同盟等候中": "同盟で待機中",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "このキャラクターが別の端末でオンラインの場合、再開すると以前の接続は切断されます。"
|
||||
}
|
||||
+54
-1
@@ -1220,5 +1220,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
|
||||
"库存不足": "재고 부족",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)."
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "자동 입력 방지 인증이 필요합니다. 아래 버튼을 눌러 인증해 주세요.",
|
||||
"开始人机验证": "인증 시작",
|
||||
"正在加载验证组件…": "인증을 불러오는 중…",
|
||||
"验证已取消,请点击按钮重试。": "인증이 취소되었습니다. 버튼을 눌러 다시 시도해 주세요.",
|
||||
"验证通过,正在继续…": "인증이 완료되어 계속 진행합니다…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "지원하지 않는 인증 방식입니다. SMS 또는 QR 코드로 로그인해 주세요.",
|
||||
"验证组件加载失败,请重试。": "인증을 불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"验证未完成,请重试。": "인증을 완료하지 못했습니다. 다시 시도해 주세요.",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "공식 서버는 휴대폰 번호로 로그인할 수 있습니다. Bilibili 서버는 스카이랜드로 QR 코드를 스캔하고 화면 안내에 따라 연동을 승인해 주세요.",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "이 서버에서는 계정 연동이 비활성화되어 있습니다. 서버 관리자에게 활성화를 요청한 뒤 입장하세요.",
|
||||
"游戏会话已失效,请关闭后重试。": "게임 세션이 만료되었습니다. 창을 닫고 다시 시도해 주세요.",
|
||||
"连接失败,请重试。": "연결하지 못했습니다. 다시 시도해 주세요.",
|
||||
"二维码加载失败,请重试。": "QR 코드를 불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"服务器响应异常,请重试。": "서버 응답에 문제가 있습니다. 다시 시도해 주세요.",
|
||||
"已绑定:{name}": "연동됨: {name}",
|
||||
"绑定明日方舟账号": "명일방주 계정 연동",
|
||||
"登录成功,未找到明日方舟角色。": "로그인했으나 명일방주 캐릭터를 찾지 못했습니다.",
|
||||
"登录失败,请重试。": "로그인하지 못했습니다. 다시 시도해 주세요.",
|
||||
"验证码已发送,请查看短信。": "인증 코드를 보냈습니다. 문자 메시지를 확인하세요.",
|
||||
"二维码已失效,请重新获取。": "QR 코드가 만료되었습니다. 새로 발급해 주세요.",
|
||||
"请在验证窗口中完成人机验证。": "인증 창에서 사람 인증을 완료해 주세요.",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "연동 후 캐릭터 닉네임을 코드네임으로 자동으로 가져옵니다. 가져오면 시작할 수 있습니다.",
|
||||
"绑定账号后自动获取": "계정 연동 후 자동으로 가져옵니다",
|
||||
"绑定账号": "계정 연동",
|
||||
"· 绑定账号并开始后将自动加入": "· 계정 연동 후 시작하면 자동으로 참가합니다",
|
||||
"选择明日方舟角色": "명일방주 캐릭터 선택",
|
||||
"刷新角色": "캐릭터 새로고침",
|
||||
"解绑账号": "계정 연동 해제",
|
||||
"登录方式": "로그인 방법",
|
||||
"森空岛扫码": "스카이랜드 QR 로그인",
|
||||
"验证码": "인증 코드",
|
||||
"密码": "비밀번호",
|
||||
"官服": "공식 서버",
|
||||
"B服:扫码后按提示完成授权": "Bilibili: 스캔 후 안내에 따라 연동 승인",
|
||||
"使用森空岛 App 扫描二维码": "스카이랜드 앱으로 QR 코드 스캔",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "스카이랜드 앱으로 QR 코드를 스캔하고 확인하세요. 공식 및 Bilibili 서버를 지원합니다.",
|
||||
"重新获取二维码": "QR 코드 재발급",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "QR 코드는 10초마다 자동으로 갱신됩니다. 직접 갱신할 수도 있습니다.",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "스캔 완료, 확인을 기다리고 있습니다. QR 코드는 1분 후 자동으로 갱신됩니다.",
|
||||
"获取二维码": "QR 코드 발급",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "공식 서버는 Hypergryph 계정으로 로그인하세요. Bilibili 서버는 스카이랜드 QR 로그인을 선택하세요.",
|
||||
"手机号": "휴대폰 번호",
|
||||
"{n} 秒后重发": "{n}초 후 재전송",
|
||||
"发送验证码": "인증 코드 전송",
|
||||
"登录并绑定": "로그인 및 연동",
|
||||
"恢复账号对局": "계정 대전 이어하기",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "같은 계정으로 로그인하고 같은 서버의 캐릭터를 선택하면 다른 기기에서 만료되지 않은 대전을 이어할 수 있습니다. 서버가 재시작되면 복구할 수 없습니다.",
|
||||
"正在查询账号对局…": "계정 대전 확인 중…",
|
||||
"此对局已结束或过期,请刷新后重试。": "대전이 종료되었거나 만료되었습니다. 새로고침한 후 다시 시도하세요.",
|
||||
"当前角色没有可恢复的对局。": "현재 캐릭터로 이어할 수 있는 대전이 없습니다.",
|
||||
"对局进行中": "대전 진행 중",
|
||||
"同盟等候中": "동맹 대기 중",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "이 캐릭터가 다른 기기에 접속 중이라면 이어하기 후 기존 연결이 끊어집니다."
|
||||
}
|
||||
+54
-1
@@ -1222,5 +1222,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
|
||||
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
|
||||
"库存不足": "庫存不足"
|
||||
"库存不足": "庫存不足",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "需要人機驗證,請點擊下方按鈕完成驗證。",
|
||||
"开始人机验证": "開始人機驗證",
|
||||
"正在加载验证组件…": "正在載入驗證元件…",
|
||||
"验证已取消,请点击按钮重试。": "驗證已取消,請點擊按鈕重試。",
|
||||
"验证通过,正在继续…": "驗證通過,正在繼續…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "暫不支援此驗證類型,請使用驗證碼或掃碼登入。",
|
||||
"验证组件加载失败,请重试。": "驗證元件載入失敗,請重試。",
|
||||
"验证未完成,请重试。": "驗證未完成,請重試。",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "官服可使用手機號登入;B服請使用森空島掃碼,並按頁面提示完成授權。",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "此伺服器未啟用帳號綁定,請聯絡服主開啟後再進入。",
|
||||
"游戏会话已失效,请关闭后重试。": "遊戲工作階段已失效,請關閉後重試。",
|
||||
"连接失败,请重试。": "連線失敗,請重試。",
|
||||
"二维码加载失败,请重试。": "二維碼載入失敗,請重試。",
|
||||
"服务器响应异常,请重试。": "伺服器回應異常,請重試。",
|
||||
"已绑定:{name}": "已綁定:{name}",
|
||||
"绑定明日方舟账号": "綁定明日方舟帳號",
|
||||
"登录成功,未找到明日方舟角色。": "登入成功,未找到明日方舟角色。",
|
||||
"登录失败,请重试。": "登入失敗,請重試。",
|
||||
"验证码已发送,请查看短信。": "驗證碼已傳送,請查看簡訊。",
|
||||
"二维码已失效,请重新获取。": "二維碼已失效,請重新取得。",
|
||||
"请在验证窗口中完成人机验证。": "請在驗證視窗中完成人機驗證。",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "綁定後自動取得角色暱稱作為博士代號,取得成功後即可開始。",
|
||||
"绑定账号后自动获取": "綁定帳號後自動取得",
|
||||
"绑定账号": "綁定帳號",
|
||||
"· 绑定账号并开始后将自动加入": "· 綁定帳號並開始後將自動加入",
|
||||
"选择明日方舟角色": "選擇明日方舟角色",
|
||||
"刷新角色": "重新整理角色",
|
||||
"解绑账号": "解除帳號綁定",
|
||||
"登录方式": "登入方式",
|
||||
"森空岛扫码": "森空島掃碼",
|
||||
"验证码": "驗證碼",
|
||||
"密码": "密碼",
|
||||
"官服": "官服",
|
||||
"B服:扫码后按提示完成授权": "B服:掃碼後按提示完成授權",
|
||||
"使用森空岛 App 扫描二维码": "使用森空島 App 掃描二維碼",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "使用森空島 App 掃碼並確認,支援官服和 B 服。",
|
||||
"重新获取二维码": "重新取得二維碼",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "二維碼每 10 秒自動更新,也可手動更新。",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "已掃碼,等待確認;二維碼將在 1 分鐘後自動更新。",
|
||||
"获取二维码": "取得二維碼",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "使用鷹角帳號登入官服。B服請選擇森空島掃碼。",
|
||||
"手机号": "手機號碼",
|
||||
"{n} 秒后重发": "{n} 秒後重傳",
|
||||
"发送验证码": "傳送驗證碼",
|
||||
"登录并绑定": "登入並綁定",
|
||||
"恢复账号对局": "恢復帳號對局",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "登入同一帳號並選擇同一區服角色,可在其他裝置繼續尚未過期的對局。伺服器重新啟動後無法恢復。",
|
||||
"正在查询账号对局…": "正在查詢帳號對局…",
|
||||
"此对局已结束或过期,请刷新后重试。": "此對局已結束或過期,請重新整理後重試。",
|
||||
"当前角色没有可恢复的对局。": "目前角色沒有可恢復的對局。",
|
||||
"对局进行中": "對局進行中",
|
||||
"同盟等候中": "同盟等候中",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "如果此角色仍在其他裝置上線,恢復後將中斷舊連線。"
|
||||
}
|
||||
+12
-9
@@ -8,13 +8,13 @@
|
||||
// otherwise → Lobby
|
||||
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
|
||||
// session is online (after a short grace period in case the server restores a room on resume).
|
||||
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and
|
||||
// Reloading a tab that already passed the title re-enters after checking its current role binding and
|
||||
// resumes the server session with the saved token; stale room/match state is dropped if the
|
||||
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
|
||||
// `identity.init()` (which token this tab may use without stealing another live tab's session)
|
||||
// before the first connect; a page restored from the back/forward cache reloads.
|
||||
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
|
||||
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale.
|
||||
// drop stale match state (store.js sessionResetNotice); an unauthenticated transport returns to the title for checked entry.
|
||||
//
|
||||
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
|
||||
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
|
||||
@@ -41,6 +41,7 @@ import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectat
|
||||
import { data } from './data.js';
|
||||
import { GAME_FILES } from './ui/gameComponents.js';
|
||||
import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
import { GameScreen } from './screens/game.js';
|
||||
@@ -145,6 +146,7 @@ function backToLobby() {
|
||||
}
|
||||
|
||||
function onWelcome(msg) {
|
||||
const accountAccepted = acceptAccountWelcome(msg);
|
||||
identity.saveToken(msg.token);
|
||||
identity.rememberMatch(null);
|
||||
const prev = store.get();
|
||||
@@ -156,8 +158,8 @@ function onWelcome(msg) {
|
||||
|
||||
if (prevId != null && prevId !== msg.playerId) {
|
||||
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
|
||||
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
|
||||
const notice = sessionResetNotice(prev, msg.playerId);
|
||||
// expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry.
|
||||
const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null;
|
||||
backToLobby();
|
||||
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
|
||||
} else if (prev.room || prev.match.public) {
|
||||
@@ -303,7 +305,7 @@ function App() {
|
||||
return html`<div class="app-root">
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
|
||||
<${ConnectionBanner} />
|
||||
<${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} />
|
||||
<${ToastHost} />
|
||||
<${UiHosts} />
|
||||
<${GuideHost} />
|
||||
@@ -360,7 +362,7 @@ async function boot() {
|
||||
const entered = identity.wasEntered() && !!savedName;
|
||||
store.set((s) => ({
|
||||
me: { ...s.me, name: savedName },
|
||||
session: { entered },
|
||||
session: { entered: false },
|
||||
ui: { ...s.ui, pendingJoin },
|
||||
}));
|
||||
|
||||
@@ -378,9 +380,10 @@ async function boot() {
|
||||
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
|
||||
data.load('local').catch(() => {});
|
||||
|
||||
const connectWhenReady = identityReady.then(() => {
|
||||
if (entered) net.setName(savedName);
|
||||
else net.connect();
|
||||
const connectWhenReady = identityReady.then((token) => {
|
||||
if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true });
|
||||
identity.setEntered(false);
|
||||
net.connect();
|
||||
});
|
||||
// the language (and its UI translations) before the first render: no Chinese flash for an English player
|
||||
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
|
||||
|
||||
@@ -746,6 +746,7 @@ export function createIdentity(deps = {}) {
|
||||
let initPromise = null;
|
||||
let initialized = false;
|
||||
let resumeBusy = false;
|
||||
let explicitClaim = false; // account entry POST supplied the token; do not persist until welcome
|
||||
let current = null; // token this tab uses (null = let the server create a session)
|
||||
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
|
||||
let owned = sget(session, K_TOKEN);
|
||||
@@ -811,6 +812,7 @@ export function createIdentity(deps = {}) {
|
||||
// The original page can reappear after resume's query but before its hello. Keep our old
|
||||
// session in storage and give back the tentative selection immediately in that interval.
|
||||
if (current && tokenHash(current) === hash) {
|
||||
explicitClaim = false;
|
||||
if (current === owned) {
|
||||
owned = null; welcomeAt = null; welcomed = false;
|
||||
sdel(session, K_WELCOME);
|
||||
@@ -911,6 +913,13 @@ export function createIdentity(deps = {}) {
|
||||
} catch { return false; }
|
||||
finally { resumeBusy = false; }
|
||||
},
|
||||
/** Select an authenticated account entry token without treating an unacknowledged hello as a saved session. */
|
||||
stageToken(token) {
|
||||
if (!isToken(token)) return false;
|
||||
current = token;
|
||||
explicitClaim = true;
|
||||
return true;
|
||||
},
|
||||
/** @returns {string} remembered player name ('' if none) */
|
||||
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
|
||||
/** @param {string} name */
|
||||
@@ -924,6 +933,7 @@ export function createIdentity(deps = {}) {
|
||||
},
|
||||
/** A shared candidate must never replace a connected session, including a hello already in flight. */
|
||||
getTokenClaim() {
|
||||
if (explicitClaim) return { noReplace: false };
|
||||
return current === owned && owned
|
||||
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
|
||||
: { noReplace: true };
|
||||
@@ -935,6 +945,7 @@ export function createIdentity(deps = {}) {
|
||||
/** @param {string} token from `welcome` */
|
||||
saveToken(token) {
|
||||
if (!isToken(token)) return;
|
||||
explicitClaim = false;
|
||||
if (current !== token) rememberMatch(null);
|
||||
if (owned !== token || welcomeAt == null) welcomeAt = now();
|
||||
owned = token;
|
||||
@@ -946,6 +957,7 @@ export function createIdentity(deps = {}) {
|
||||
},
|
||||
/** Forget this tab's token (e.g. the server said the session is invalid). */
|
||||
clearToken() {
|
||||
explicitClaim = false;
|
||||
rememberMatch(null);
|
||||
const t = current || sget(session, K_TOKEN);
|
||||
current = null;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
|
||||
// config.json mode texts come localized from data.js.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
|
||||
@@ -321,7 +320,6 @@ export function LobbyScreen() {
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${ResumeMatchButton} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
|
||||
+25
-18
@@ -1,8 +1,8 @@
|
||||
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu
|
||||
// Title screen: season-style backdrop, big title 卫戍协议:盟约, bound role callsign, 开始, the language menu
|
||||
// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and
|
||||
// the small wordmark above it hides).
|
||||
//
|
||||
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
|
||||
// Binding reads the role nickname; pressing 开始 validates it (1..NAME_MAX_LEN chars, no control characters), stores it,
|
||||
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
|
||||
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
|
||||
//
|
||||
@@ -10,17 +10,18 @@
|
||||
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
|
||||
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { AccountResumeMatchButton } from '../skland/resume.js';
|
||||
import { AccountBindingEntry, BoundCallsign } from '../skland/entry.js';
|
||||
import { enterBoundAccount, restoreBoundEntry } from '../skland/gate.js';
|
||||
import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
|
||||
import { html, Button, Icon, MicroLabel, PingPill } from '../ui/components.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.js';
|
||||
import { data, useData } from '../data.js';
|
||||
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
|
||||
import { FullscreenButton, PwaInstallButton } from '../ui/device.js';
|
||||
import { LangToggle, useLang } from '../ui/lang.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
import { scriptOf } from '../../../shared/i18nPacks.js';
|
||||
@@ -192,8 +193,10 @@ export function TitleScreen() {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const pendingJoin = useStore((s) => s.ui.pendingJoin);
|
||||
useLang(); // re-render on a language switch
|
||||
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
|
||||
const [name, setName] = useState('');
|
||||
const [bindingOpen, setBindingOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [entering, setEntering] = useState(false);
|
||||
const assetsSettled = useData('assets');
|
||||
const assets = data.get('assets');
|
||||
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
|
||||
@@ -209,16 +212,17 @@ export function TitleScreen() {
|
||||
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
|
||||
|
||||
const valid = isValidName(name);
|
||||
const start = () => {
|
||||
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
|
||||
enterSession(name);
|
||||
const start = async () => {
|
||||
if (entering) return;
|
||||
if (!valid) { setBindingOpen(true); return; }
|
||||
setEntering(true);
|
||||
if (!await restoreBoundEntry(sanitizeName)) { setName(''); setBindingOpen(true); }
|
||||
setEntering(false);
|
||||
};
|
||||
|
||||
const online = conn.status === 'online' || conn.status === 'connected';
|
||||
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
|
||||
|
||||
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
|
||||
const touchUi = useMemo(() => detectFeatures().coarse, []);
|
||||
// a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it
|
||||
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
|
||||
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
|
||||
@@ -267,12 +271,10 @@ export function TitleScreen() {
|
||||
<div class="title-login">
|
||||
${pendingJoin ? html`<div class="title-invite">
|
||||
<${Icon} name="key" />
|
||||
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 输入代号后将自动加入')}</span>
|
||||
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 绑定账号并开始后将自动加入')}</span>
|
||||
</div>` : null}
|
||||
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
|
||||
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
|
||||
onInput=${setName} onEnter=${start} />
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
|
||||
<${BoundCallsign} name=${name} />
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" data-testid="binding-start" disabled=${entering} onClick=${start}>${valid ? t('开始') : t('绑定账号')}<//>
|
||||
<div class="title-conn">
|
||||
<span class=${`status-dot ${dotClass}`}></span>
|
||||
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
|
||||
@@ -283,12 +285,17 @@ export function TitleScreen() {
|
||||
<${FullscreenButton} class="title-fs" />
|
||||
</div>
|
||||
</div>
|
||||
<${AccountBindingEntry} name=${name} open=${bindingOpen} onOpen=${() => setBindingOpen(true)} onClose=${() => setBindingOpen(false)}
|
||||
onNickname=${(nickname) => {
|
||||
const value = sanitizeName(nickname); setName(value); identity.saveName(value);
|
||||
if (!value) identity.setEntered(false);
|
||||
}} />
|
||||
</main>
|
||||
|
||||
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
|
||||
|
||||
<footer class="title-foot">
|
||||
<${ResumeMatchButton} />
|
||||
<${AccountResumeMatchButton} bound=${valid} onResume=${(id) => enterBoundAccount(sanitizeName, { recoveryId: id })} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<span class="title-foot__meta">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Independent title adapter: the game reconnect token stays in a header, never a URL, QR code or provider request.
|
||||
export class BindingError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
}
|
||||
|
||||
export function createBindingApi({ token, fetchImpl = globalThis.fetch, signal } = {}) {
|
||||
return async (path, body) => {
|
||||
const credential = typeof token === 'function' ? token() : token;
|
||||
if (!credential) throw new BindingError('session');
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(`/skland/session/${path}`, {
|
||||
method: body === undefined ? 'GET' : 'POST', cache: 'no-store', signal,
|
||||
credentials: 'same-origin',
|
||||
headers: { authorization: `Bearer ${credential}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new BindingError('network');
|
||||
}
|
||||
if (response.status === 404) throw new BindingError('off');
|
||||
if (response.status === 401) throw new BindingError('session');
|
||||
let result;
|
||||
try { result = await response.json(); } catch { throw new BindingError('response'); }
|
||||
if (!response.ok) throw new BindingError(result.code === 'resume_expired' ? 'resume_expired' : 'upstream', result.error);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/** Establish hello without pressing Start or changing the router's entered flag. */
|
||||
export async function ensureBindingSession({ net, identity, name, timeoutMs = 10_000 }) {
|
||||
await identity.init();
|
||||
if (net.status === 'online' && identity.getToken()) return identity.getToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const clean = () => { clearTimeout(timer); offWelcome(); offError(); };
|
||||
const offWelcome = net.on('welcome', (message) => { clean(); resolve(message.token); });
|
||||
const offError = net.on('helloError', () => { clean(); reject(new BindingError('session')); });
|
||||
const timer = setTimeout(() => { clean(); reject(new BindingError('network')); }, timeoutMs);
|
||||
net.setName(name);
|
||||
net.connect();
|
||||
});
|
||||
}
|
||||
|
||||
export { roleKey } from '../../../shared/accountIdentity.js';
|
||||
import { roleKey, hasRoleIdentity } from '../../../shared/accountIdentity.js';
|
||||
|
||||
export function preferredRole(roles, selected) {
|
||||
const named = roles?.filter((role) => hasRoleIdentity(role) && typeof role?.nickName === 'string' && role.nickName.trim()) || [];
|
||||
return named.find((role) => roleKey(role) === selected) || named.find((role) => role.isDefault) || named[0] || null;
|
||||
}
|
||||
|
||||
let qrLoader;
|
||||
export async function qrDataUrl(value) {
|
||||
if (typeof globalThis.qrcode !== 'function') {
|
||||
qrLoader ||= new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
const fail = () => { clearTimeout(timer); script.remove(); qrLoader = null; reject(new BindingError('qr')); };
|
||||
const timer = setTimeout(fail, 10_000);
|
||||
script.src = '/vendor/qrcode.js';
|
||||
script.onload = () => { clearTimeout(timer); if (typeof globalThis.qrcode === 'function') resolve(); else fail(); };
|
||||
script.onerror = fail;
|
||||
document.head.append(script);
|
||||
});
|
||||
await qrLoader;
|
||||
}
|
||||
const qr = globalThis.qrcode(0, 'M');
|
||||
qr.addData(value); qr.make();
|
||||
return qr.createDataURL(6, 4);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Interactive Geetest hand-off, following the official HG account SDK. The user completes the provider's widget;
|
||||
// only its proof is returned to the original login request. No phone, password or account token is sent here.
|
||||
const scripts = new Map();
|
||||
const PROVIDERS = {
|
||||
v4: { src: 'https://static.geetest.com/v4/gt4.js', init: 'initGeetest4' },
|
||||
v3: { src: 'https://static.geetest.com/static/tools/gt.js', init: 'initGeetest' },
|
||||
};
|
||||
|
||||
async function loadProvider(version) {
|
||||
const provider = PROVIDERS[version];
|
||||
if (typeof globalThis[provider.init] === 'function') return;
|
||||
if (!scripts.has(version)) {
|
||||
const pending = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
const fail = () => { clearTimeout(timer); script.remove(); reject(new Error('captcha_load')); };
|
||||
const timer = setTimeout(fail, 20_000);
|
||||
script.src = provider.src;
|
||||
script.async = true;
|
||||
script.onerror = fail;
|
||||
script.onload = () => {
|
||||
clearTimeout(timer);
|
||||
if (typeof globalThis[provider.init] === 'function') resolve();
|
||||
else fail();
|
||||
};
|
||||
document.head.append(script);
|
||||
});
|
||||
scripts.set(version, pending);
|
||||
pending.catch(() => scripts.delete(version));
|
||||
}
|
||||
await scripts.get(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called only by the user's captcha button. `scope` and `load` allow isolated widget contract tests without a
|
||||
* network call or a real challenge. Resolves null on user cancellation; never completes a real puzzle itself.
|
||||
*/
|
||||
export async function solveCaptcha(challenge, { scope = globalThis, load = loadProvider } = {}) {
|
||||
const version = challenge.geetestId ? 'v4' : challenge.gt && challenge.challenge ? 'v3' : null;
|
||||
if (!version) throw new Error('captcha_type');
|
||||
await load(version);
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const init = scope[PROVIDERS[version].init];
|
||||
const options = version === 'v4'
|
||||
? { captchaId: challenge.geetestId, riskType: challenge.riskType, product: 'bind', language: 'zho' }
|
||||
: { gt: challenge.gt, challenge: challenge.challenge, new_captcha: challenge.new_captcha,
|
||||
offline: false, product: 'bind', https: true, lang: 'zh-cn' };
|
||||
init(options, (widget) => {
|
||||
const finish = (proof, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
widget.destroy?.();
|
||||
if (error) reject(error); else resolve(proof);
|
||||
};
|
||||
widget.onSuccess(() => {
|
||||
const proof = widget.getValidate();
|
||||
if (!proof) { finish(null, new Error('captcha_validate')); return; }
|
||||
finish(version === 'v4' ? { ...proof, challenge: challenge.challenge } : proof);
|
||||
});
|
||||
widget.onError((error) => {
|
||||
if (error?.error_code !== 'error_01') finish(null, new Error('captcha_validate'));
|
||||
});
|
||||
widget.onClose(() => finish(null));
|
||||
widget.onReady(() => {
|
||||
if (version === 'v4') widget.showCaptcha(); else widget.verify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Login UI, callsign display and HTTP integration stay inside the account module.
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Modal, Icon } from '../ui/components.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { useLang } from '../ui/lang.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { createBindingApi, ensureBindingSession, preferredRole, roleKey, qrDataUrl } from './api.js';
|
||||
import { solveCaptcha } from './captcha.js';
|
||||
import { selectedBoundRole, syncAccountSession, resetAccountSession } from './gate.js';
|
||||
import { createQrLogin } from './scan.js';
|
||||
import { hasRoleIdentity } from '../../../shared/accountIdentity.js';
|
||||
|
||||
// Brand images served by the official Arknights account page; no extracted game art is stored in this repository.
|
||||
const LOGOS = {
|
||||
hg: 'https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png',
|
||||
bili: 'https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png',
|
||||
};
|
||||
|
||||
function Brand({ kind }) {
|
||||
return html`<img class="sp-binding__brand" src=${LOGOS[kind]} alt="" referrerpolicy="no-referrer" onError=${(event) => { event.currentTarget.hidden = true; }} />`;
|
||||
}
|
||||
|
||||
function errorText(error) {
|
||||
if (error.code === 'off') return t('此服务器未启用账号绑定,请联系服主开启后再进入。');
|
||||
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
|
||||
if (error.code === 'network') return t('连接失败,请重试。');
|
||||
if (error.code === 'qr') return t('二维码加载失败,请重试。');
|
||||
if (error.code === 'response') return t('服务器响应异常,请重试。');
|
||||
if (error.message === 'scan_response') return t('服务器响应异常,请重试。');
|
||||
if (error.message === 'scan_expired') return t('二维码已失效,请重新获取。');
|
||||
if (error.message === 'captcha_load') return t('验证组件加载失败,请重试。');
|
||||
if (error.message === 'captcha_type') return t('暂不支持此验证类型,请使用验证码或扫码登录。');
|
||||
if (error.message === 'captcha_validate') return t('验证未完成,请重试。');
|
||||
return error.message;
|
||||
}
|
||||
|
||||
export function BoundCallsign({ name }) {
|
||||
if (!name) return null;
|
||||
return html`<dl class="sp-binding-callsign">
|
||||
<dt class="sp-binding-callsign__label">${t('博士代号')}<span class="micro">CALLSIGN</span></dt>
|
||||
<dd class="sp-binding-callsign__name"><${Icon} name="user" class="sp-binding-callsign__icon" /><span data-testid="bound-callsign">${name}</span></dd>
|
||||
</dl>`;
|
||||
}
|
||||
|
||||
export function AccountBindingEntry({ name, open, onOpen, onClose, onNickname }) {
|
||||
useLang();
|
||||
const playerId = useStore((state) => state.me.playerId);
|
||||
const checkedPlayer = useRef(playerId);
|
||||
const nicknameRef = useRef(onNickname);
|
||||
nicknameRef.current = onNickname;
|
||||
useEffect(() => {
|
||||
if (document.querySelector('link[data-sp-binding]')) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet'; link.href = '/css/skland/binding.css'; link.dataset.spBinding = '';
|
||||
document.head.append(link);
|
||||
}, []);
|
||||
// Restore from the authenticated status, never from the locally remembered callsign.
|
||||
useEffect(() => {
|
||||
if (checkedPlayer.current !== playerId) {
|
||||
checkedPlayer.current = playerId;
|
||||
if (!open) nicknameRef.current('');
|
||||
}
|
||||
if (open) return undefined;
|
||||
let alive = true;
|
||||
const abort = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const token = await ensureBindingSession({ net, identity, name: t('博士') });
|
||||
const state = await createBindingApi({ token, signal: abort.signal })('status');
|
||||
if (alive) {
|
||||
await syncAccountSession(state);
|
||||
if (alive) nicknameRef.current(selectedBoundRole(state)?.nickName || '');
|
||||
}
|
||||
} catch { if (alive) nicknameRef.current(''); }
|
||||
})();
|
||||
return () => { alive = false; abort.abort(); };
|
||||
}, [open, playerId]);
|
||||
const choose = (role) => {
|
||||
onNickname(role?.nickName || '');
|
||||
};
|
||||
return html`<div class="sp-binding-entry">
|
||||
${name ? html`<${Button} variant="ghost" size="sm" icon="user" data-testid="account-binding-entry" onClick=${onOpen}>
|
||||
${t('已绑定:{name}', { name })}<//>` : null}
|
||||
${open ? html`<${BindingDialog} name=${name} onChoose=${choose} onUnbind=${() => choose(null)} onClose=${onClose} />` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function BindingDialog({ name, onChoose, onUnbind, onClose }) {
|
||||
const [api, setApi] = useState(null);
|
||||
const [tab, setTab] = useState('scan');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
const [bound, setBound] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [qr, setQr] = useState(null);
|
||||
const [scanBusy, setScanBusy] = useState(false);
|
||||
const [scanAwaiting, setScanAwaiting] = useState(false);
|
||||
const [challenge, setChallenge] = useState(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const alive = useRef(true);
|
||||
const scan = useRef(null);
|
||||
const scanTab = useRef(tab);
|
||||
scanTab.current = tab;
|
||||
const requestLock = useRef(false);
|
||||
const selectionKey = useRef('');
|
||||
const selectedRef = useRef('');
|
||||
const chooseRef = useRef(onChoose);
|
||||
chooseRef.current = onChoose;
|
||||
const showSelection = (role) => {
|
||||
const key = roleKey(role);
|
||||
selectedRef.current = key; setSelected(key); chooseRef.current(role);
|
||||
try { if (selectionKey.current) sessionStorage.setItem(selectionKey.current, key); } catch { /* optional preference */ }
|
||||
};
|
||||
const accept = async (state) => {
|
||||
await syncAccountSession(state);
|
||||
if (!alive.current) return;
|
||||
const list = state.roles || [];
|
||||
setBound(!!state.bound); setRoles(list);
|
||||
const role = state.bound ? preferredRole(list, state.selectedRoleKey || selectedRef.current) : null;
|
||||
if (role) showSelection(role);
|
||||
else { selectedRef.current = ''; setSelected(''); chooseRef.current(null); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
alive.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const token = await ensureBindingSession({ net, identity, name: name || t('博士') });
|
||||
if (!alive.current) return;
|
||||
const request = createBindingApi({ token: () => identity.getToken() || token, signal: abort.signal });
|
||||
const state = await request('status');
|
||||
selectionKey.current = `sp.binding.role.${state.playerId}`;
|
||||
try { selectedRef.current = sessionStorage.getItem(selectionKey.current) || ''; } catch { /* optional preference */ }
|
||||
if (alive.current) { setApi(() => request); await accept(state); }
|
||||
} catch (failure) { if (alive.current) setError(errorText(failure)); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
})();
|
||||
return () => { alive.current = false; abort.abort(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cooldown) return undefined;
|
||||
const timer = setTimeout(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const run = async (work) => {
|
||||
if (!api || requestLock.current) return;
|
||||
requestLock.current = true; setBusy(true); setError(''); setNotice('');
|
||||
try { await work(); } catch (failure) { if (alive.current) setError(errorText(failure)); }
|
||||
finally { requestLock.current = false; if (alive.current) setBusy(false); }
|
||||
};
|
||||
const finish = async () => {
|
||||
const state = await api('status');
|
||||
if (!alive.current) return;
|
||||
await accept(state); setPassword(''); setCode(''); setChallenge(null);
|
||||
if (!state.roles?.length) setNotice(t('登录成功,未找到明日方舟角色。'));
|
||||
};
|
||||
const finishRef = useRef(finish);
|
||||
finishRef.current = finish;
|
||||
const login = async (path, body) => {
|
||||
const result = await api(path, body);
|
||||
if (!alive.current) return;
|
||||
if (result.needCaptcha) { setChallenge({ result, path, body }); return; }
|
||||
if (!result.ok) throw new Error(result.error || t('登录失败,请重试。'));
|
||||
setChallenge(null);
|
||||
if (path === 'phone/send') { setCooldown(60); setNotice(t('验证码已发送,请查看短信。')); }
|
||||
else await finish();
|
||||
};
|
||||
const verify = () => run(async () => {
|
||||
setVerifying(true);
|
||||
try {
|
||||
const proof = await solveCaptcha(challenge.result.captcha);
|
||||
if (!alive.current) return;
|
||||
if (!proof) { setNotice(t('验证已取消,请点击按钮重试。')); return; }
|
||||
await login(challenge.path, { ...challenge.body, captcha: proof, captchaStep: challenge.result.captchaStep });
|
||||
} finally { if (alive.current) setVerifying(false); }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || bound) return undefined;
|
||||
const controller = createQrLogin({ request: api, encode: qrDataUrl, onQr: setQr,
|
||||
onPending: setScanAwaiting, onBusy: setScanBusy,
|
||||
onError: (failure) => setError(failure ? errorText(failure) : ''),
|
||||
onConfirmed: () => finishRef.current(),
|
||||
});
|
||||
scan.current = controller;
|
||||
const visibility = () => controller.setActive(scanTab.current === 'scan' && !document.hidden);
|
||||
visibility();
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
return () => { controller.stop(); scan.current = null; document.removeEventListener('visibilitychange', visibility); };
|
||||
}, [api, bound]);
|
||||
useEffect(() => { scan.current?.setActive(tab === 'scan' && !document.hidden); }, [tab, api, bound]);
|
||||
const switchTab = (value) => {
|
||||
setTab(value); setChallenge(null); setPassword(''); setError(''); setNotice('');
|
||||
};
|
||||
const field = (label, value, change, type, autocomplete) => html`<label class="field sp-binding__field">
|
||||
<span class="field__label">${label}</span><span class="field__box brackets">
|
||||
<input class="field__input" type=${type} value=${value} autocomplete=${autocomplete} required disabled=${busy}
|
||||
inputmode=${type === 'tel' ? 'tel' : autocomplete === 'one-time-code' ? 'numeric' : undefined}
|
||||
onInput=${(event) => { change(event.currentTarget.value); setChallenge(null); }} />
|
||||
</span></label>`;
|
||||
|
||||
return html`
|
||||
${verifying ? html`<div class="sp-binding__verifying" role="status">${t('请在验证窗口中完成人机验证。')}</div>` : null}
|
||||
<${Modal} open=${!verifying} title=${t('绑定明日方舟账号')} micro="ARKNIGHTS ACCOUNT" width="6.8rem" class="sp-binding" onClose=${onClose}
|
||||
actions=${html`<${Button} variant="ghost" onClick=${onClose}>${t('完成')}<//>`}>
|
||||
<p class="sp-binding__intro">${t('绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。')}</p>
|
||||
${error ? html`<p class="sp-binding__error" role="alert">${error}</p>` : null}
|
||||
${notice ? html`<p class="sp-binding__notice" role="status">${notice}</p>` : null}
|
||||
${bound ? html`<div class="sp-binding__roles">
|
||||
<p>${t('选择明日方舟角色')}</p>
|
||||
${roles.map((role) => html`<${Button} key=${roleKey(role)} block=${true} active=${selected === roleKey(role)}
|
||||
class="sp-binding__role" disabled=${busy || !hasRoleIdentity(role) || !role.nickName?.trim()} onClick=${() => run(async () => {
|
||||
const state = await api('role/select', { roleKey: roleKey(role) });
|
||||
if (!state.ok) throw new Error(state.error);
|
||||
if (alive.current) await accept(state);
|
||||
})}>
|
||||
<${Brand} kind=${/bilibili|b服|b站/i.test(role.channelName || '') ? 'bili' : 'hg'} />
|
||||
<span><b>${role.nickName || role.uid}</b><small>${role.channelName || ''} · UID ${role.uid}</small></span><//>`)}
|
||||
<div class="sp-binding__actions">
|
||||
<${Button} size="sm" disabled=${busy} onClick=${() => run(async () => {
|
||||
const result = await api('roles?refresh=1');
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
await finish();
|
||||
})}>${t('刷新角色')}<//>
|
||||
<${Button} size="sm" variant="ghost" disabled=${busy} onClick=${() => run(async () => {
|
||||
const result = await api('unbind', {});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
await resetAccountSession();
|
||||
if (alive.current) {
|
||||
setBound(false); setRoles([]); setSelected(''); selectedRef.current = '';
|
||||
try { sessionStorage.removeItem(selectionKey.current); } catch { /* optional preference */ }
|
||||
onUnbind();
|
||||
}
|
||||
})}>${t('解绑账号')}<//>
|
||||
</div>
|
||||
</div>` : html`
|
||||
<div class="sp-binding__tabs" role="tablist" aria-label=${t('登录方式')}>
|
||||
<${Button} size="sm" role="tab" aria-selected=${tab === 'scan'} active=${tab === 'scan'} disabled=${busy || scanBusy} onClick=${() => switchTab('scan')}>${t('森空岛扫码')}<//>
|
||||
<${Button} size="sm" role="tab" aria-selected=${tab === 'sms'} active=${tab === 'sms'} disabled=${busy || scanBusy} onClick=${() => switchTab('sms')}>${t('验证码')}<//>
|
||||
<${Button} size="sm" role="tab" aria-selected=${tab === 'password'} active=${tab === 'password'} disabled=${busy || scanBusy} onClick=${() => switchTab('password')}>${t('密码')}<//>
|
||||
</div>
|
||||
<div class="sp-binding__channels"><span><${Brand} kind="hg" />${t('官服')}</span><span><${Brand} kind="bili" />${t('B服:扫码后按提示完成授权')}</span></div>
|
||||
${busy && !api ? html`<p role="status">${t('正在连接服务器')}</p>` : null}
|
||||
${tab === 'scan' ? html`<div class="sp-binding__scan">
|
||||
${qr ? html`<img class="sp-binding__qr" src=${qr} alt=${t('使用森空岛 App 扫描二维码')} />` : null}
|
||||
<p>${t('使用森空岛 App 扫码并确认,支持官服和 B 服。')}</p>
|
||||
<p class="t-lo" role="status">${scanAwaiting ? t('已扫码,等待确认;二维码将在 1 分钟后自动刷新。') : t('二维码每 10 秒自动刷新,也可手动刷新。')}</p>
|
||||
<${Button} disabled=${!api || busy || scanBusy} loading=${busy || scanBusy} onClick=${() => scan.current?.refresh()}>${t('重新获取二维码')}<//>
|
||||
</div>` : html`<form class="sp-binding__form" onSubmit=${(event) => {
|
||||
event.preventDefault();
|
||||
run(() => login(tab === 'sms' ? 'phone/login' : 'password/login', tab === 'sms' ? { phone, code } : { phone, password }));
|
||||
}}>
|
||||
<p class="t-lo">${t('使用鹰角账号登录官服。B服请选择森空岛扫码。')}</p>
|
||||
${field(t('手机号'), phone, setPhone, 'tel', 'username')}
|
||||
${tab === 'sms' ? html`<div class="sp-binding__code">
|
||||
${field(t('验证码'), code, setCode, 'text', 'one-time-code')}
|
||||
<${Button} size="sm" disabled=${!api || busy || !phone.trim() || cooldown > 0} onClick=${() => run(() => login('phone/send', { phone }))}>
|
||||
${cooldown ? t('{n} 秒后重发', { n: cooldown }) : t('发送验证码')}<//>
|
||||
</div>` : field(t('密码'), password, setPassword, 'password', 'current-password')}
|
||||
<${Button} type="submit" block=${true} variant="primary" loading=${busy} disabled=${!api || busy || !phone.trim() || !(tab === 'sms' ? code.trim() : password)}>${t('登录并绑定')}<//>
|
||||
</form>`}
|
||||
${challenge ? html`<div class="sp-binding__captcha"><p>${t('需要人机验证,请点击下方按钮完成验证。')}</p>
|
||||
<${Button} loading=${busy} onClick=${verify}>${t('开始人机验证')}<//></div>` : null}
|
||||
`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Fork-local entry policy: saved callsigns/entered flags are hints, never proof of a role binding.
|
||||
import { net, identity } from '../net.js';
|
||||
import { store } from '../store.js';
|
||||
import { BindingError, createBindingApi, ensureBindingSession, preferredRole } from './api.js';
|
||||
|
||||
export function selectedBoundRole(state) {
|
||||
if (!state?.bound) return null;
|
||||
let selected = '';
|
||||
try { selected = sessionStorage.getItem('sp.binding.role.' + state.playerId) || ''; } catch { /* optional preference */ }
|
||||
return preferredRole(state.roles, state.selectedRoleKey || selected);
|
||||
}
|
||||
|
||||
/** Losing the authenticated game transport must never leave an anonymous socket inside the game shell. */
|
||||
export function acceptAccountWelcome(message) {
|
||||
if (typeof message.playerId === 'string' && message.playerId.startsWith('a_')) return true;
|
||||
identity.setEntered(false);
|
||||
store.patch('session', { entered: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A title lookup restores the login only. Changing roles releases an old, roomless game transport. */
|
||||
export async function syncAccountSession(state) {
|
||||
const current = store.get().me.playerId;
|
||||
if (current?.startsWith('a_') && current !== state?.gamePlayerId) await resetAccountSession();
|
||||
}
|
||||
|
||||
export async function resetAccountSession() {
|
||||
identity.clearToken(); identity.setEntered(false);
|
||||
store.patch('session', { entered: false });
|
||||
net.reconnectNow('Doctor');
|
||||
await ensureBindingSession({ net, identity, name: 'Doctor' });
|
||||
}
|
||||
|
||||
export async function accountRecovery({ signal } = {}) {
|
||||
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
|
||||
const state = await createBindingApi({ token: () => identity.getToken(), signal })('recovery');
|
||||
if (!state.ok) throw new BindingError('response');
|
||||
return state.recovery;
|
||||
}
|
||||
|
||||
/** Explicit entry/recovery: ask for the selected role's token, then verify the WebSocket actually attached it. */
|
||||
export async function enterBoundAccount(normalizeName, { recoveryId, automatic = false } = {}) {
|
||||
identity.setEntered(false);
|
||||
store.patch('session', { entered: false });
|
||||
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
|
||||
const api = createBindingApi({ token: () => identity.getToken() });
|
||||
const state = await api('status');
|
||||
const name = normalizeName(selectedBoundRole(state)?.nickName || '');
|
||||
if (!name || !state.gamePlayerId) throw new BindingError('session');
|
||||
if (automatic) {
|
||||
// A copied/stale local token must not silently take the role back from the device that now owns it.
|
||||
if (net.playerId !== state.gamePlayerId) return false;
|
||||
} else {
|
||||
const entry = await api(recoveryId ? 'resume' : 'enter', recoveryId ? { id: recoveryId } : {});
|
||||
if (!entry.ok || entry.gamePlayerId !== state.gamePlayerId || typeof entry.token !== 'string') throw new BindingError('response');
|
||||
net.close();
|
||||
if (!identity.stageToken(entry.token)) throw new BindingError('response');
|
||||
try {
|
||||
net.reconnectNow(name);
|
||||
await ensureBindingSession({ net, identity, name });
|
||||
if (net.playerId !== state.gamePlayerId) throw new BindingError('session');
|
||||
} catch (error) {
|
||||
net.close(); identity.clearToken();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
identity.saveName(name);
|
||||
store.patch('me', { name });
|
||||
net.setName(name);
|
||||
identity.setEntered(true);
|
||||
store.patch('session', { entered: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Boot/Start keep the title visible when the server cannot verify the selected role. */
|
||||
export async function restoreBoundEntry(normalizeName, options) {
|
||||
try { return await enterBoundAccount(normalizeName, options); } catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Cross-device recovery uses authenticated server state, not the browser's recent-token list.
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Modal } from '../ui/components.js';
|
||||
import { useLang } from '../ui/lang.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { accountRecovery } from './gate.js';
|
||||
|
||||
function recoveryError(error) {
|
||||
if (error.code === 'resume_expired') return t('此对局已结束或过期,请刷新后重试。');
|
||||
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
|
||||
return t('连接失败,请重试。');
|
||||
}
|
||||
|
||||
export function AccountResumeMatchButton({ bound, onResume }) {
|
||||
useLang();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [item, setItem] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
const lock = useRef(false);
|
||||
const alive = useRef(true);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
if (!bound) { setOpen(false); setItem(null); return undefined; }
|
||||
if (!open) return undefined;
|
||||
const abort = new AbortController();
|
||||
setLoading(true); setItem(null); setError('');
|
||||
accountRecovery({ signal: abort.signal }).then((value) => {
|
||||
if (!abort.signal.aborted) setItem(value);
|
||||
}).catch((failure) => {
|
||||
if (!abort.signal.aborted) setError(recoveryError(failure));
|
||||
}).finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [open, bound, revision]);
|
||||
if (!bound) return null;
|
||||
const resume = async () => {
|
||||
if (!item || lock.current) return;
|
||||
lock.current = true; setBusy(true); setError('');
|
||||
try {
|
||||
await onResume(item.id);
|
||||
if (alive.current) setOpen(false);
|
||||
} catch (failure) {
|
||||
if (alive.current) {
|
||||
setError(recoveryError(failure));
|
||||
if (failure.code === 'resume_expired') setItem(null);
|
||||
}
|
||||
} finally {
|
||||
lock.current = false;
|
||||
if (alive.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
return html`<${Button} variant="secondary" size="sm" data-testid="resume-account-match" onClick=${() => setOpen(true)}>${t('恢复账号对局')}<//>
|
||||
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复账号对局')} class="sp-account-resume"
|
||||
actions=${html`<${Button} variant="secondary" disabled=${busy || loading} onClick=${() => setRevision((value) => value + 1)}>${t('刷新')}<//>
|
||||
<${Button} variant="ghost" disabled=${busy} onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
|
||||
<p>${t('登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。')}</p>
|
||||
${loading ? html`<p role="status">${t('正在查询账号对局…')}</p>` : null}
|
||||
${error ? html`<p role="alert">${error}</p>` : null}
|
||||
${!loading && !item && !error ? html`<p role="status">${t('当前角色没有可恢复的对局。')}</p>` : null}
|
||||
${item ? html`<p>${item.inMatch ? t('对局进行中') : t('同盟等候中')}${item.inMatch && item.round > 0 ? ' · ' + t('第 {r} 回合', { r: item.round }) : ''}</p>
|
||||
<p class="t-lo">${t('如果此角色仍在其他设备在线,恢复后将断开旧连接。')}</p>
|
||||
<${Button} variant="primary" block=${true} disabled=${busy || loading} data-testid="confirm-account-resume" onClick=${resume}>
|
||||
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code })}<//>` : null}
|
||||
<//>`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Serial QR requests: a refresh cannot replace a scan while its confirmation poll is still in flight.
|
||||
export const SCAN_POLL_MS = 2_000;
|
||||
export const SCAN_REFRESH_MS = 10_000;
|
||||
export const SCAN_CONFIRM_REFRESH_MS = 60_000;
|
||||
|
||||
export function createQrLogin({ request, encode, onQr, onPending, onError, onConfirmed, onBusy,
|
||||
now = Date.now, timers = globalThis, isHidden = () => false }) {
|
||||
let stopped = false, completed = false, confirmed = false, active = false, busy = false, timer;
|
||||
let ready = false, awaiting = false, manual = true;
|
||||
let refreshAt = Infinity, pollAt = Infinity;
|
||||
const clear = () => { timers.clearTimeout(timer); timer = undefined; };
|
||||
const schedule = () => {
|
||||
clear();
|
||||
if (stopped || completed || !active || busy) return;
|
||||
const due = manual ? now() : ready ? Math.min(refreshAt, pollAt) : refreshAt;
|
||||
timer = timers.setTimeout(tick, isHidden() ? SCAN_POLL_MS : Math.max(0, due - now()));
|
||||
};
|
||||
const generate = async () => {
|
||||
manual = false; ready = false; awaiting = false;
|
||||
// Keep the displayed image until its replacement is encoded; refreshing only changes the existing img's src.
|
||||
onBusy(true); onPending(false); onError(null);
|
||||
try {
|
||||
const result = await request('scan/start', {});
|
||||
if (stopped) return;
|
||||
if (!result.ok || !result.scanUrl) throw new Error(result.error || 'scan_response');
|
||||
const image = await encode(result.scanUrl);
|
||||
if (stopped) return;
|
||||
ready = true; refreshAt = now() + SCAN_REFRESH_MS; pollAt = now() + SCAN_POLL_MS;
|
||||
onQr(image);
|
||||
} finally { if (!stopped) onBusy(false); }
|
||||
};
|
||||
async function tick() {
|
||||
clear();
|
||||
if (stopped || completed || !active || busy) return;
|
||||
if (isHidden()) { schedule(); return; }
|
||||
busy = true;
|
||||
try {
|
||||
if (confirmed) { manual = false; await onConfirmed(); completed = true; }
|
||||
else if (manual || !ready) await generate();
|
||||
else {
|
||||
// Poll once before an automatic refresh too: a just-scanned QR gets its full confirmation window.
|
||||
const result = await request('scan/poll', {});
|
||||
if (stopped) return;
|
||||
if (!result.ok || result.error) throw new Error(result.error || 'scan_response');
|
||||
if (result.state === 'confirmed') {
|
||||
confirmed = true; onQr(null);
|
||||
await onConfirmed();
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
if (result.state !== 'waiting') throw new Error('scan_expired');
|
||||
if (result.awaitingConfirmation && !awaiting) {
|
||||
awaiting = true; refreshAt = now() + SCAN_CONFIRM_REFRESH_MS; onPending(true);
|
||||
}
|
||||
pollAt = now() + SCAN_POLL_MS;
|
||||
if (active && !isHidden() && (manual || now() >= refreshAt)) await generate();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stopped) {
|
||||
ready = false; refreshAt = now() + SCAN_REFRESH_MS;
|
||||
awaiting = false; onPending(false); onQr(null); onError(error);
|
||||
}
|
||||
} finally { busy = false; schedule(); }
|
||||
}
|
||||
return {
|
||||
setActive(value) { active = value; schedule(); },
|
||||
refresh() { manual = true; schedule(); },
|
||||
stop() { stopped = true; clear(); },
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function bannerVisible(conn, entered, restoring, buildStale = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ConnectionBanner() {
|
||||
export function ConnectionBanner({ continueSession } = {}) {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const entered = useStore((s) => s.session.entered);
|
||||
const restoring = useStore((s) => s.ui.restoring);
|
||||
@@ -53,7 +53,7 @@ export function ConnectionBanner() {
|
||||
: conn.status === 'closed' ? t('连接已关闭')
|
||||
: rejected ? t(conn.lastError.text) : t('正在连接服务器');
|
||||
const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() }
|
||||
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: () => net.connect() }
|
||||
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: continueSession || (() => net.connect()) }
|
||||
: versionMismatch ? { label: t('刷新页面'), run: () => location.reload() }
|
||||
: rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
|
||||
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { store } from '../store.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { toast } from './toasts.js';
|
||||
|
||||
export function ResumeMatchButton() {
|
||||
export function ResumeMatchButton({ beforeEnter } = {}) {
|
||||
const [items, setItems] = useState(() => identity.recoverable());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -22,13 +22,15 @@ export function ResumeMatchButton() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (await identity.resume(item.id)) {
|
||||
identity.setEntered(true);
|
||||
store.patch('session', { entered: true });
|
||||
// Keep the tentative token in this document until welcome persists it. Reloading here
|
||||
// would either lose the selection or falsely turn it into an established session claim.
|
||||
net.close();
|
||||
net.setName(item.name);
|
||||
net.connect();
|
||||
if (!beforeEnter || await beforeEnter()) {
|
||||
identity.setEntered(true);
|
||||
store.patch('session', { entered: true });
|
||||
}
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
public/skland.html — 森空岛(Skland)账号绑定的独立诊断 / 引导面板(server/skland/ 的浏览器端)。
|
||||
这不是游戏 Preact UI 的一部分(public/js/screens/ 里的界面不动):一个单文件、无构建、无框架的页面,
|
||||
三步走完绑定的三条路径 —— 扫码(QR)/ 验证码(SMS)/ 密码 —— 并随时显示绑定状态与角色列表。
|
||||
|
||||
身份:?player=<id> > localStorage('sp.skland.player') > 新生成的占位 id。这个 id 目前只是占位,
|
||||
真正的游戏会话接入后应由会话自己的 player id 提供(server/skland/service.js 的头注释、docs/skland.md 安全模型)。
|
||||
|
||||
端点(全部 JSON;错误是 {ok:false,error} 或双语错误页;详见 server/skland/http.js):
|
||||
GET /skland/status?player=… 绑定状态
|
||||
POST /skland/scan/start {player} 开始扫码 → {scanId, scanUrl}(二维码编码 scanUrl 深链,不是 scanId)
|
||||
POST /skland/scan/poll {player} 每 ~2s 轮询一次,confirmed 的那次调用同时完成登录
|
||||
POST /skland/phone/send {player,phone,captcha?} 发短信验证码;可能要 geetest 人机验证
|
||||
POST /skland/phone/login {player,phone,code} 验证码登录
|
||||
POST /skland/password/login {player,phone,password} 密码登录;仅上游要求时才需要额外设备验证(needDevice)
|
||||
POST /skland/unbind {player} 解绑
|
||||
GET /skland/roles?player=…&refresh=1 重新拉取角色
|
||||
|
||||
模块默认关闭(SP_SKLAND=1 才挂载):此时 /skland/* 由占位处理器统一答 JSON 404。页面据此显示"该功能未启用",
|
||||
而不是看起来坏掉。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>森空岛绑定 · Skland · 卫戍协议:盟约</title>
|
||||
<!-- 二维码库:qrcode-generator(MIT),由 tools/vendor.mjs 在安装期复制到 public/vendor/。
|
||||
源码检出 / 未跑 npm install 时它可能不存在——脚本缺失不报错,renderQr() 回退为可选中的深链文本。 -->
|
||||
<script src="/vendor/qrcode.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #111614; color: #d8e3de; font: 15px/1.6 "Noto Sans SC", system-ui, sans-serif; }
|
||||
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; color: #4ed8af; letter-spacing: .04em; }
|
||||
h2 { font-size: 15px; margin: 0 0 10px; color: #4ed8af; letter-spacing: .08em; }
|
||||
.sub { opacity: .7; margin: 0 0 16px; font-size: 13px; }
|
||||
.card { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; margin: 0 0 16px; }
|
||||
.tabs { display: flex; gap: 8px; margin: 0 0 12px; }
|
||||
.tabs button { flex: 1; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
|
||||
.tabs button[aria-selected="true"] { border-color: #4ed8af; color: #4ed8af; }
|
||||
.panel { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; }
|
||||
label { display: block; margin: 10px 0 4px; font-size: 13px; opacity: .8; }
|
||||
input { width: 100%; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; }
|
||||
button.act { padding: 8px 14px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
|
||||
button.act:hover:not(:disabled) { border-color: #4ed8af; color: #4ed8af; }
|
||||
button.act:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; margin: 0 0 10px; }
|
||||
.kv dt { opacity: .6; font-size: 13px; }
|
||||
.kv dd { margin: 0; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0; }
|
||||
th, td { border: 1px solid #2c3a35; padding: 4px 8px; text-align: left; }
|
||||
th { opacity: .6; font-weight: 400; }
|
||||
.hint { font-size: 13px; opacity: .75; margin: 10px 0 0; white-space: pre-wrap; }
|
||||
.hint.err, .err { color: #ff9d8a; }
|
||||
.off, .warn { border: 1px solid #4ed8af; background: #12211c; padding: 10px 12px; margin: 0 0 12px; color: #4ed8af; font-size: 13px; }
|
||||
.warn { border-color: #ffb454; background: #241c10; color: #ffb454; }
|
||||
.deeplink { width: 100%; margin-top: 8px; padding: 8px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: 12px/1.5 monospace; }
|
||||
#qr-box svg { width: 220px; max-width: 100%; height: auto; background: #fff; padding: 4px; }
|
||||
code { background: #141816; padding: 1px 4px; word-break: break-all; }
|
||||
a { color: #4ed8af; }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>森空岛绑定 · Skland binding</h1>
|
||||
<p class="sub">
|
||||
绑定身份 · Player id:<code id="player-id">…</code><br />
|
||||
这个 id 目前是占位(?player= 或浏览器本地保存),等游戏会话提供真实 id 后再由会话注入 ·
|
||||
placeholder until the game session provides the real player id.
|
||||
</p>
|
||||
|
||||
<section class="card" id="status-card">
|
||||
<h2>绑定状态 · Binding status</h2>
|
||||
<!-- 404 = 模块没挂载(默认关闭,SP_SKLAND=1 才启用)。 -->
|
||||
<p class="off" id="module-off" hidden>
|
||||
该功能未启用 · the module is off —— 服务器没有挂载 /skland/*(以 <code>SP_SKLAND=1</code> 启动后可用)·
|
||||
the server was started without the module (404/405).
|
||||
</p>
|
||||
<dl class="kv">
|
||||
<dt>状态 · Status</dt><dd id="st-status">—</dd>
|
||||
<dt>昵称 · Nickname</dt><dd id="st-nickname">—</dd>
|
||||
<dt>森空岛 ID · hgId</dt><dd id="st-hgid">—</dd>
|
||||
</dl>
|
||||
<table id="roles-table">
|
||||
<thead><tr><th>uid</th><th>昵称 · nickName</th><th>服务器 · channelName</th><th>gameId</th></tr></thead>
|
||||
<tbody id="roles-body"></tbody>
|
||||
</table>
|
||||
<p class="hint" id="roles-empty">还没有角色 · No roles yet.</p>
|
||||
<!-- 每个 fetch 的失败都落在这一行上,不会有未处理的 rejection。 -->
|
||||
<p class="err" id="status-error" hidden></p>
|
||||
<div class="row">
|
||||
<button class="act" id="btn-refresh" type="button">刷新 · Refresh</button>
|
||||
<button class="act" id="btn-unbind" type="button" data-needs-module>解绑 · Unbind</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="act" type="button" role="tab" data-tab="scan">扫码 · QR</button>
|
||||
<button class="act" type="button" role="tab" data-tab="sms">验证码 · SMS</button>
|
||||
<button class="act" type="button" role="tab" data-tab="pw">密码 · Password</button>
|
||||
</nav>
|
||||
<p class="hint" id="login-scope"></p>
|
||||
|
||||
<section class="panel" id="panel-scan" role="tabpanel">
|
||||
<h2>扫码登录 · Scan to log in</h2>
|
||||
<p class="hint">用森空岛 App 扫描二维码,或在 App 内打开深链;面板每 2 秒问一次状态 ·
|
||||
scan the code with the Skland app; the panel polls every 2 s.</p>
|
||||
<div class="row"><button class="act" id="btn-scan-start" type="button" data-needs-module>获取二维码 · Get QR code</button></div>
|
||||
<div id="qr-box"></div>
|
||||
<p class="hint" id="qr-note"></p>
|
||||
<p class="hint" id="scan-state"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="panel-sms" role="tabpanel" hidden>
|
||||
<h2>验证码登录 · SMS code</h2>
|
||||
<label for="sms-phone">手机号 · Phone</label>
|
||||
<input id="sms-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
|
||||
<div class="row"><button class="act" id="btn-sms-send" type="button" data-needs-module>发送验证码 · Send code</button></div>
|
||||
<label for="sms-code">验证码 · Code</label>
|
||||
<input id="sms-code" type="text" autocomplete="one-time-code" inputmode="numeric" placeholder="123456" />
|
||||
<div class="row"><button class="act" id="btn-sms-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
|
||||
<p class="hint" id="sms-state"></p>
|
||||
|
||||
<div id="captcha-box" hidden>
|
||||
<p class="hint" aria-live="polite"></p>
|
||||
<button class="act" type="button"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="panel-pw" role="tabpanel" hidden>
|
||||
<h2>密码登录 · Phone + password</h2>
|
||||
<label for="pw-phone">手机号 · Phone</label>
|
||||
<input id="pw-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
|
||||
<label for="pw-password">密码 · Password</label>
|
||||
<input id="pw-password" type="password" autocomplete="current-password" />
|
||||
<div class="row"><button class="act" id="btn-pw-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
|
||||
<p class="hint" id="pw-state"></p>
|
||||
<div id="pw-captcha-box" hidden>
|
||||
<p class="hint" aria-live="polite"></p>
|
||||
<button class="act" type="button"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="hint"><a href="/">返回首页 · Back to home</a></p>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
import { t } from '/shared/i18n.js';
|
||||
import { solveCaptcha } from '/js/skland/captcha.js';
|
||||
// 面板逻辑:所有 /skland/* 调用都过 api(),失败一律显示成文字;二维码编码 scanUrl 深链。
|
||||
|
||||
/** 轮询间隔(契约:约 2 秒)。 */
|
||||
const POLL_MS = 2000;
|
||||
/** 玩家 id 的 localStorage 键。 */
|
||||
const PLAYER_KEY = 'sp.skland.player';
|
||||
/** 404 = 模块未启用(默认关闭)。 */
|
||||
const NOT_ENABLED = '该功能未启用 · the module is off(服务器未以 SP_SKLAND=1 启动)· not enabled (404)';
|
||||
/** 状态值 → 双语标签(server/skland/service.js 的 status)。 */
|
||||
const STATUS_LABEL = { unbound: '未绑定 · unbound', scanning: '扫码中 · scanning', bound: '已绑定 · bound', error: '错误 · error' };
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
$('#login-scope').textContent = t('官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。');
|
||||
|
||||
/** 面板自己的错误类型:message 已经是给人看的一句话。 */
|
||||
class PanelError extends Error {}
|
||||
|
||||
/** 玩家标识:?player= > localStorage > 新生成的占位 id。 */
|
||||
function resolvePlayer() {
|
||||
const fromUrl = new URLSearchParams(location.search).get('player');
|
||||
let id = (fromUrl || '').trim();
|
||||
try { id = id || localStorage.getItem(PLAYER_KEY) || ''; } catch { /* 隐私模式:localStorage 不可用时只用 URL/新 id */ }
|
||||
if (!id) id = `web-${Math.random().toString(36).slice(2, 10)}`;
|
||||
try { localStorage.setItem(PLAYER_KEY, id); } catch { /* 同上 */ }
|
||||
return id;
|
||||
}
|
||||
const player = resolvePlayer();
|
||||
|
||||
/** 把非 JSON 的错误页 HTML 压成一行可读文本。 */
|
||||
function brief(body) {
|
||||
return String(body).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||
}
|
||||
|
||||
/** 模块没挂载:亮出未启用提示,并停掉所有依赖模块的按钮。 */
|
||||
let moduleOff = false;
|
||||
function setModuleOff() {
|
||||
if (moduleOff) return;
|
||||
moduleOff = true;
|
||||
$('#module-off').hidden = false;
|
||||
for (const b of document.querySelectorAll('[data-needs-module]')) b.disabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用一个 /skland/* JSON 接口。网络 / 非 JSON / HTTP 错误都抛 PanelError,由调用方展示;
|
||||
* 404 先于解析处理:模块关闭时 GET 由静态服务器答 404 错误页(不是 JSON)。
|
||||
*/
|
||||
async function api(path, { body } = {}) {
|
||||
const init = { method: 'GET', cache: 'no-store', headers: {} };
|
||||
if (body !== undefined) {
|
||||
init.method = 'POST';
|
||||
init.headers['content-type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
let res;
|
||||
try { res = await fetch(path, init); } catch (e) { throw new PanelError(`网络错误 · Network error: ${e.message}`); }
|
||||
let text = '';
|
||||
try { text = await res.text(); } catch { /* 读体失败也当空体处理 */ }
|
||||
// 模块默认关闭时 /skland/* 由一个占位处理器统一答 JSON 404(server/skland/http.js createSklandOffHandler),
|
||||
// 所以"未启用"只需认这一种应答;HTML 应答不是这个接口的形态,一并当作未启用兜底。
|
||||
if ((res.status === 404 && !(res.headers.get('content-type') || '').includes('application/json'))
|
||||
|| (res.headers.get('content-type') || '').includes('text/html')) {
|
||||
setModuleOff();
|
||||
throw new PanelError(NOT_ENABLED);
|
||||
}
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
|
||||
if (!res.ok) throw new PanelError(data?.error || `HTTP ${res.status} · ${brief(text)}`);
|
||||
if (!data || typeof data !== 'object') throw new PanelError(`响应不是 JSON · Response is not JSON (HTTP ${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 状态区(常显)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function showError(message) {
|
||||
const el = $('#status-error');
|
||||
el.hidden = !message;
|
||||
el.textContent = message || '';
|
||||
}
|
||||
|
||||
/** 一行提示:id 指向某个 .hint,note 非空时整体标红。 */
|
||||
function msg(id, main, note = '') {
|
||||
const el = $(id);
|
||||
el.textContent = note ? `${main} —— ${note}` : main;
|
||||
el.classList.toggle('err', !!note);
|
||||
}
|
||||
|
||||
function text(id, value) { $(id).textContent = value == null || value === '' ? '—' : String(value); }
|
||||
|
||||
/** roles = {uid, nickName, channelName, gameId}(上游没给的字段以 — 显示)。 */
|
||||
function renderRoles(roles) {
|
||||
const body = $('#roles-body');
|
||||
body.textContent = '';
|
||||
const list = Array.isArray(roles) ? roles : [];
|
||||
$('#roles-empty').hidden = list.length > 0;
|
||||
for (const r of list) {
|
||||
const tr = document.createElement('tr');
|
||||
for (const v of [r?.uid, r?.nickName, r?.channelName, r?.gameId]) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = v == null || v === '' ? '—' : String(v);
|
||||
tr.append(td);
|
||||
}
|
||||
body.append(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(s) {
|
||||
text('#st-status', STATUS_LABEL[s.status] || s.status);
|
||||
text('#st-nickname', s.nickname);
|
||||
text('#st-hgid', s.hgId);
|
||||
renderRoles(s.roles);
|
||||
showError(s.error || '');
|
||||
}
|
||||
|
||||
/** GET /skland/status。返回状态对象(失败返回 null;错误已显示)。 */
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const s = await api(`/skland/status?player=${encodeURIComponent(player)}`);
|
||||
renderStatus(s);
|
||||
return s;
|
||||
} catch (e) { showError(e.message); return null; }
|
||||
}
|
||||
|
||||
/** 刷新按钮:先状态;已绑定再 ?refresh=1 让服务器重新问一次森空岛要角色列表。 */
|
||||
async function refreshAll() {
|
||||
const s = await refreshStatus();
|
||||
if (moduleOff || !s?.bound) return;
|
||||
try {
|
||||
const r = await api(`/skland/roles?player=${encodeURIComponent(player)}&refresh=1`);
|
||||
if (r.ok) renderRoles(r.roles);
|
||||
else showError(r.error || '查询角色失败 · Roles lookup failed');
|
||||
} catch (e) { showError(e.message); }
|
||||
}
|
||||
|
||||
async function unbind() {
|
||||
showError('');
|
||||
try {
|
||||
await api('/skland/unbind', { body: { player } });
|
||||
await refreshStatus();
|
||||
} catch (e) { showError(e.message); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 扫码:编码 scanUrl → 每 2s 轮询直到 confirmed / expired
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** 扫码会话是否还在进行(离开页面/Tab 只停表,不清会话)。 */
|
||||
let scanLive = false;
|
||||
let pollTimer = null;
|
||||
let pollBusy = false;
|
||||
|
||||
/** 停表:唯一清 clearInterval 的地方。 */
|
||||
function pollStop() {
|
||||
if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
|
||||
/** 起表:只有会话还在、扫码 Tab 在前台时才轮询。 */
|
||||
function pollStart() {
|
||||
pollStop();
|
||||
if (!scanLive || activeTab !== 'scan' || document.hidden) return;
|
||||
pollTimer = setInterval(() => { void pollOnce(); }, POLL_MS);
|
||||
}
|
||||
|
||||
/** 二维码库缺失 / 绘制失败时的回退:把 scanUrl 深链作为可选中的文本放出来,绝不抛错。 */
|
||||
function renderQrFallback(scanUrl) {
|
||||
const box = $('#qr-box');
|
||||
box.textContent = '';
|
||||
const note = document.createElement('p');
|
||||
note.className = 'hint';
|
||||
note.textContent = '二维码库 /vendor/qrcode.js 不可用(未执行 npm install / npm run vendor)——请手动打开下面的深链 · QR library unavailable; open the deep link manually.';
|
||||
const link = document.createElement('textarea');
|
||||
link.className = 'deeplink';
|
||||
link.readOnly = true;
|
||||
link.rows = 3;
|
||||
link.value = String(scanUrl);
|
||||
link.addEventListener('focus', () => link.select());
|
||||
box.append(note, link);
|
||||
$('#qr-note').textContent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码:编码 scanUrl(hypergryph:// 深链),不是会话标识。库缺失(源码检出没有 vendor 文件)或调用失败时
|
||||
* 回退为可选中的深链文本,绝不让缺库抛出去。
|
||||
*/
|
||||
function renderQr(scanUrl) {
|
||||
if (typeof qrcode !== 'function') { renderQrFallback(scanUrl); return; }
|
||||
const box = $('#qr-box');
|
||||
box.textContent = '';
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(scanUrl);
|
||||
qr.make();
|
||||
box.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2 });
|
||||
$('#qr-note').textContent = '用森空岛 App 扫码 · Scan with the Skland app';
|
||||
} catch { renderQrFallback(scanUrl); }
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
scanLive = false;
|
||||
pollStop();
|
||||
showError('');
|
||||
msg('#scan-state', '正在获取二维码… · Requesting a QR code…');
|
||||
try {
|
||||
const r = await api('/skland/scan/start', { body: { player } });
|
||||
if (!r.ok) { msg('#scan-state', '获取二维码失败 · Could not start', r.error || ''); return; }
|
||||
renderQr(r.scanUrl);
|
||||
const secs = Number(r.expiresInMs) > 0 ? Math.round(Number(r.expiresInMs) / 1000) : 0;
|
||||
msg('#scan-state', secs ? `等待手机确认…(${secs}s 内有效 · valid for ${secs}s)` : '等待手机确认… · Waiting for the phone…');
|
||||
scanLive = true;
|
||||
pollStart();
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
msg('#scan-state', '获取二维码失败 · Could not start', e.message);
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次轮询。confirmed / expired / none 都是终态:停表、刷新状态。 */
|
||||
async function pollOnce() {
|
||||
if (pollBusy || !scanLive) return;
|
||||
pollBusy = true;
|
||||
try {
|
||||
const r = await api('/skland/scan/poll', { body: { player } });
|
||||
// 服务端把上游失败答成 HTTP 200 + { ok: false, error }(没有 state)——先认它,否则真正的原因会被
|
||||
// 下面那条 `state === undefined` 的兜底吞成"未知状态"。
|
||||
if (r.ok === false) {
|
||||
scanLive = false;
|
||||
pollStop();
|
||||
msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
|
||||
showError(r.error || '');
|
||||
return;
|
||||
}
|
||||
if (r.state === 'waiting') { msg('#scan-state', '等待手机确认… · Waiting for the phone…'); return; }
|
||||
scanLive = false;
|
||||
pollStop();
|
||||
if (r.state === 'confirmed') msg('#scan-state', '已确认 · Confirmed', r.error || '');
|
||||
else if (r.state === 'expired') msg('#scan-state', '二维码已过期,请重新获取 · Expired — start again');
|
||||
else if (r.state === 'none') msg('#scan-state', '没有进行中的扫码会话 · No scan session');
|
||||
else if (r.state === 'error') msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
|
||||
else msg('#scan-state', `未知状态 · Unknown state: ${String(r.state)}`, `响应 · response: ${brief(JSON.stringify(r))}`);
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
scanLive = false;
|
||||
pollStop();
|
||||
msg('#scan-state', '轮询已停止 · Polling stopped', e.message);
|
||||
showError(e.message);
|
||||
} finally { pollBusy = false; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 验证码
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Send SMS, optionally retrying with the proof from the interactive widget. */
|
||||
async function sendCode(extra) {
|
||||
const phone = $('#sms-phone').value.trim();
|
||||
if (!phone) { msg('#sms-state', '请填写手机号 · Enter a phone number'); return; }
|
||||
showError('');
|
||||
msg('#sms-state', '正在发送… · Sending…');
|
||||
try {
|
||||
const body = { player, phone };
|
||||
if (extra) body.captcha = extra;
|
||||
const r = await api('/skland/phone/send', { body });
|
||||
if (r.ok) { $('#captcha-box').hidden = true; msg('#sms-state', '验证码已发送 · Code sent'); return; }
|
||||
if (r.needCaptcha) { renderCaptcha(r.captcha || {}, '#captcha-box', (proof) => sendCode(proof)); return; }
|
||||
msg('#sms-state', '发送失败 · Send failed', r.error || '');
|
||||
showError(r.error || '发送失败 · Send failed');
|
||||
} catch (e) {
|
||||
msg('#sms-state', '发送失败 · Send failed', e.message);
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load and open the provider's widget only after the user clicks its button. */
|
||||
function renderCaptcha(challenge, selector, retry) {
|
||||
const box = $(selector);
|
||||
const state = box.querySelector('p');
|
||||
const button = box.querySelector('button');
|
||||
box.hidden = false;
|
||||
state.textContent = t('需要人机验证,请点击下方按钮完成验证。');
|
||||
button.textContent = t('开始人机验证');
|
||||
button.disabled = false;
|
||||
button.onclick = async () => {
|
||||
button.disabled = true;
|
||||
state.textContent = t('正在加载验证组件…');
|
||||
try {
|
||||
const proof = await solveCaptcha(challenge);
|
||||
if (!proof) { state.textContent = t('验证已取消,请点击按钮重试。'); return; }
|
||||
state.textContent = t('验证通过,正在继续…');
|
||||
box.hidden = true;
|
||||
await retry(proof);
|
||||
} catch (e) {
|
||||
state.textContent = e.message === 'captcha_type'
|
||||
? t('暂不支持此验证类型,请使用验证码或扫码登录。')
|
||||
: e.message === 'captcha_load' ? t('验证组件加载失败,请重试。') : t('验证未完成,请重试。');
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
box.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
async function smsLogin() {
|
||||
const phone = $('#sms-phone').value.trim();
|
||||
const code = $('#sms-code').value.trim();
|
||||
if (!phone || !code) { msg('#sms-state', '请填写手机号与验证码 · Enter the phone and the code'); return; }
|
||||
showError('');
|
||||
msg('#sms-state', '正在登录… · Logging in…');
|
||||
try {
|
||||
const r = await api('/skland/phone/login', { body: { player, phone, code } });
|
||||
if (r.ok) {
|
||||
$('#captcha-box').hidden = true;
|
||||
msg('#sms-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
|
||||
} else {
|
||||
msg('#sms-state', '登录失败 · Login failed', r.error || '');
|
||||
showError(r.error || '登录失败 · Login failed');
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
msg('#sms-state', '登录失败 · Login failed', e.message);
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 密码
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
async function passwordLogin(extra = {}) {
|
||||
const phone = $('#pw-phone').value.trim();
|
||||
const password = $('#pw-password').value;
|
||||
if (!phone || !password) { msg('#pw-state', '请填写手机号与密码 · Enter the phone and the password'); return; }
|
||||
showError('');
|
||||
msg('#pw-state', '正在登录… · Logging in…');
|
||||
try {
|
||||
const r = await api('/skland/password/login', { body: { player, phone, password, ...extra } });
|
||||
if (r.ok) {
|
||||
$('#pw-captcha-box').hidden = true;
|
||||
msg('#pw-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
|
||||
} else if (r.needCaptcha) {
|
||||
renderCaptcha(r.captcha || {}, '#pw-captcha-box', (proof) => passwordLogin({ captcha: proof, captchaStep: r.captchaStep }));
|
||||
return;
|
||||
} else {
|
||||
msg('#pw-state', '登录失败 · Login failed', r.error || '');
|
||||
showError(r.error || '登录失败 · Login failed');
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
msg('#pw-state', '登录失败 · Login failed', e.message);
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tabs 与启动
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
let activeTab = 'scan';
|
||||
|
||||
function switchTab(name) {
|
||||
activeTab = name;
|
||||
for (const b of document.querySelectorAll('[data-tab]')) b.setAttribute('aria-selected', String(b.dataset.tab === name));
|
||||
for (const p of document.querySelectorAll('.panel')) p.hidden = p.id !== `panel-${name}`;
|
||||
// 离开扫码区只停表:会话还在时切回来会继续问(scanLive 不由 Tab 决定)。
|
||||
if (name !== 'scan') pollStop();
|
||||
else pollStart();
|
||||
}
|
||||
|
||||
$('#player-id').textContent = player;
|
||||
for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => switchTab(b.dataset.tab));
|
||||
$('#btn-refresh').addEventListener('click', () => { void refreshAll(); });
|
||||
$('#btn-unbind').addEventListener('click', () => { void unbind(); });
|
||||
$('#btn-scan-start').addEventListener('click', () => { void startScan(); });
|
||||
$('#btn-sms-send').addEventListener('click', () => { void sendCode(); });
|
||||
$('#btn-sms-login').addEventListener('click', () => { void smsLogin(); });
|
||||
$('#btn-pw-login').addEventListener('click', () => { void passwordLogin(); });
|
||||
|
||||
// 页面隐藏 / 离开时停表,不给后台标签页留定时器;回到前台且扫码会话还在就继续。
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) pollStop(); else pollStart(); });
|
||||
window.addEventListener('pagehide', () => pollStop());
|
||||
|
||||
switchTab('scan');
|
||||
void refreshAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,9 +19,10 @@ import { hashedFileOpts } from './assetIndex.js';
|
||||
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
|
||||
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
|
||||
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
|
||||
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
|
||||
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* isPrivate?: (file: string) => Promise<boolean> }} [hashing]
|
||||
*/
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null, isPrivate = async () => false } = {}) {
|
||||
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
|
||||
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
@@ -48,6 +49,7 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
|
||||
stat = await fsp.stat(absPath);
|
||||
} catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
|
||||
// or that of its content-hashed form.
|
||||
|
||||
+11
-2
@@ -2,6 +2,8 @@
|
||||
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
|
||||
//
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * /skland/* → the Skland module's own listener when it is enabled (server/skland/http.js; SP_SKLAND=1). It owns
|
||||
// its methods (POST), so it runs before the GET/HEAD gate below;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
|
||||
@@ -48,16 +50,23 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* rawPath: string, query: string) => Promise<void>,
|
||||
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean }} deps
|
||||
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean,
|
||||
* skland?: ((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* parts: { rawPath: string, query: string }) => Promise<boolean>) | null }} deps
|
||||
* trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY)
|
||||
* skland: the /skland/* handler when the module is enabled (server/skland/http.js; SP_SKLAND=1). It owns its paths
|
||||
* and its methods (POST), so it runs before the GET/HEAD method gate.
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
||||
*/
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) {
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) {
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url || '/';
|
||||
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
||||
const parts = splitUrl(url);
|
||||
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (skland && (parts.rawPath === '/skland' || parts.rawPath.startsWith('/skland/'))) {
|
||||
if (await skland(req, res, parts)) return;
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
||||
|
||||
+14
-4
@@ -29,6 +29,7 @@
|
||||
// Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { MEDIA_PREFIX } from '../../shared/media.js';
|
||||
import { splitHashedPath } from '../../shared/assetPaths.js';
|
||||
@@ -62,7 +63,7 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object, privateFiles?: string[],
|
||||
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
|
||||
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
|
||||
@@ -70,7 +71,14 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
|
||||
assetIndex = null, views = null }) {
|
||||
assetIndex = null, views = null, privateFiles = [] }) {
|
||||
const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
|
||||
const privatePaths = new Set(privateFiles.map(canonical));
|
||||
for (const file of privateFiles) {
|
||||
try { privatePaths.add(canonical(realpathSync(file))); } catch { /* Side files may not exist yet. */ }
|
||||
}
|
||||
const isPrivate = async (file) => privatePaths.size > 0 && (privatePaths.has(canonical(file))
|
||||
|| privatePaths.has(canonical(await fsp.realpath(file))));
|
||||
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
@@ -107,13 +115,13 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; }
|
||||
const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null;
|
||||
const st = abs ? await fsp.stat(abs).catch(() => null) : null;
|
||||
if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
|
||||
if (!st || !st.isFile() || await isPrivate(abs)) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
|
||||
await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex });
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex, isPrivate });
|
||||
return;
|
||||
}
|
||||
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
|
||||
@@ -137,6 +145,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
return;
|
||||
}
|
||||
let absPath = path.join(mount.dir, ...segments);
|
||||
if (privatePaths.has(canonical(absPath))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
let stat;
|
||||
@@ -183,6 +192,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
}
|
||||
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
|
||||
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
|
||||
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
|
||||
};
|
||||
}
|
||||
+55
-3
@@ -17,6 +17,9 @@
|
||||
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
||||
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
|
||||
// WebSocket, since the start (GET /healthz?traffic=1)
|
||||
// skland/ the opt-in Skland account binding (SP_SKLAND=1): /skland/* — QR / 验证码 / 密码 login, one
|
||||
// SQLite row per player. server/skland/http.js is handed to routes.js as its own listener (it
|
||||
// owns POST); docs/skland.md
|
||||
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
||||
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
||||
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
||||
@@ -29,6 +32,7 @@
|
||||
// The server only auto-listens when this file is the process entry point.
|
||||
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { getData, loadData } from './data.js';
|
||||
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
|
||||
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
||||
@@ -42,6 +46,13 @@ import { createAssetIndex } from './http/assetIndex.js';
|
||||
import { createManifestViews } from './http/assetViews.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
||||
import { createSklandStore } from './skland/store.js';
|
||||
import { createSklandService } from './skland/service.js';
|
||||
import { createSklandHttp, createSklandOffHandler } from './skland/http.js';
|
||||
import { createSessionPlayerResolver } from './skland/session.js';
|
||||
import { createAccountAuth } from './skland/auth.js';
|
||||
import { createAccountHttp } from './skland/accountHttp.js';
|
||||
import { createAccountRecovery } from './skland/recovery.js';
|
||||
|
||||
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
|
||||
export {
|
||||
@@ -60,8 +71,12 @@ export {
|
||||
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
||||
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
|
||||
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
|
||||
* skland?: boolean | { storeFile?: string, fetchImpl?: typeof fetch, client?: object, scanTimeoutMs?: number,
|
||||
* authSecret?: string, publicOrigin?: string, diagnosticKey?: string, authNow?: () => number },
|
||||
* }} [opts]
|
||||
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
|
||||
* skland: the Skland account binding (server/skland/), off by default (SP_SKLAND=1 turns it on). `storeFile` picks
|
||||
* the SQLite file (SP_SKLAND_DB, default data/skland.db); `fetchImpl` replaces the upstream HTTP (tests).
|
||||
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
@@ -83,14 +98,20 @@ export async function startServer(opts = {}) {
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
||||
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
|
||||
// The Skland account binding (server/skland/): off unless asked for (SP_SKLAND=1, or opts.skland). Its own SQLite
|
||||
// file (SP_SKLAND_DB, default <dataDir>/skland.db) holds one row per player; the routes are /skland/* (POST).
|
||||
const skland = createSklandModule(opts, { log, registry, network, lobby });
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views,
|
||||
privateFiles: skland?.privateFiles || [] });
|
||||
// Off still owns /skland/*: one uniform JSON 404 beats a static 404 for GET and a 405 for POST (skland/http.js).
|
||||
const sklandHttp = skland ? skland.http : createSklandOffHandler();
|
||||
const startedAt = Date.now();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
resetBuildTag();
|
||||
buildTag();
|
||||
|
||||
const traffic = createTrafficCounter();
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy }));
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy, skland: sklandHttp }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log, traffic });
|
||||
|
||||
@@ -142,6 +163,7 @@ export async function startServer(opts = {}) {
|
||||
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
|
||||
network.close();
|
||||
assetIndex?.close();
|
||||
try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); }
|
||||
await new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
server.closeIdleConnections?.();
|
||||
@@ -152,7 +174,37 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, skland: skland ? skland.service : null, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skland module (server/skland/), or null when it is off. Off is the default: an unconfigured server keeps no
|
||||
* account credentials at all. Turn it on with `SP_SKLAND=1` (or `opts.skland`), which every caller of startServer can
|
||||
* pass — the HTTP tests inject their own store file and a stub `fetchImpl`, so nothing here reaches the network.
|
||||
* @param {Record<string, any>} opts
|
||||
* @param {{ log: object, registry: import('./net.js').SessionRegistry, network: import('./net.js').Network }} deps
|
||||
* @returns {{ service: any, http: any, privateFiles: string[], close: () => void } | null}
|
||||
*/
|
||||
function createSklandModule(opts, { log, registry, network, lobby }) {
|
||||
const cfg = opts.skland;
|
||||
const enabled = !!cfg || process.env.SP_SKLAND === '1';
|
||||
if (!enabled) return null;
|
||||
const conf = typeof cfg === 'object' ? cfg : {};
|
||||
const file = conf.storeFile || process.env.SP_SKLAND_DB || path.join(ROOT, 'data', 'skland.db');
|
||||
const store = createSklandStore({ file, log });
|
||||
const service = createSklandService({ store, fetchImpl: conf.fetchImpl, client: conf.client, scanTimeoutMs: conf.scanTimeoutMs });
|
||||
const auth = createAccountAuth({ file: store.file, service, registry, secret: conf.authSecret || process.env.SP_AUTH_SECRET,
|
||||
publicOrigin: conf.publicOrigin || process.env.SP_PUBLIC_ORIGIN, diagnosticKey: conf.diagnosticKey || process.env.SP_SKLAND_DIAGNOSTIC_KEY,
|
||||
trustProxy: network.opts.trustProxy, now: conf.authNow, onExpire: (session) => lobby.onExpire(session) });
|
||||
network.accountPolicy = auth.gamePolicy;
|
||||
log.info?.(`[skland] account binding on — ${store.file}`);
|
||||
return {
|
||||
service,
|
||||
http: createAccountHttp({ service, auth, registry, recovery: createAccountRecovery({ registry, lobby }),
|
||||
diagnostic: createSklandHttp({ service, sessionPlayer: createSessionPlayerResolver(registry) }) }),
|
||||
privateFiles: store.file === ':memory:' ? [] : [store.file, store.file + '-wal', store.file + '-shm'],
|
||||
close: () => { auth.close(); store.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
|
||||
@@ -184,6 +184,16 @@ export class SessionRegistry {
|
||||
if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token);
|
||||
}
|
||||
|
||||
/** Rotate an authenticated session's bearer after attaching a new socket. @param {Session} s */
|
||||
rotateToken(s) {
|
||||
const previous = s.token;
|
||||
if (this.byTokenMap.get(previous) === s) this.byTokenMap.delete(previous);
|
||||
let token;
|
||||
do token = newToken(); while (token === previous || this.byTokenMap.has(token));
|
||||
s.token = token;
|
||||
this.byTokenMap.set(token, s);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer
|
||||
* (the lobby may only extend it, never shorten it).
|
||||
@@ -526,6 +536,8 @@ export class Network {
|
||||
/** @type {Map<string, number>} open sockets per client network key */
|
||||
this.connsPerKey = new Map();
|
||||
this.closed = false;
|
||||
// Optional fork account policy; transport and the upstream game protocol remain usable when it is absent.
|
||||
this.accountPolicy = null;
|
||||
this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);
|
||||
this.heartbeatTimer.unref?.();
|
||||
const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4)));
|
||||
@@ -559,6 +571,7 @@ export class Network {
|
||||
if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; }
|
||||
const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts);
|
||||
this.conns.set(ws, conn);
|
||||
if (this.accountPolicy) conn.account = this.accountPolicy.connection(req);
|
||||
if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1);
|
||||
ws.on('message', (data, isBinary) => {
|
||||
try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); }
|
||||
@@ -612,6 +625,9 @@ export class Network {
|
||||
}
|
||||
if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; }
|
||||
if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; }
|
||||
if (this.accountPolicy && !this.accountPolicy.authorized(conn)) {
|
||||
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account binding required')); return;
|
||||
}
|
||||
if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; }
|
||||
|
||||
let res;
|
||||
@@ -640,6 +656,11 @@ export class Network {
|
||||
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`));
|
||||
return;
|
||||
}
|
||||
if (this.accountPolicy) {
|
||||
const account = this.accountPolicy.hello(conn, msg);
|
||||
if (!account) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account authentication required')); return; }
|
||||
msg = { ...msg, ...account };
|
||||
}
|
||||
const name = sanitizeName(msg.name);
|
||||
if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; }
|
||||
|
||||
@@ -670,6 +691,7 @@ export class Network {
|
||||
session.lastSeen = now;
|
||||
session.addr = conn.ip;
|
||||
session.limitKey = conn.key;
|
||||
this.accountPolicy?.attached?.(conn, { repeat });
|
||||
|
||||
let extra = null;
|
||||
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// i18n-ignore-file: API errors are diagnostics; the account UI supplies translated labels.
|
||||
import { sendJson } from '../http/common.js';
|
||||
import { readJsonBody } from './http.js';
|
||||
import { ACCOUNT_PREFIX } from './auth.js';
|
||||
import { SESSION_BINDING_PREFIX } from './session.js';
|
||||
|
||||
export function createAccountHttp({ service, auth, registry, recovery, diagnostic }) {
|
||||
const endpoints = { status: 'GET', roles: 'GET', recovery: 'GET', enter: 'POST', resume: 'POST', 'scan/start': 'POST', 'scan/poll': 'POST',
|
||||
'phone/send': 'POST', 'phone/login': 'POST', 'password/login': 'POST', 'role/select': 'POST', unbind: 'POST' };
|
||||
return async (req, res, parts) => {
|
||||
const fail = (status, error, code) => { sendJson(req, res, status, { ok: false, error, ...(code ? { code } : {}) }); return true; };
|
||||
if (!auth.originAllowed(req)) return fail(403, '跨站请求已拒绝 · Cross-origin request denied');
|
||||
if (auth.limited(req)) return fail(429, '请求过于频繁 · Too many requests');
|
||||
if (!parts.rawPath.startsWith('/skland/session/')) {
|
||||
if (!auth.diagnosticAllowed(req)) return fail(403, '诊断接口需要管理员密钥 · Diagnostic API requires an admin key');
|
||||
return diagnostic(req, res, parts);
|
||||
}
|
||||
const endpoint = parts.rawPath.slice('/skland/session/'.length);
|
||||
if (!Object.hasOwn(endpoints, endpoint)) return fail(404, '未知接口 · Unknown endpoint');
|
||||
if (req.method !== endpoints[endpoint]) return fail(405, '不支持的请求方法 · Method not allowed');
|
||||
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
|
||||
const session = match && registry.byToken(match[1]);
|
||||
if (!session || registry.isExpired(session, registry.now())) return fail(401, '游戏会话已失效 · Game session expired');
|
||||
const query = new URLSearchParams(parts.query);
|
||||
let body = {};
|
||||
if (req.method === 'POST') {
|
||||
try { body = await readJsonBody(req); } catch { return fail(400, '请求体无效 · Bad request'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, '请求体无效 · Bad request');
|
||||
}
|
||||
// Reading a POST body yields: logout, expiry or a device takeover may invalidate either credential meanwhile.
|
||||
if (registry.byToken(match[1]) !== session) return fail(401, '游戏会话已失效 · Game session expired');
|
||||
let login;
|
||||
try { login = auth.authenticate(req); } catch { auth.setCookie(req, res, null); return fail(401, '登录已过期,请重新绑定 · Login expired'); }
|
||||
if (session.playerId.startsWith('a_') && (!login || auth.state(login).gamePlayerId !== session.playerId)) {
|
||||
return fail(403, '身份不匹配 · Identity mismatch');
|
||||
}
|
||||
// Claimed identity fields never choose the target row; all ownership comes from authenticated credentials.
|
||||
const pendingPlayer = SESSION_BINDING_PREFIX + session.playerId;
|
||||
const issue = () => {
|
||||
login = auth.publish(pendingPlayer);
|
||||
if (login) auth.setCookie(req, res, login);
|
||||
};
|
||||
if (!login && service.status(pendingPlayer).bound) issue(); // Migrate an existing binding owned by this transport.
|
||||
const player = login ? ACCOUNT_PREFIX + login.subject : pendingPlayer;
|
||||
const syncSelection = () => {
|
||||
if (!login) return;
|
||||
const updated = auth.ensureSelection(login);
|
||||
if (updated !== login) { login = updated; auth.setCookie(req, res, login); }
|
||||
};
|
||||
try {
|
||||
let answer;
|
||||
if (endpoint === 'status') { syncSelection(); answer = login ? auth.state(login) : service.status(player); }
|
||||
else if (endpoint === 'recovery' || endpoint === 'enter' || endpoint === 'resume') {
|
||||
if (!login) return fail(401, '请先绑定账号 · Account binding required');
|
||||
syncSelection();
|
||||
const playerId = auth.state(login).gamePlayerId;
|
||||
if (!playerId) return fail(409, '请选择有效角色 · A playable role is required');
|
||||
const item = recovery.describe(playerId);
|
||||
if (endpoint === 'recovery') answer = { ok: true, recovery: item };
|
||||
else {
|
||||
if (endpoint === 'resume' && (!item || typeof body.id !== 'string' || body.id !== item.id)) {
|
||||
return fail(409, '此对局已结束或过期,请刷新后重试 · Recovery is no longer available', 'resume_expired');
|
||||
}
|
||||
const target = auth.gameSession(login);
|
||||
answer = { ok: true, gamePlayerId: target.playerId, token: target.token, name: target.name };
|
||||
}
|
||||
} else if (endpoint === 'roles') {
|
||||
if (!login) return fail(401, '请先绑定账号 · Account binding required');
|
||||
answer = { ok: true, roles: await service.roles(player, query.get('refresh') === '1') };
|
||||
syncSelection();
|
||||
} else if (endpoint === 'role/select') {
|
||||
if (!login) return fail(401, '请先绑定账号 · Account binding required');
|
||||
const selected = auth.select(login, body.roleKey);
|
||||
if (!selected) return fail(403, '无法选择该角色 · Role not owned or currently in a room');
|
||||
login = selected; auth.setCookie(req, res, login); answer = { ok: true, ...auth.state(login) };
|
||||
} else if (endpoint === 'unbind') {
|
||||
if (login) {
|
||||
const role = auth.roleOf(login), current = role && registry.byId(auth.state(login).gamePlayerId);
|
||||
if (current?.roomCode) return fail(409, '请先离开同盟 · Leave your room first');
|
||||
auth.revoke(login);
|
||||
}
|
||||
service.unbind(pendingPlayer); auth.setCookie(req, res, null); answer = { ok: true };
|
||||
} else {
|
||||
if (login) return fail(409, '请先解绑当前账号 · Unbind the current account first');
|
||||
if (endpoint === 'scan/start') answer = { ok: true, ...await service.startScan(player) };
|
||||
else if (endpoint === 'scan/poll') {
|
||||
answer = { ok: true, ...await service.pollScan(player) };
|
||||
if (answer.state === 'confirmed' && !answer.error) issue();
|
||||
} else if (endpoint === 'phone/send') {
|
||||
if (!body.phone) return fail(400, '缺少手机号 · Missing phone');
|
||||
answer = await service.sendPhoneCode(player, String(body.phone), body.captcha);
|
||||
} else if (endpoint === 'phone/login') {
|
||||
if (!body.phone || !body.code) return fail(400, '缺少登录信息 · Missing login fields');
|
||||
answer = await service.loginByPhoneCode(player, String(body.phone), String(body.code));
|
||||
if (answer.ok) issue();
|
||||
} else if (endpoint === 'password/login') {
|
||||
if (!body.phone || !body.password) return fail(400, '缺少登录信息 · Missing login fields');
|
||||
answer = await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, body.captchaStep);
|
||||
if (answer.ok) issue();
|
||||
}
|
||||
}
|
||||
sendJson(req, res, 200, answer);
|
||||
} catch (error) {
|
||||
sendJson(req, res, 200, { ok: false, ...(endpoint === 'scan/poll' ? { state: 'error' } : {}), error: error.message });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { TokenBucket, isLocalIp, sanitizeName } from '../net.js';
|
||||
import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js';
|
||||
|
||||
export const ACCOUNT_PREFIX = '@account/';
|
||||
export const ACCOUNT_COOKIE = 'sp_account';
|
||||
export const LOGIN_TTL = 30 * 24 * 60 * 60;
|
||||
const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game';
|
||||
const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b);
|
||||
// 128 digest bits in base64url keep `n:<playerId>` within g.watch's existing 32-character field limit.
|
||||
export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url');
|
||||
|
||||
export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) {
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec(`PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_logins (
|
||||
id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0
|
||||
);`);
|
||||
if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes');
|
||||
db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url'));
|
||||
const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value);
|
||||
const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null;
|
||||
const signature = (value) => createHmac('sha256', key).update(value).digest();
|
||||
const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName);
|
||||
const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null;
|
||||
const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000)
|
||||
&& service.status(ACCOUNT_PREFIX + login.subject).bound;
|
||||
const buckets = new Map();
|
||||
function limited(req) {
|
||||
const id = req.socket?.remoteAddress || '?';
|
||||
if (!buckets.has(id)) {
|
||||
if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value);
|
||||
buckets.set(id, new TokenBucket(3, 120, now()));
|
||||
}
|
||||
return !buckets.get(id).take(now());
|
||||
}
|
||||
function originAllowed(req) {
|
||||
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
|
||||
if (!req.headers.origin) return true; // Non-browser clients still need both credentials.
|
||||
const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress))
|
||||
&& req.headers['x-forwarded-proto'] === 'https');
|
||||
return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`);
|
||||
}
|
||||
function cookieValue(req) {
|
||||
const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '='));
|
||||
if (entries.length > 1) throw new Error('invalid account cookie');
|
||||
return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null;
|
||||
}
|
||||
function verify(token) {
|
||||
try {
|
||||
if (typeof token !== 'string' || token.length > 4096) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
|
||||
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
||||
if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null;
|
||||
if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null;
|
||||
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
const at = Math.floor(now() / 1000);
|
||||
if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at
|
||||
|| !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL
|
||||
|| typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null;
|
||||
const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti);
|
||||
return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function authenticate(req) {
|
||||
const token = cookieValue(req);
|
||||
if (!token) return null;
|
||||
const login = verify(token);
|
||||
if (!login) throw new Error('invalid account cookie');
|
||||
return login;
|
||||
}
|
||||
function setCookie(req, res, login) {
|
||||
const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:')
|
||||
|| (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https');
|
||||
let value = '', maxAge = 0;
|
||||
if (login) {
|
||||
const at = Math.floor(now() / 1000);
|
||||
const head = json64({ alg: 'HS256', typ: 'JWT' });
|
||||
const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id,
|
||||
iat: at, exp: login.expires, role: login.role_key });
|
||||
value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url');
|
||||
maxAge = Math.max(0, login.expires - at);
|
||||
}
|
||||
res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`);
|
||||
}
|
||||
function publish(player) {
|
||||
const row = service._store.get(player);
|
||||
if (!row || row.status !== 'bound') return null;
|
||||
const roles = service.status(player).roles.filter(playable);
|
||||
const selected = roles.find((role) => role.isDefault) || roles[0];
|
||||
const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null,
|
||||
expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 };
|
||||
const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row;
|
||||
service._store.update(ACCOUNT_PREFIX + login.subject, credentials);
|
||||
db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)')
|
||||
.run(login.id, login.subject, login.role_key, login.expires);
|
||||
service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout.
|
||||
return login;
|
||||
}
|
||||
function select(login, selectedKey) {
|
||||
const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey);
|
||||
if (!role) return null;
|
||||
const old = roleOf(login), session = old && registry.byId(accountPlayerId(old));
|
||||
if (session?.roomCode && selectedKey !== login.role_key) return null;
|
||||
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
|
||||
return { ...login, role_key: selectedKey };
|
||||
}
|
||||
function ensureSelection(login) {
|
||||
if (roleOf(login)) return login;
|
||||
const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable);
|
||||
const role = roles.find((item) => item.isDefault) || roles[0];
|
||||
const selectedKey = role ? roleKey(role) : null;
|
||||
if (selectedKey === login.role_key) return login;
|
||||
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
|
||||
return { ...login, role_key: selectedKey };
|
||||
}
|
||||
function state(login) {
|
||||
const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login);
|
||||
return { ...status, loginId: login.id, selectedRoleKey: login.role_key,
|
||||
gamePlayerId: role ? accountPlayerId(role) : null };
|
||||
}
|
||||
function roleSession(role) {
|
||||
const id = accountPlayerId(role);
|
||||
let session = registry.byId(id);
|
||||
if (session && registry.isExpired(session, registry.now())) {
|
||||
registry.remove(session);
|
||||
onExpire?.(session); // Remove the old seat before reusing its stable account playerId.
|
||||
session = null;
|
||||
}
|
||||
if (!session) {
|
||||
session = registry.create(sanitizeName(role.nickName));
|
||||
if (!session) throw new Error('server full');
|
||||
registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
function revoke(login) {
|
||||
db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject);
|
||||
service.unbind(ACCOUNT_PREFIX + login.subject);
|
||||
}
|
||||
const gamePolicy = {
|
||||
connection(req) {
|
||||
let login = null;
|
||||
try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ }
|
||||
return { login, allowed: originAllowed(req) };
|
||||
},
|
||||
hello(conn, message) {
|
||||
if (!conn.account.allowed) return null;
|
||||
const login = conn.account.login;
|
||||
if (login) {
|
||||
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id);
|
||||
const role = active(current) && current.role_key === login.role_key && roleOf(current);
|
||||
const target = conn.session || (message.token && registry.byToken(message.token));
|
||||
// Merely restoring the cookie or viewing the title must not take over another device. A current role token
|
||||
// resumes normally; the authenticated enter/resume POST supplies it after an explicit user action.
|
||||
if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token };
|
||||
}
|
||||
// Anonymous hellos may establish transport for login, but never resume an authenticated game identity.
|
||||
if (conn.session?.playerId.startsWith('a_')) return null;
|
||||
const target = message.token && registry.byToken(message.token);
|
||||
return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token };
|
||||
},
|
||||
attached(conn, { repeat }) {
|
||||
if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session);
|
||||
},
|
||||
authorized(conn) {
|
||||
const captured = conn.account?.login;
|
||||
if (!captured) return false;
|
||||
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id);
|
||||
const role = active(current) && current.role_key === captured.role_key && roleOf(current);
|
||||
return !!role && conn.session?.playerId === accountPlayerId(role);
|
||||
},
|
||||
};
|
||||
return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited,
|
||||
gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; },
|
||||
diagnosticAllowed(req) {
|
||||
const supplied = req.headers['x-sp-diagnostic-key'];
|
||||
return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey));
|
||||
},
|
||||
close() { db.close(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an
|
||||
// (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of
|
||||
// errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the
|
||||
// "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads)
|
||||
// injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here
|
||||
// touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland
|
||||
// endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js.
|
||||
//
|
||||
// The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service:
|
||||
// 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred
|
||||
// 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred
|
||||
// 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list
|
||||
//
|
||||
// A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant
|
||||
// even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification.
|
||||
// Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14).
|
||||
//
|
||||
// SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and
|
||||
// returns its proof to the challenged step; the server never solves the challenge or exposes account tokens.
|
||||
//
|
||||
// Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired →
|
||||
// re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds.
|
||||
|
||||
import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js';
|
||||
|
||||
const AS_BASE = 'https://as.hypergryph.com';
|
||||
const ZONAI_BASE = 'https://zonai.skland.com';
|
||||
const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com';
|
||||
// Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14).
|
||||
const BINDING_APP_CODE = 'be36d44aa36bfb5b';
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */
|
||||
export class SklandError extends Error {
|
||||
/**
|
||||
* @param {string} message @param {number | string} [code] @param {string} [step]
|
||||
*/
|
||||
constructor(message, code, step) {
|
||||
super(message);
|
||||
this.name = 'SklandError';
|
||||
this.code = code;
|
||||
this.step = step;
|
||||
}
|
||||
}
|
||||
|
||||
/** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */
|
||||
export class SklandCaptchaError extends SklandError {
|
||||
/**
|
||||
* @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha
|
||||
*/
|
||||
constructor(captcha, step = 'sendPhoneCode') {
|
||||
super('需要人机验证,请完成验证后重试', 'captcha', step);
|
||||
this.name = 'SklandCaptchaError';
|
||||
this.captcha = captcha;
|
||||
}
|
||||
}
|
||||
|
||||
/** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */
|
||||
export class SklandNeedDeviceError extends SklandError {
|
||||
constructor(message = '当前设备需要额外验证', step = 'getGrantCode') {
|
||||
super(`${message},请使用验证码或扫码完成登录`, 109, step);
|
||||
this.name = 'SklandNeedDeviceError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(res, step) {
|
||||
try { return await res.json(); } catch {
|
||||
throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step);
|
||||
}
|
||||
}
|
||||
|
||||
/** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */
|
||||
function assertAs(json, step, captchaStep = step) {
|
||||
if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep);
|
||||
if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step);
|
||||
if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step);
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
/** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */
|
||||
function assertZonai(json, step) {
|
||||
if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step);
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* fetchImpl?: typeof fetch,
|
||||
* baseHeaders?: Record<string, string>,
|
||||
* pollIntervalMs?: number,
|
||||
* pollTimeoutMs?: number,
|
||||
* sleep?: (ms: number) => Promise<void>,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function createSklandClient(opts = {}) {
|
||||
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
||||
const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
||||
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||
const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) };
|
||||
|
||||
async function post(url, body, extraHeaders) {
|
||||
return fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 扫码登录 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link),
|
||||
* not the scanId — the 森空岛 app's scanner reads the deep link.
|
||||
* @returns {Promise<{ scanId: string, scanUrl: string }>}
|
||||
*/
|
||||
async function genScan() {
|
||||
const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE });
|
||||
const json = await readJson(res, '获取扫码会话');
|
||||
const data = assertAs(json, '获取扫码会话');
|
||||
return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it).
|
||||
* The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation).
|
||||
* Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14).
|
||||
* Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors.
|
||||
* @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>}
|
||||
*/
|
||||
async function checkScan(scanId) {
|
||||
const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders });
|
||||
const json = await readJson(res, '查询扫码状态');
|
||||
if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode };
|
||||
if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 };
|
||||
throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 (looping): poll until the phone confirms, or the timeout passes.
|
||||
* @param {string} scanId
|
||||
* @param {{ onWait?: (state: string) => void }} [o]
|
||||
* @returns {Promise<string>} the scanCode
|
||||
*/
|
||||
async function pollScan(scanId, o = {}) {
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
for (;;) {
|
||||
const { state, scanCode } = await checkScan(scanId);
|
||||
if (state === 'confirmed') return scanCode;
|
||||
if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan');
|
||||
o.onWait?.(state);
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 3: scanCode → { token, hgId?, deviceToken? }. */
|
||||
async function tokenByScanCode(scanCode) {
|
||||
const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`,
|
||||
{ appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders());
|
||||
const json = await readJson(res, '扫码换取 token');
|
||||
const data = assertAs(json, '扫码换取 token');
|
||||
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
||||
}
|
||||
|
||||
// ── 验证码登录 ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center:
|
||||
* https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14).
|
||||
* Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors;
|
||||
* retry the latter with the solved `captcha` fields.
|
||||
* @param {string} phone
|
||||
* @param {Record<string, string>} [captcha] Geetest v3/v4 proof supplied by the browser
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
async function sendPhoneCode(phone, captcha) {
|
||||
const body = { phone, type: 2 };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body);
|
||||
const json = await readJson(res, '发送验证码');
|
||||
if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha);
|
||||
if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode');
|
||||
assertAs(json, '发送验证码');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */
|
||||
async function tokenByPhoneCode(phone, code) {
|
||||
const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`,
|
||||
{ appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders());
|
||||
const json = await readJson(res, '验证码换取 token');
|
||||
const data = assertAs(json, '验证码换取 token');
|
||||
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
||||
}
|
||||
|
||||
// ── 密码登录 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码 → { token }; a deviceToken is not required locally to try the following grant. */
|
||||
async function tokenByPhonePassword(phone, password, captcha) {
|
||||
const body = { phone, password };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders());
|
||||
const json = await readJson(res, '密码换取 token');
|
||||
const data = assertAs(json, '密码换取 token', 'password');
|
||||
if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword');
|
||||
return { token: data.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Arknights account bindings without a Skland account or cred.
|
||||
* Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak').
|
||||
* Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14).
|
||||
*/
|
||||
async function getAccountBinding(accessToken, captcha) {
|
||||
const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`,
|
||||
body, deviceHeaders());
|
||||
const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant');
|
||||
if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding');
|
||||
const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' });
|
||||
const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders });
|
||||
const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定');
|
||||
if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding');
|
||||
return binding;
|
||||
}
|
||||
|
||||
// ── token → 授权码 → cred → 绑定 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* access_token (+ deviceToken when the login path provided one) → grant code.
|
||||
* @param {string} accessToken
|
||||
* @param {string | null} [deviceToken]
|
||||
* @returns {Promise<string>} the grant code
|
||||
*/
|
||||
async function getGrantCode(accessToken, deviceToken = null) {
|
||||
const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 };
|
||||
if (deviceToken) body.deviceToken = deviceToken;
|
||||
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body);
|
||||
const json = await readJson(res, '换取授权码');
|
||||
return assertAs(json, '换取授权码').code;
|
||||
}
|
||||
|
||||
/** grant code → { cred, credToken } (this endpoint takes no signature). */
|
||||
async function getCred(code) {
|
||||
const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 });
|
||||
const json = await readJson(res, '换取 cred');
|
||||
const data = assertZonai(json, '换取 cred');
|
||||
return { cred: data.cred, credToken: data.token };
|
||||
}
|
||||
|
||||
/** A signed GET on zonai (the signature lives in crypto.js). */
|
||||
async function signedGet(path, query, cred, credToken, dId) {
|
||||
const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query });
|
||||
const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } });
|
||||
return readJson(res, path);
|
||||
}
|
||||
|
||||
/** A signed POST on zonai (attendance etc.). */
|
||||
async function signedPost(path, body, cred, credToken, dId) {
|
||||
const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body });
|
||||
const res = await post(`${ZONAI_BASE}${path}`, body, headers);
|
||||
return readJson(res, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's bound games / roles, refreshing the credential once when the upstream says it expired.
|
||||
* @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c
|
||||
* @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials
|
||||
*/
|
||||
async function getBinding(c) {
|
||||
let { cred, credToken } = c;
|
||||
const dId = c.dId;
|
||||
let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
if (json.code === 10000) {
|
||||
const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred });
|
||||
const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token');
|
||||
credToken = data.token;
|
||||
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
}
|
||||
if (json.code === 10002 && (c.accessToken || c.deviceToken)) {
|
||||
const grantCode = await getGrantCode(c.accessToken, c.deviceToken);
|
||||
const fresh = await getCred(grantCode);
|
||||
cred = fresh.cred;
|
||||
credToken = fresh.credToken;
|
||||
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const message = json.message || json.msg || '';
|
||||
if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding');
|
||||
throw new SklandError(message || '查询绑定失败', json.code, 'getBinding');
|
||||
}
|
||||
return { binding: json.data, cred, credToken };
|
||||
}
|
||||
|
||||
// ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ──────────────────────────────────
|
||||
|
||||
/** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */
|
||||
async function loginByScan({ onWait } = {}) {
|
||||
const { scanId, scanUrl } = await genScan();
|
||||
const scanCode = await pollScan(scanId, { onWait });
|
||||
const auth = await tokenByScanCode(scanCode);
|
||||
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await getCred(grantCode);
|
||||
return { scanId, scanUrl, ...auth, cred, credToken };
|
||||
}
|
||||
|
||||
/** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */
|
||||
async function loginByPhoneCode(phone, code) {
|
||||
const auth = await tokenByPhoneCode(phone, code);
|
||||
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await getCred(grantCode);
|
||||
return { ...auth, cred, credToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Password → HG account token → binding-service token (type 1) → Arknights roles.
|
||||
* This flow does not acquire a Skland cred or require a stored deviceToken.
|
||||
*/
|
||||
async function loginByPhonePassword(phone, password) {
|
||||
const { token } = await tokenByPhonePassword(phone, password);
|
||||
const binding = await getAccountBinding(token);
|
||||
return { token, binding };
|
||||
}
|
||||
|
||||
return {
|
||||
genScan, checkScan, pollScan, tokenByScanCode,
|
||||
sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword,
|
||||
getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost,
|
||||
loginByScan, loginByPhoneCode, loginByPhonePassword,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// server/skland/crypto.js — the Skland API request signature (docs/PACKS.md §4 keeps packs data-only; this module is
|
||||
// server code, not a pack). Same algorithm the community clients use, reconstructed from the public descriptions:
|
||||
//
|
||||
// header_ca = { platform: '3', timestamp: '<seconds>', dId: '<device id>', vName: '1.0.0' }
|
||||
// GET: raw = path + query + timestamp + compactJson(header_ca)
|
||||
// POST: raw = path + compactJson(body) + timestamp + compactJson(header_ca)
|
||||
// sign = md5( hmacSha256Hex(credToken, raw) )
|
||||
//
|
||||
// Only Web Crypto / node:crypto are used; no third-party dependency. Pure functions — fully unit-tested in
|
||||
// test/skland/crypto.test.js against pinned vectors.
|
||||
|
||||
import { createHmac, createHash, randomUUID, randomBytes } from 'node:crypto';
|
||||
|
||||
/** The Skland mobile client's appCode (public SDK constant, visible in decompiled clients). */
|
||||
export const SKLAND_APP_CODE = '4ca99fa6b56cc2ba';
|
||||
/** User-Agent the upstream expects (a Skland Android client). */
|
||||
export const SKLAND_UA = 'Skland/1.32.1 (com.hypergryph.skland; build:103201004; Android 33; ) Okhttp/4.11.0';
|
||||
|
||||
/** Compact JSON: no spaces, key order preserved (the upstream signs the exact body bytes). */
|
||||
export function compactJson(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the signed header set for one Skland API request.
|
||||
* @param {{
|
||||
* cred: string, credToken: string, dId: string,
|
||||
* method: 'GET' | 'POST', path: string,
|
||||
* query?: string, body?: object | string,
|
||||
* timestampSeconds?: number,
|
||||
* }} p
|
||||
* @returns {{ headers: Record<string, string>, signInput: string }}
|
||||
*/
|
||||
export function signedHeaders(p) {
|
||||
const timestampSeconds = p.timestampSeconds ?? Math.floor(Date.now() / 1000) - 2;
|
||||
const ts = String(timestampSeconds);
|
||||
const headerCa = { platform: '3', timestamp: ts, dId: p.dId, vName: '1.0.0' };
|
||||
const caJson = compactJson(headerCa);
|
||||
const method = p.method.toUpperCase();
|
||||
const bodyJson = method === 'POST'
|
||||
? compactJson(p.body ?? {})
|
||||
: '';
|
||||
const query = p.query || '';
|
||||
const signInput = method === 'POST'
|
||||
? `${p.path}${bodyJson}${ts}${caJson}`
|
||||
: `${p.path}${query}${ts}${caJson}`;
|
||||
const sign = md5(hmacSha256Hex(p.credToken, signInput));
|
||||
return {
|
||||
signInput,
|
||||
headers: {
|
||||
'User-Agent': SKLAND_UA,
|
||||
'Accept-Encoding': 'gzip',
|
||||
Connection: 'close',
|
||||
'X-Requested-With': 'com.hypergryph.skland',
|
||||
cred: p.cred,
|
||||
dId: p.dId,
|
||||
platform: '3',
|
||||
timestamp: ts,
|
||||
vName: '1.0.0',
|
||||
sign,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** HMAC-SHA256, lowercase hex digest. */
|
||||
export function hmacSha256Hex(key, message) {
|
||||
return createHmac('sha256', key).update(message, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** MD5, lowercase hex digest. */
|
||||
export function md5(text) {
|
||||
return createHash('md5').update(text, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* A device fingerprint (`dId`). The upstream accepts any stable opaque value here; the community clients derive
|
||||
* one from a random UUID, cache it and reuse it for the account's lifetime. `B` + 32 hex mirrors their format.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function makeDeviceId() {
|
||||
return `B${randomBytes(16).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** A fresh scan session id placeholder (not a real upstream value — tests only). */
|
||||
export function randomId() {
|
||||
return randomUUID().replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The account/login device headers (as.hypergryph.com 验证码 / 密码 / 扫码登录都要求):
|
||||
* `X-DeviceId` random ~10 chars, `X-DeviceModel` a device name, `X-DeviceType: 2` (mobile).
|
||||
* The device id is stable per process so the upstream sees one device for the server's lifetime.
|
||||
* @returns {{ 'X-DeviceId': string, 'X-DeviceModel': string, 'X-DeviceType': string }}
|
||||
*/
|
||||
export function deviceHeaders() {
|
||||
if (!deviceHeaders.cache) {
|
||||
deviceHeaders.cache = Object.freeze({
|
||||
'X-DeviceId': randomBytes(5).toString('hex'), // 10 hex chars
|
||||
'X-DeviceModel': 'Stronghold-Protocol Server',
|
||||
'X-DeviceType': '2',
|
||||
});
|
||||
}
|
||||
return deviceHeaders.cache;
|
||||
}
|
||||
deviceHeaders.cache = null;
|
||||
@@ -0,0 +1,158 @@
|
||||
// server/skland/http.js — the /skland/* HTTP surface (JSON). Wired into the main request listener by routes.js when
|
||||
// (i18n-ignore-file: the answers are bilingual by design, 中文 · English, the same convention as server/http/*
|
||||
// — docs/I18N.md; the panel renders its own labels and shows these strings verbatim as the diagnostic)
|
||||
// the skland module is enabled (opts.skland / env SP_SKLAND=1). **Every** answer of this surface is JSON, including
|
||||
// the errors: a JSON client must never be handed the module-level HTML error page (http/common.js sendError), so a
|
||||
// local `fail()` writes { ok: false, error } with the status instead. POST bodies are read with a size cap; the
|
||||
// security headers come from the outer listener.
|
||||
//
|
||||
// Endpoints (docs/skland.md):
|
||||
// GET /skland/status?player=… the player's binding state
|
||||
// POST /skland/scan/start {player} start a scan session → { scanId, scanUrl }
|
||||
// POST /skland/scan/poll {player} one poll; finishes the login when the phone confirmed
|
||||
// POST /skland/phone/send {player, phone, captcha?} send the SMS code
|
||||
// POST /skland/phone/login {player, phone, code} 验证码 login → bind
|
||||
// POST /skland/password/login {player, phone, password} 密码 login → bind
|
||||
// POST /skland/unbind {player} forget the credentials
|
||||
// GET /skland/roles?player=…&refresh=1 the bound arknights roles (cached)
|
||||
|
||||
import { sendJson } from '../http/common.js';
|
||||
import { SESSION_BINDING_PREFIX } from './session.js';
|
||||
|
||||
/** The cap on a POST body (a phone + code + player id is small). */
|
||||
const MAX_BODY = 64 * 1024;
|
||||
|
||||
/** Read the request body as JSON (async iterable; node:http IncomingMessage supports it). */
|
||||
export async function readJsonBody(req) {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) throw new Error('body too large');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ service: ReturnType<typeof import('./service.js').createSklandService>, sessionPlayer?: Function }} deps
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* parts: { rawPath: string, query: string }) => Promise<boolean>}
|
||||
* resolves true when the request was a /skland/* route (handled here).
|
||||
*/
|
||||
export function createSklandHttp({ service, sessionPlayer }) {
|
||||
const queryParam = (parts, name) => {
|
||||
for (const pair of parts.query.split('&')) {
|
||||
const [k, v] = pair.split('=');
|
||||
if (decodeURIComponent(k || '') === name) return decodeURIComponent(v || '');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/** Every failure of this surface is JSON — never the module-level HTML error page. */
|
||||
const fail = (req, res, status, error) => sendJson(req, res, status, { ok: false, error });
|
||||
|
||||
async function handle(req, res, parts) {
|
||||
if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false;
|
||||
const scoped = parts.rawPath.startsWith('/skland/session/');
|
||||
const path = scoped ? parts.rawPath.replace('/skland/session/', '/skland/') : parts.rawPath;
|
||||
const ownedPlayer = scoped ? sessionPlayer?.(req) : null;
|
||||
if (scoped && !ownedPlayer) {
|
||||
fail(req, res, 401, '游戏会话已失效,请重新连接 · Game session expired'); return true;
|
||||
}
|
||||
const allowed = (player) => scoped || (!player.startsWith(SESSION_BINDING_PREFIX) && !player.startsWith('@account/'));
|
||||
try {
|
||||
if (req.method === 'GET' && path === '/skland/status') {
|
||||
const player = ownedPlayer || queryParam(parts, 'player') || '';
|
||||
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
|
||||
if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; }
|
||||
sendJson(req, res, 200, service.status(player));
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'GET' && path === '/skland/roles') {
|
||||
const player = ownedPlayer || queryParam(parts, 'player') || '';
|
||||
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
|
||||
const refresh = queryParam(parts, 'refresh') === '1';
|
||||
if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; }
|
||||
try {
|
||||
sendJson(req, res, 200, { ok: true, roles: await service.roles(player, refresh) });
|
||||
} catch (e) {
|
||||
sendJson(req, res, 200, { ok: false, error: e.message });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (req.method !== 'POST') { fail(req, res, 405, '不支持的请求方法 · Method not allowed'); return true; }
|
||||
|
||||
let body;
|
||||
try { body = await readJsonBody(req); } catch {
|
||||
fail(req, res, 400, '请求体无效 · Bad request'); return true;
|
||||
}
|
||||
const player = ownedPlayer || String(body.player || '');
|
||||
if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; }
|
||||
|
||||
if (path === '/skland/scan/start') {
|
||||
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
|
||||
try {
|
||||
const qr = await service.startScan(player);
|
||||
sendJson(req, res, 200, { ok: true, ...qr });
|
||||
} catch (e) { sendJson(req, res, 200, { ok: false, error: e.message }); }
|
||||
return true;
|
||||
}
|
||||
if (path === '/skland/scan/poll') {
|
||||
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
|
||||
try {
|
||||
sendJson(req, res, 200, { ok: true, ...(await service.pollScan(player)) });
|
||||
} catch (e) {
|
||||
// A poll that fails still names a terminal state: a caller stops on anything but 'waiting', and a bare
|
||||
// { ok: false } would leave it reading `state === undefined` (the panel showed "未知状态" for that).
|
||||
sendJson(req, res, 200, { ok: false, state: 'error', error: e.message });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === '/skland/phone/send') {
|
||||
if (!player || !body.phone) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
|
||||
try {
|
||||
sendJson(req, res, 200, await service.sendPhoneCode(player, String(body.phone), body.captcha));
|
||||
} catch { fail(req, res, 500, '服务器内部错误 · Internal error'); }
|
||||
return true;
|
||||
}
|
||||
if (path === '/skland/phone/login') {
|
||||
if (!player || !body.phone || !body.code) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
|
||||
sendJson(req, res, 200, await service.loginByPhoneCode(player, String(body.phone), String(body.code)));
|
||||
return true;
|
||||
}
|
||||
if (path === '/skland/password/login') {
|
||||
if (!player || !body.phone || !body.password) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; }
|
||||
sendJson(req, res, 200, await service.loginByPhonePassword(player, String(body.phone), String(body.password),
|
||||
body.captcha, typeof body.captchaStep === 'string' ? body.captchaStep : undefined));
|
||||
return true;
|
||||
}
|
||||
if (path === '/skland/unbind') {
|
||||
if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; }
|
||||
sendJson(req, res, 200, service.unbind(player));
|
||||
return true;
|
||||
}
|
||||
fail(req, res, 404, '未知的 /skland 接口 · Unknown endpoint');
|
||||
} catch {
|
||||
fail(req, res, 500, '服务器内部错误 · Internal error');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler mounted when the module is OFF. Without it the path would fall through to the static mount (a 404 for
|
||||
* GET) and to routes.js's GET/HEAD gate (a **405 HTML** for POST) — an inconsistent surface for one API. This one
|
||||
* answers every /skland/* request the same way, JSON like the enabled surface, so a client can always parse the
|
||||
* "not enabled" answer.
|
||||
* @returns {(req, res, parts: { rawPath: string }) => Promise<boolean>}
|
||||
*/
|
||||
export function createSklandOffHandler() {
|
||||
return async (req, res, parts) => {
|
||||
if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false;
|
||||
sendJson(req, res, 404, { ok: false, error: '未启用森空岛绑定 · the Skland module is off (set SP_SKLAND=1)' });
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Account recovery describes only the selected role's live server-side seat. It never reads browser caches.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function createAccountRecovery({ registry, lobby }) {
|
||||
const roomIds = new WeakMap();
|
||||
return {
|
||||
describe(playerId) {
|
||||
const session = playerId && registry.byId(playerId);
|
||||
if (!session || registry.isExpired(session, registry.now())) return null;
|
||||
const room = lobby.roomOf(session);
|
||||
if (!room || room.disposed) return null;
|
||||
if (!roomIds.has(room)) roomIds.set(room, randomUUID());
|
||||
return {
|
||||
// A selector, not a credential: a replaced room or a later match must not satisfy a stale recovery request.
|
||||
id: roomIds.get(room) + ':' + room.matchCount,
|
||||
name: session.name, code: room.code, mode: room.mode, difficulty: room.difficulty,
|
||||
inMatch: !!room.match, round: room.match?.round ?? 0,
|
||||
connected: session.connected, spectating: !!room.spectatorOf(playerId),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// server/skland/service.js — per-player Arknights account bindings on top of the client (client.js) and the
|
||||
// (i18n-ignore-file: the Chinese strings are error/diagnostic text (an upstream failure's message, a login that did
|
||||
// not finish) returned as `{ ok: false, error }` — docs/I18N.md's "Error messages are not player text"; the panel
|
||||
// supplies the wording a player reads)
|
||||
// store (store.js). One instance per server. The scan sessions live in memory (a QR code is transient); everything
|
||||
// durable lives in the store.
|
||||
//
|
||||
// HTTP uses transport-scoped pending rows, then JWT-owned account rows after provider confirmation.
|
||||
// Diagnostic ids require an administrator key and cannot access either reserved namespace (docs/skland.md §5).
|
||||
|
||||
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from './client.js';
|
||||
import { makeDeviceId } from './crypto.js';
|
||||
|
||||
/** Extracts a readable profile name from the binding answer (an arknights channel first, then any). */
|
||||
function nicknameFromBinding(binding) {
|
||||
const lists = binding?.list || [];
|
||||
const ordered = [...lists.filter((l) => l?.appCode === 'arknights'), ...lists.filter((l) => l?.appCode !== 'arknights')];
|
||||
for (const list of ordered) {
|
||||
for (const b of list?.bindingList || []) {
|
||||
if (b.nickName) return b.nickName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The arknights characters of the binding answer, as a stable shape (every field always present, so JSON keeps it).
|
||||
* `uid` is the in-game account id, `channelName` the channel, `isDefault` its flag, `gameId` the game's own id. */
|
||||
function arknightsRoles(binding) {
|
||||
const roles = [];
|
||||
for (const list of binding?.list || []) {
|
||||
if (list?.appCode !== 'arknights') continue;
|
||||
for (const b of list?.bindingList || []) {
|
||||
roles.push({
|
||||
uid: b.uid == null ? null : String(b.uid),
|
||||
nickName: b.nickName ?? null,
|
||||
channelName: b.channelName ?? null,
|
||||
isDefault: b.is_default === true || b.isDefault === true,
|
||||
gameId: b.gameId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* store: ReturnType<typeof import('./store.js').createSklandStore>,
|
||||
* client?: ReturnType<typeof import('./client.js').createSklandClient>,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* scanTimeoutMs?: number,
|
||||
* }} p
|
||||
*/
|
||||
export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) {
|
||||
const skland = client || createSklandClient({ fetchImpl });
|
||||
/** @type {Map<string, { scanId: string, scanUrl: string, createdAt: number, attempt: object, polling: boolean }>} in-memory scan sessions by player */
|
||||
const scans = new Map();
|
||||
// Resume the same grant after its captcha; never cache passwords or repeat a successful password exchange.
|
||||
const pendingPasswordGrants = new Map();
|
||||
const attempts = new Map();
|
||||
function begin(playerId) {
|
||||
const attempt = {};
|
||||
attempts.set(playerId, attempt); scans.delete(playerId);
|
||||
return attempt;
|
||||
}
|
||||
const current = (playerId, attempt) => attempts.get(playerId) === attempt;
|
||||
function checkCurrent(playerId, attempt) {
|
||||
if (!current(playerId, attempt)) {
|
||||
const error = new Error('登录已取消,请重试');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function retire(playerId, attempt) { if (current(playerId, attempt)) attempts.delete(playerId); }
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
/** The player's row, created when missing. */
|
||||
function row(playerId) {
|
||||
store.ensure(playerId);
|
||||
return store.get(playerId);
|
||||
}
|
||||
|
||||
/** GET /skland/status body. */
|
||||
function status(playerId) {
|
||||
const r = row(playerId);
|
||||
const roles = r.binding ? arknightsRoles(r.binding) : [];
|
||||
return {
|
||||
playerId,
|
||||
status: r.status,
|
||||
bound: r.status === 'bound',
|
||||
nickname: r.nickname,
|
||||
hgId: r.hg_id,
|
||||
roles,
|
||||
error: r.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts (or restarts) a QR login session for the player. The QR code encodes `scanUrl`. */
|
||||
async function startScan(playerId) {
|
||||
const attempt = begin(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
try {
|
||||
const { scanId, scanUrl } = await skland.genScan();
|
||||
checkCurrent(playerId, attempt);
|
||||
scans.set(playerId, { scanId, scanUrl, createdAt: now(), attempt, polling: false });
|
||||
store.update(playerId, { status: 'scanning', error: null });
|
||||
return { scanId, scanUrl, expiresInMs: scanTimeoutMs };
|
||||
} catch (error) { retire(playerId, attempt); throw error; }
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll of the player's scan session. When the phone confirmed, the rest of the chain runs and the player is
|
||||
* bound — the same call that reports `confirmed` also completes the login.
|
||||
* @returns {Promise<{ state: 'none' | 'waiting' | 'confirmed' | 'expired', scanUrl?: string, error?: string,
|
||||
* awaitingConfirmation?: boolean, status?: string, nickname?: string | null }>}
|
||||
*/
|
||||
async function pollScan(playerId) {
|
||||
const session = scans.get(playerId);
|
||||
if (!session) return { state: 'none' };
|
||||
if (now() - session.createdAt > scanTimeoutMs) {
|
||||
scans.delete(playerId);
|
||||
retire(playerId, session.attempt);
|
||||
store.update(playerId, { status: 'unbound', error: '扫码超时' });
|
||||
return { state: 'expired', error: '扫码超时,请重新获取二维码' };
|
||||
}
|
||||
if (session.polling) return { state: 'waiting', scanUrl: session.scanUrl };
|
||||
session.polling = true;
|
||||
let confirmed = false;
|
||||
try {
|
||||
const { state, scanCode, awaitingConfirmation } = await skland.checkScan(session.scanId);
|
||||
checkCurrent(playerId, session.attempt);
|
||||
if (state === 'waiting') return { state, scanUrl: session.scanUrl, awaitingConfirmation: !!awaitingConfirmation };
|
||||
confirmed = true;
|
||||
const auth = await skland.tokenByScanCode(scanCode);
|
||||
await finishLogin(playerId, auth, { attempt: session.attempt });
|
||||
const r = row(playerId);
|
||||
scans.delete(playerId); retire(playerId, session.attempt);
|
||||
return { state: 'confirmed', status: r.status, nickname: r.nickname };
|
||||
} catch (e) {
|
||||
if (!current(playerId, session.attempt)) return { state: 'none' };
|
||||
scans.delete(playerId); retire(playerId, session.attempt);
|
||||
store.update(playerId, { status: 'error', error: e.message });
|
||||
if (!confirmed) throw e;
|
||||
return { state: 'confirmed', error: e.message };
|
||||
} finally { session.polling = false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail of every login path: grant → cred → binding, persisted per player.
|
||||
* @param {string} playerId
|
||||
* @param {{ token: string, hgId?: string | null, deviceToken?: string | null }} auth
|
||||
* @param {{ dId?: string, attempt?: object }} [o]
|
||||
*/
|
||||
async function finishLogin(playerId, auth, o = {}) {
|
||||
const existing = row(playerId);
|
||||
const dId = existing.d_id || o.dId || makeDeviceId();
|
||||
const grantCode = await skland.getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await skland.getCred(grantCode);
|
||||
const { binding, cred: cred2, credToken: credToken2 } = await skland.getBinding({
|
||||
cred, credToken, dId, accessToken: auth.token, deviceToken: auth.deviceToken,
|
||||
});
|
||||
if (o.attempt) checkCurrent(playerId, o.attempt);
|
||||
store.update(playerId, {
|
||||
access_token: auth.token, device_token: auth.deviceToken || existing.device_token || null,
|
||||
hg_id: auth.hgId || existing.hg_id || null,
|
||||
cred: cred2, cred_token: credToken2, d_id: dId, binding, binding_source: 'skland',
|
||||
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
|
||||
});
|
||||
return row(playerId);
|
||||
}
|
||||
|
||||
/** 发送短信验证码. `captcha` = the solved geetest fields when the first attempt asked for one. */
|
||||
async function sendPhoneCode(playerId, phone, captcha) {
|
||||
try {
|
||||
await skland.sendPhoneCode(phone, captcha);
|
||||
store.update(playerId, { error: null });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
if (e instanceof SklandCaptchaError) return { ok: false, needCaptcha: true, captcha: e.captcha };
|
||||
if (e instanceof SklandError) return { ok: false, error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 验证码登录: phone + code → bind. Uses the step methods so `finishLogin` is the only grant→cred→binding place. */
|
||||
async function loginByPhoneCode(playerId, phone, code) {
|
||||
const attempt = begin(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
try {
|
||||
const auth = await skland.tokenByPhoneCode(phone, code);
|
||||
const r = await finishLogin(playerId, auth, { attempt });
|
||||
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
|
||||
const message = e instanceof SklandError ? e.message : '登录失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
return { ok: false, error: message };
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login queries the official HG account binding service directly, without acquiring a Skland cred.
|
||||
* A previous QR/SMS login and its deviceToken are not needed for this flow.
|
||||
*/
|
||||
async function loginByPhonePassword(playerId, phone, password, captcha, captchaStep = 'password') {
|
||||
const attempt = begin(playerId);
|
||||
let auth;
|
||||
try {
|
||||
if (captchaStep === 'accountGrant' && captcha) {
|
||||
const pending = pendingPasswordGrants.get(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
if (!pending || pending.phone !== phone || now() - pending.createdAt > 120_000) {
|
||||
throw new SklandError('验证已过期,请重新点击登录', 'captcha_expired', 'accountGrant');
|
||||
}
|
||||
auth = { token: pending.token };
|
||||
} else {
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
auth = await skland.tokenByPhonePassword(phone, password, captcha);
|
||||
}
|
||||
const { token } = auth;
|
||||
const binding = await skland.getAccountBinding(token, captchaStep === 'accountGrant' ? captcha : undefined);
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, {
|
||||
access_token: token, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, binding, binding_source: 'hypergryph',
|
||||
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
|
||||
});
|
||||
const r = row(playerId);
|
||||
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
|
||||
if (e instanceof SklandCaptchaError) {
|
||||
if (e.step === 'accountGrant' && auth) pendingPasswordGrants.set(playerId, { token: auth.token, phone, createdAt: now() });
|
||||
return { ok: false, needCaptcha: true, captcha: e.captcha, captchaStep: e.step };
|
||||
}
|
||||
if (e instanceof SklandNeedDeviceError) {
|
||||
store.update(playerId, { status: 'error', error: e.message });
|
||||
return { ok: false, error: e.message, needDevice: true };
|
||||
}
|
||||
const message = e instanceof SklandError ? e.message : '登录失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
return { ok: false, error: message };
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
/** 解绑: forget every credential, including the deviceToken; later logins are checked by the upstream again. */
|
||||
function unbind(playerId) {
|
||||
store.unbind(playerId);
|
||||
scans.delete(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
attempts.delete(playerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** The cached arknights roles (a fresh /player/binding round-trip when `refresh`). */
|
||||
async function roles(playerId, refresh = false) {
|
||||
const r = row(playerId);
|
||||
if (!refresh || r.status !== 'bound') return arknightsRoles(r.binding);
|
||||
const attempt = begin(playerId);
|
||||
try {
|
||||
if (r.binding_source === 'hypergryph') {
|
||||
const binding = await skland.getAccountBinding(r.access_token);
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, { binding, nickname: nicknameFromBinding(binding), error: null });
|
||||
return arknightsRoles(binding);
|
||||
}
|
||||
const { binding, cred, credToken } = await skland.getBinding({
|
||||
cred: r.cred, credToken: r.cred_token, dId: r.d_id, accessToken: r.access_token, deviceToken: r.device_token,
|
||||
});
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, { binding, cred, credToken, nickname: nicknameFromBinding(binding) });
|
||||
return arknightsRoles(binding);
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) throw e;
|
||||
const message = e instanceof SklandError ? e.message : '查询失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
throw e;
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
return {
|
||||
status, startScan, pollScan, sendPhoneCode, loginByPhoneCode, loginByPhonePassword, unbind, roles,
|
||||
/** test hooks */
|
||||
_store: store, _client: skland, _scans: scans,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// The title integration reuses the game's existing reconnect token. No new WebSocket messages or session fields.
|
||||
export const SESSION_BINDING_PREFIX = '@session/';
|
||||
|
||||
export function createSessionPlayerResolver(registry) {
|
||||
return (req) => {
|
||||
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
|
||||
const session = match ? registry.byToken(match[1]) : null;
|
||||
return session ? SESSION_BINDING_PREFIX + session.playerId : null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is
|
||||
// server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in
|
||||
// WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file.
|
||||
//
|
||||
// One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state:
|
||||
// access_token the HG account token (refreshes official bindings or rebuilds a Skland cred)
|
||||
// cred the long-term API credential
|
||||
// cred_token the HMAC signing key (never leaves the server)
|
||||
// user_id the Skland internal user id
|
||||
// d_id the device fingerprint the credentials are pinned to
|
||||
// nickname / binding what the account reports (cache; refreshed on demand)
|
||||
// binding_source selects official HG account lookup or the legacy Skland refresh path
|
||||
//
|
||||
// The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their
|
||||
// own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js.
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS skland_players (
|
||||
player_id TEXT PRIMARY KEY,
|
||||
access_token TEXT,
|
||||
device_token TEXT,
|
||||
hg_id TEXT,
|
||||
cred TEXT,
|
||||
cred_token TEXT,
|
||||
user_id TEXT,
|
||||
d_id TEXT,
|
||||
nickname TEXT,
|
||||
binding TEXT, -- the raw binding list from the selected provider (cache)
|
||||
binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph
|
||||
status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still
|
||||
* boots (the credentials then do not survive a restart — logged once).
|
||||
* @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for
|
||||
* tests). The parent directory is created when missing.
|
||||
*/
|
||||
export function createSklandStore(opts = {}) {
|
||||
const file = opts.file || join(process.cwd(), 'data', 'skland.db');
|
||||
const log = opts.log || { warn: () => {} };
|
||||
if (typeof DatabaseSync !== 'function') {
|
||||
log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)');
|
||||
return createMemoryStore(file);
|
||||
}
|
||||
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA synchronous = NORMAL;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec(SCHEMA);
|
||||
// Preserve existing QR/SMS bindings when opening a database created before official account lookup was added.
|
||||
if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) {
|
||||
db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'");
|
||||
}
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
/** @returns {Record<string, any> | null} the row as an object, or null */
|
||||
function rowToObject(row) {
|
||||
if (!row) return null;
|
||||
return { ...row, binding: row.binding ? JSON.parse(row.binding) : null };
|
||||
}
|
||||
|
||||
return {
|
||||
/** The database file (or ':memory:'). */
|
||||
file,
|
||||
/** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */
|
||||
journalMode() {
|
||||
try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; }
|
||||
},
|
||||
close() { db.close(); },
|
||||
|
||||
/** Ensure a row exists for the player (idempotent). */
|
||||
ensure(playerId) {
|
||||
db.prepare(
|
||||
'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(player_id) DO NOTHING',
|
||||
).run(playerId, 'unbound', now(), now());
|
||||
},
|
||||
|
||||
get(playerId) {
|
||||
const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId);
|
||||
return rowToObject(row);
|
||||
},
|
||||
|
||||
/** All rows (admin/debug views). */
|
||||
list() {
|
||||
return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject);
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON.
|
||||
* @param {string} playerId
|
||||
* @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string,
|
||||
* user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string,
|
||||
* error: string | null}>} fields
|
||||
*/
|
||||
update(playerId, fields) {
|
||||
this.ensure(playerId);
|
||||
const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error'];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); }
|
||||
}
|
||||
if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); }
|
||||
if (!sets.length) return;
|
||||
sets.push('updated_at = ?');
|
||||
values.push(now(), playerId);
|
||||
db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values);
|
||||
},
|
||||
|
||||
/** Remove a player's credentials (解绑). The row stays, marked unbound. */
|
||||
unbind(playerId) {
|
||||
this.update(playerId, {
|
||||
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/** Delete a player's row entirely (e.g. session cleanup). */
|
||||
remove(playerId) {
|
||||
db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId);
|
||||
},
|
||||
|
||||
/** For tests: raw row count. */
|
||||
count() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */
|
||||
function createMemoryStore(file) {
|
||||
/** @type {Map<string, Record<string, any>>} */
|
||||
const rows = new Map();
|
||||
const now = () => Date.now();
|
||||
const blank = () => ({
|
||||
player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
created_at: 0, updated_at: 0,
|
||||
});
|
||||
return {
|
||||
file,
|
||||
journalMode() { return 'memory'; },
|
||||
close() { rows.clear(); },
|
||||
ensure(playerId) {
|
||||
if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() });
|
||||
},
|
||||
get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; },
|
||||
list() { return [...rows.values()].map((r) => ({ ...r })); },
|
||||
update(playerId, fields) {
|
||||
this.ensure(playerId);
|
||||
const row = rows.get(playerId);
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null);
|
||||
}
|
||||
row.updated_at = now();
|
||||
},
|
||||
unbind(playerId) {
|
||||
this.update(playerId, {
|
||||
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
});
|
||||
},
|
||||
remove(playerId) { rows.delete(playerId); },
|
||||
count() { return rows.size; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// A role's UID is unique within its provider-reported server region. Display names are never identity keys.
|
||||
// gameId is optional provider metadata (the official password API may omit it), so it cannot be part of identity.
|
||||
export const roleKey = (role) => JSON.stringify([String(role.channelName || '').trim(), String(role.uid).trim()]);
|
||||
export const hasRoleIdentity = (role) => role?.uid != null && String(role.uid).trim() !== ''
|
||||
&& String(role.channelName || '').trim() !== '';
|
||||
@@ -141,3 +141,24 @@ test('live token ownership survives a duplicated tab, reload and reconnect', { t
|
||||
assert.equal(welcome.playerId, a.welcome.playerId);
|
||||
await assertOnline(h, [a2, b]);
|
||||
});
|
||||
|
||||
|
||||
test('explicit account tokens are tentative until welcome and do not overwrite the last established local identity', async () => {
|
||||
const old = '1'.repeat(32), selected = '2'.repeat(32), attached = '3'.repeat(32);
|
||||
const local = storage(), session = storage({ 'sp.token': old });
|
||||
const id = createIdentity({ local, session, channel: null });
|
||||
await id.init();
|
||||
id.saveToken(old);
|
||||
assert.equal(id.stageToken(selected), true);
|
||||
assert.equal(id.getToken(), selected);
|
||||
assert.deepEqual(id.getTokenClaim(), { noReplace: false });
|
||||
assert.equal(session.getItem('sp.token'), old);
|
||||
assert.deepEqual(JSON.parse(local.getItem('sp.tokens')), [old]);
|
||||
assert.equal(id.stageToken(''), false);
|
||||
assert.equal(id.getToken(), selected);
|
||||
id.saveToken(attached);
|
||||
assert.equal(session.getItem('sp.token'), attached);
|
||||
assert.equal(id.getToken(), attached);
|
||||
assert.ok(Number.isFinite(id.getTokenClaim().claimAt));
|
||||
assert.equal(JSON.parse(local.getItem('sp.tokens')).includes(selected), false, 'only the rotated welcome token is remembered');
|
||||
});
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
// .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked
|
||||
// files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache
|
||||
// .npm-cache`) must never be committed by any contributor.
|
||||
// .npm-cache`) must never be committed by any contributor. The opt-in Skland credential database (docs/skland.md) is
|
||||
// the other per-machine secret: `data/skland.db` and the Docker volume path `skland-data/`.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -29,3 +30,15 @@ test('GitHub #258: the local npm cache (.npm-cache/) is ignored, like node_modul
|
||||
}
|
||||
assert.notEqual(ignored('package.json'), true, 'a tracked file is not');
|
||||
});
|
||||
|
||||
test('the Skland credential database (docs/skland.md): the file, its WAL side files and the Docker volume path', () => {
|
||||
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
|
||||
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
|
||||
}
|
||||
// the store's default path, the two side files node:sqlite/WAL writes next to it, and the bind-mount form a
|
||||
// deployment may use instead of docker-compose.yml's named volume (docker.test.js pins the volume itself)
|
||||
for (const rel of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/skland.db', 'skland-data/skland.db-wal']) {
|
||||
const v = ignored(rel);
|
||||
if (v !== null) assert.equal(v, true, `${rel} is ignored`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBindingApi, ensureBindingSession, preferredRole, roleKey } from '../../public/js/skland/api.js';
|
||||
|
||||
test('the title client puts its game token only in an authorization header and never sends a player id', async () => {
|
||||
const calls = [];
|
||||
const request = createBindingApi({ token: 'private-game-token', fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) };
|
||||
} });
|
||||
await request('status'); await request('password/login', { phone: '13800000000', password: 'local-input' });
|
||||
assert.equal(calls[0].url, '/skland/session/status');
|
||||
assert.equal(calls[0].init.headers.authorization, 'Bearer private-game-token');
|
||||
assert.equal(calls[0].init.method, 'GET');
|
||||
assert.equal(calls[1].init.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[1].init.body), { phone: '13800000000', password: 'local-input' });
|
||||
assert.ok(calls.every(({ url, init }) => !url.includes('private-game-token') && !init.body?.includes('private-game-token')));
|
||||
});
|
||||
|
||||
test('the title client distinguishes disabled module, expired session, network and malformed responses', async () => {
|
||||
for (const [status, code] of [[404, 'off'], [401, 'session']]) {
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status }) })('status'), { code });
|
||||
}
|
||||
await assert.rejects(createBindingApi()('status'), { code: 'session' });
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => { throw new Error('offline'); } })('status'), { code: 'network' });
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 200, ok: true, json: async () => { throw new Error(); } }) })('status'), { code: 'response' });
|
||||
});
|
||||
|
||||
test('binding starts hello without entering the game, reuses an online identity, and cleans up on failure', async () => {
|
||||
const listeners = new Map();
|
||||
let current = null, connects = 0, sentName;
|
||||
const identity = { init: async () => {}, getToken: () => current };
|
||||
const net = {
|
||||
status: 'connected',
|
||||
on: (event, fn) => { listeners.set(event, fn); return () => listeners.delete(event); },
|
||||
setName: (name) => { sentName = name; },
|
||||
connect: () => { connects++; setTimeout(() => { current = 'T'; listeners.get('welcome')?.({ token: 'T' }); }, 0); },
|
||||
};
|
||||
assert.equal(await ensureBindingSession({ net, identity, name: '博士代号' }), 'T');
|
||||
assert.equal(sentName, '博士代号'); assert.equal(listeners.size, 0);
|
||||
net.status = 'online';
|
||||
assert.equal(await ensureBindingSession({ net, identity, name: '另一个名字' }), 'T');
|
||||
assert.equal(connects, 1);
|
||||
net.status = 'connected'; net.connect = () => {};
|
||||
await assert.rejects(ensureBindingSession({ net, identity, name: '博士', timeoutMs: 1 }), { code: 'network' });
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('nickname selection uses the default role, preserves a selected channel, and supports no roles', () => {
|
||||
const a = { uid: '123', channelName: '官服', gameId: 1, nickName: '官服博士' };
|
||||
const b = { uid: '123', channelName: 'B服', gameId: 2, nickName: 'B服博士', isDefault: true };
|
||||
assert.equal(preferredRole([a, b]), b);
|
||||
assert.equal(preferredRole([a, b], roleKey(a)), a);
|
||||
assert.equal(preferredRole([a, b], 'removed-role'), b);
|
||||
assert.equal(preferredRole([a]), a);
|
||||
assert.equal(preferredRole([]), null);
|
||||
assert.equal(preferredRole([{ uid: 'missing-name', isDefault: true }, { ...b, nickName: ' ' }, a]), a);
|
||||
assert.equal(preferredRole([{ uid: 'missing-name' }]), null);
|
||||
assert.equal(preferredRole([{ uid: null, channelName: '官服', nickName: '缺少身份' }]), null);
|
||||
assert.equal(roleKey(a), roleKey({ ...a, uid: 123, gameId: null, nickName: '改名以后' }), 'nickname and optional gameId never change identity');
|
||||
});
|
||||
|
||||
|
||||
test('an expired recovery is distinguishable from a transport error and never becomes a successful entry', async () => {
|
||||
const request = createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 409, ok: false,
|
||||
json: async () => ({ ok: false, code: 'resume_expired', error: 'expired' }),
|
||||
}) });
|
||||
await assert.rejects(request('resume', { id: 'stale' }), { code: 'resume_expired' });
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Provider contract tests use a fake widget and fake proof only; no real captcha or network request is performed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import { solveCaptcha } from '../../public/js/skland/captcha.js';
|
||||
|
||||
function fakeProvider(proof) {
|
||||
const events = {};
|
||||
const calls = { shown: 0, destroyed: 0, options: null };
|
||||
const widget = {
|
||||
onReady(fn) { events.ready = fn; }, onSuccess(fn) { events.success = fn; },
|
||||
onClose(fn) { events.close = fn; }, onError(fn) { events.error = fn; },
|
||||
getValidate() { return proof; },
|
||||
showCaptcha() { calls.shown++; }, verify() { calls.shown++; },
|
||||
destroy() { calls.destroyed++; events.close?.(); },
|
||||
};
|
||||
const init = (options, ready) => { calls.options = options; ready(widget); };
|
||||
return { events, calls, scope: { initGeetest4: init, initGeetest: init }, load: async () => {} };
|
||||
}
|
||||
|
||||
test('captcha v4 opens the provider widget and preserves its proof plus the upstream challenge', async () => {
|
||||
const proof = { captcha_id: 'TEST', lot_number: 'TEST-LOT', captcha_output: 'TEST-PROOF', pass_token: 'TEST-PASS', gen_time: '1' };
|
||||
const provider = fakeProvider(proof);
|
||||
const pending = solveCaptcha({ geetestId: 'TEST', challenge: 'TEST-CHALLENGE', riskType: 'slide' }, provider);
|
||||
await setImmediate();
|
||||
assert.equal(provider.calls.shown, 0, 'initialization does not submit a proof');
|
||||
provider.events.ready();
|
||||
assert.equal(provider.calls.shown, 1);
|
||||
assert.deepEqual(provider.calls.options, { captchaId: 'TEST', riskType: 'slide', product: 'bind', language: 'zho' });
|
||||
provider.events.success();
|
||||
assert.deepEqual(await pending, { ...proof, challenge: 'TEST-CHALLENGE' });
|
||||
assert.equal(provider.calls.destroyed, 1);
|
||||
});
|
||||
|
||||
test('captcha v3 preserves the three provider fields for the SMS retry', async () => {
|
||||
const proof = { geetest_challenge: 'TEST-CH', geetest_validate: 'TEST-VALIDATE', geetest_seccode: 'TEST-SEC' };
|
||||
const provider = fakeProvider(proof);
|
||||
const pending = solveCaptcha({ gt: 'TEST', challenge: 'TEST-CH', new_captcha: true }, provider);
|
||||
await setImmediate();
|
||||
provider.events.ready();
|
||||
provider.events.success();
|
||||
assert.deepEqual(await pending, proof);
|
||||
assert.equal(provider.calls.options.https, true);
|
||||
});
|
||||
|
||||
test('captcha cancellation never yields a proof, including after a provider retry event', async () => {
|
||||
const provider = fakeProvider({ captcha_output: 'UNUSED-TEST-PROOF' });
|
||||
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
|
||||
await setImmediate();
|
||||
provider.events.error({ error_code: 'error_01' });
|
||||
provider.events.close();
|
||||
provider.events.success();
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(provider.calls.destroyed, 1);
|
||||
});
|
||||
|
||||
test('unsupported or failed captchas are rejected so the UI can offer retry', async () => {
|
||||
await assert.rejects(() => solveCaptcha({}), /captcha_type/);
|
||||
await assert.rejects(() => solveCaptcha({ geetestId: 'TEST' }, { load: async () => { throw new Error('captcha_load'); } }), /captcha_load/);
|
||||
const provider = fakeProvider(null);
|
||||
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
|
||||
const rejected = assert.rejects(pending, /captcha_validate/);
|
||||
await setImmediate();
|
||||
provider.events.success();
|
||||
await rejected;
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
// The Skland login chains (server/skland/client.js): 扫码 / 验证码 / 密码, the 人机验证 hand-off, and the two
|
||||
// credential-refresh paths.
|
||||
//
|
||||
// Every case runs against a stub upstream whose answers are the shapes the community documentation records (the
|
||||
// 3mua.cn write-up and TNXG/skland-api), so the chain's *order* and the *fields on the wire* are pinned without
|
||||
// touching the network. The stub records each call, and the assertions read that record — a chain that skipped a step
|
||||
// or sent the wrong body fails here rather than in production.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from '../../server/skland/client.js';
|
||||
|
||||
/**
|
||||
* A stub upstream. `overrides` replaces individual answers by endpoint key.
|
||||
* Keys: genScan, scanStatus, scanToken, sendCode, phoneToken, passwordToken, grant, cred, binding, refresh.
|
||||
*/
|
||||
function stubUpstream(overrides = {}) {
|
||||
const calls = [];
|
||||
const j = (o) => ({ status: 200, json: async () => o });
|
||||
const answers = {
|
||||
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
|
||||
scanStatus: () => j({ status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } }),
|
||||
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
sendCode: () => j({ status: 0, msg: 'OK' }),
|
||||
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
|
||||
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
|
||||
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
|
||||
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
|
||||
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] } }),
|
||||
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', isDefault: true }] }] } }),
|
||||
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
|
||||
};
|
||||
const routes = [
|
||||
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
|
||||
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
|
||||
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
|
||||
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
|
||||
['account/binding/v1/binding_list', 'accountBinding'],
|
||||
];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
const key = routes.find(([frag]) => url.includes(frag))?.[1];
|
||||
const answer = overrides[key] ?? answers[key];
|
||||
calls.push({
|
||||
key, url, method: init.method || 'GET',
|
||||
body: init.body ? JSON.parse(init.body) : null,
|
||||
headers: init.headers || {},
|
||||
});
|
||||
if (!answer) throw new Error(`stub: no route for ${url}`);
|
||||
return answer(calls.at(-1));
|
||||
};
|
||||
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
|
||||
}
|
||||
|
||||
test('扫码登录: gen_scan → scan_status → token_by_scan_code → grant → cred', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
const r = await client.loginByScan();
|
||||
|
||||
assert.equal(r.scanId, 'SCAN1');
|
||||
assert.equal(r.scanUrl, 'hypergryph://scan_login?scanId=SCAN1', 'the QR encodes scanUrl, not the scanId');
|
||||
assert.equal(r.token, 'T-SCAN');
|
||||
assert.equal(r.deviceToken, 'DEV1');
|
||||
assert.equal(r.cred, 'CRED');
|
||||
assert.equal(r.credToken, 'CREDTOKEN');
|
||||
|
||||
assert.deepEqual(up.calls.map((c) => c.key), [
|
||||
'genScan', 'scanStatus', 'scanToken', 'grant', 'cred',
|
||||
]);
|
||||
assert.deepEqual(up.byKey('genScan')[0].body, { appCode: '4ca99fa6b56cc2ba' });
|
||||
assert.deepEqual(up.byKey('scanToken')[0].body, { appCode: '4ca99fa6b56cc2ba', from: 0, scanCode: 'SCANCODE1' });
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1', 'the grant carries the scan deviceToken');
|
||||
});
|
||||
|
||||
test('扫码登录: the QR session carries the device headers the upstream requires', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
await client.loginByScan();
|
||||
const headers = up.byKey('scanToken')[0].headers;
|
||||
assert.match(headers['X-DeviceId'], /^[0-9a-f]{10}$/);
|
||||
assert.equal(headers['X-DeviceType'], '2');
|
||||
assert.ok(headers['X-DeviceModel']);
|
||||
});
|
||||
|
||||
test('扫码登录: 未扫码 (100) and 已扫码待确认 (101) keep polling until confirmed', async () => {
|
||||
const pending = [
|
||||
{ status: 100, msg: '未扫码' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
];
|
||||
const up = stubUpstream({
|
||||
scanStatus: () => ({ status: 200, json: async () => pending.shift()
|
||||
?? { status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } } }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
const onWait = [];
|
||||
const r = await client.loginByScan({ onWait: (s) => onWait.push(s) });
|
||||
assert.equal(r.token, 'T-SCAN');
|
||||
assert.equal(up.byKey('scanStatus').length, 4, 'polled through phone confirmation');
|
||||
assert.deepEqual(onWait, ['waiting', 'waiting', 'waiting']);
|
||||
assert.equal(up.byKey('scanToken').length, 1, 'the confirmed code is exchanged once');
|
||||
});
|
||||
|
||||
test('扫码登录: terminal and unknown statuses still fail instead of polling indefinitely', async () => {
|
||||
for (const status of [102, 999]) {
|
||||
const up = stubUpstream({
|
||||
scanStatus: () => ({ status: 200, json: async () => ({ status, msg: '扫码已失效' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.loginByScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.code, status);
|
||||
assert.equal(e.step, 'checkScan');
|
||||
return true;
|
||||
});
|
||||
assert.equal(up.byKey('scanStatus').length, 1);
|
||||
assert.equal(up.byKey('scanToken').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('扫码登录: a timeout stops the poll with a readable error', async () => {
|
||||
const up = stubUpstream({ scanStatus: () => ({ status: 200, json: async () => ({ status: 100, msg: '未扫码' }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1, pollTimeoutMs: 0 });
|
||||
await assert.rejects(() => client.loginByScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.step, 'pollScan');
|
||||
assert.match(e.message, /超时/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码登录: send_phone_code(type 2, LOGIN_OR_REGISTER) → token_by_phone_code → grant → cred', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
assert.deepEqual(await client.sendPhoneCode('13800000000'), { ok: true });
|
||||
const r = await client.loginByPhoneCode('13800000000', '123456');
|
||||
|
||||
assert.equal(r.token, 'T-CODE');
|
||||
assert.equal(r.hgId, 'HG1');
|
||||
assert.equal(r.deviceToken, 'DEV1');
|
||||
assert.equal(r.cred, 'CRED');
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
|
||||
assert.deepEqual(up.byKey('phoneToken')[0].body, { appCode: '4ca99fa6b56cc2ba', code: '123456', phone: '13800000000' });
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
|
||||
assert.equal(up.byKey('phoneToken')[0].headers['X-DeviceType'], '2');
|
||||
});
|
||||
|
||||
test('验证码登录: 需要人机验证 surfaces the geetest challenge', async () => {
|
||||
const captcha = { gt: 'GT', challenge: 'CH', new_captcha: true };
|
||||
const up = stubUpstream({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', type: 'A', data: { captcha } }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
|
||||
assert.ok(e instanceof SklandCaptchaError);
|
||||
assert.equal(e.captcha.gt, 'GT');
|
||||
assert.equal(e.captcha.challenge, 'CH');
|
||||
assert.equal(e.captcha.new_captcha, true);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码登录: a solved captcha is echoed into the resend', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
|
||||
await client.sendPhoneCode('13800000000', solved);
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
|
||||
});
|
||||
|
||||
test('验证码登录: 发送频率太高 (status 102) is a readable error', async () => {
|
||||
const up = stubUpstream({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 102, msg: '发送频率太高,请稍后再试', type: 'C1' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.code, 102);
|
||||
assert.match(e.message, /频率太高/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('密码登录: password → account-service grant → official Arknights bindings without Skland', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.loginByPhonePassword('13800000000', 'pw');
|
||||
assert.equal(r.token, 'T-PW');
|
||||
assert.equal(r.binding.list[0].bindingList[0].nickName, '博士');
|
||||
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' });
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
const url = new URL(up.byKey('accountBinding')[0].url);
|
||||
assert.equal(url.origin, 'https://binding-api-account-prod.hypergryph.com');
|
||||
assert.equal(url.pathname, '/account/binding/v1/binding_list');
|
||||
assert.equal(url.searchParams.get('token'), 'BINDINGTOKEN');
|
||||
assert.equal(url.searchParams.get('appCode'), 'arknights');
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
assert.equal(up.byKey('binding').length, 0);
|
||||
});
|
||||
|
||||
test('密码登录: without a deviceToken the chain still attempts grant and succeeds', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.loginByPhonePassword('13800000000', 'pw');
|
||||
assert.equal(r.binding.list[0].bindingList.length, 1);
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
});
|
||||
|
||||
test('密码登录: only an upstream status 109 requires device verification', async () => {
|
||||
const up = stubUpstream({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.loginByPhonePassword('13800000000', 'pw'), (e) => {
|
||||
assert.ok(e instanceof SklandNeedDeviceError);
|
||||
assert.equal(e.code, 109);
|
||||
return true;
|
||||
});
|
||||
assert.equal(up.byKey('grant').length, 1, 'the upstream decides whether verification is needed');
|
||||
assert.equal(up.byKey('cred').length, 0, 'a rejected grant never advances to credentials');
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
|
||||
test('account bindings: a grant missing its service token never queries with an undefined token', async () => {
|
||||
const up = stubUpstream({ grant: () => ({ status: 200, json: async () => ({ status: 0, data: {} }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getAccountBinding('T'), (e) => e.code === 'no_token');
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
|
||||
test('account bindings: upstream errors and malformed lists do not become empty successful bindings', async () => {
|
||||
for (const answer of [{ status: 3, msg: '登录已过期' }, { status: 0, data: {} }]) {
|
||||
const up = stubUpstream({ accountBinding: () => ({ status: 200, json: async () => answer }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getAccountBinding('T'), SklandError);
|
||||
}
|
||||
});
|
||||
|
||||
test('grant: the deviceToken is sent only when the login path produced one', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await client.getGrantCode('T', null);
|
||||
assert.ok(!('deviceToken' in up.byKey('grant')[0].body));
|
||||
await client.getGrantCode('T', 'DEV');
|
||||
assert.equal(up.byKey('grant')[1].body.deviceToken, 'DEV');
|
||||
});
|
||||
|
||||
test('getBinding: the signed request carries cred, dId, timestamp, vName and a signature', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'CRED', credToken: 'CREDTOKEN', dId: 'DEV' });
|
||||
assert.equal(r.binding.list[0].appCode, 'arknights');
|
||||
const call = up.byKey('binding')[0];
|
||||
assert.equal(call.method, 'GET');
|
||||
assert.equal(call.headers.cred, 'CRED');
|
||||
assert.equal(call.headers.dId, 'DEV');
|
||||
assert.equal(call.headers.platform, '3');
|
||||
assert.equal(call.headers.vName, '1.0.0');
|
||||
assert.match(call.headers.sign, /^[0-9a-f]{32}$/, 'sign = md5(hmacSha256(...))');
|
||||
assert.ok(Number(call.headers.timestamp) > 0);
|
||||
});
|
||||
|
||||
test('getBinding: code 10000 refreshes the cred_token once and retries', async () => {
|
||||
let first = true;
|
||||
const up = stubUpstream({
|
||||
binding: () => {
|
||||
if (first) { first = false; return { status: 200, json: async () => ({ code: 10000, message: 'cred_token 过期' }) }; }
|
||||
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
|
||||
},
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'CRED', credToken: 'OLD', dId: 'DEV' });
|
||||
assert.equal(r.credToken, 'CREDTOKEN2', 'the refreshed cred_token is returned to the caller');
|
||||
assert.equal(up.byKey('refresh').length, 1);
|
||||
assert.equal(up.byKey('binding').length, 2, 'retried once');
|
||||
});
|
||||
|
||||
test('getBinding: code 10002 rebuilds the cred from the stored access_token', async () => {
|
||||
let first = true;
|
||||
const up = stubUpstream({
|
||||
binding: () => {
|
||||
if (first) { first = false; return { status: 200, json: async () => ({ code: 10002, message: 'cred 过期' }) }; }
|
||||
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
|
||||
},
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'OLD', credToken: 'OLD', dId: 'DEV', accessToken: 'AT', deviceToken: 'DEV1' });
|
||||
assert.equal(r.cred, 'CRED', 'the rebuilt cred is returned to the caller');
|
||||
assert.equal(r.credToken, 'CREDTOKEN');
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('cred').length, 1);
|
||||
});
|
||||
|
||||
test('getBinding: 用户未登录 reads as an expired session', async () => {
|
||||
const up = stubUpstream({
|
||||
binding: () => ({ status: 200, json: async () => ({ code: 10002, message: '用户未登录' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getBinding({ cred: 'C', credToken: 'CT', dId: 'D' }), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.match(e.message, /过期|未登录/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an upstream failure carries the step that failed', async () => {
|
||||
const up = stubUpstream({ genScan: () => ({ status: 200, json: async () => ({ status: 1, msg: '系统繁忙' }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.genScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.message, '系统繁忙');
|
||||
assert.equal(e.step, '获取扫码会话');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a non-JSON answer is reported as such instead of throwing a parse error', async () => {
|
||||
const up = stubUpstream({ genScan: () => ({ status: 502, json: async () => { throw new Error('bad json'); } }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.genScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.match(e.message, /响应不是 JSON/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// The Skland request signature (server/skland/crypto.js): the composition of the signed header set.
|
||||
//
|
||||
// The upstream's algorithm has two independent references in the community (the 3mua.cn write-up on the account login
|
||||
// surface and TNXG/skland-api's crypto module). This file pins the *composition* — which bytes go into the HMAC, and
|
||||
// in what order — because that is the part a refactor can silently break. The digest itself is checked against
|
||||
// node:crypto's own HMAC-SHA256 and MD5, so a change of either primitive shows up here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHmac, createHash } from 'node:crypto';
|
||||
import { signedHeaders, hmacSha256Hex, md5, compactJson, makeDeviceId, deviceHeaders } from '../../server/skland/crypto.js';
|
||||
|
||||
const ref = (key, msg) => createHash('md5').update(createHmac('sha256', key).update(msg, 'utf8').digest('hex'), 'utf8').digest('hex');
|
||||
|
||||
test('compactJson: no whitespace, insertion order kept (the upstream signs the exact body bytes)', () => {
|
||||
assert.equal(compactJson({ b: 1, a: 2 }), '{"b":1,"a":2}');
|
||||
assert.equal(compactJson({ gameId: 1, uid: '123' }), '{"gameId":1,"uid":"123"}');
|
||||
assert.equal(compactJson({}), '{}');
|
||||
});
|
||||
|
||||
test('hmacSha256Hex / md5 are the plain primitives', () => {
|
||||
assert.equal(hmacSha256Hex('key', 'msg'), createHmac('sha256', 'key').update('msg').digest('hex'));
|
||||
assert.equal(md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
||||
});
|
||||
|
||||
test('a GET sign input is path + query + timestamp + compact(header_ca)', () => {
|
||||
const { signInput, headers } = signedHeaders({
|
||||
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'GET', path: '/api/v1/game/player/binding',
|
||||
query: 'a=1', timestampSeconds: 1700000000,
|
||||
});
|
||||
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
|
||||
assert.equal(signInput, `/api/v1/game/player/bindinga=11700000000${ca}`);
|
||||
assert.equal(headers.sign, ref('CTOK', signInput));
|
||||
assert.equal(headers.cred, 'CRED');
|
||||
assert.equal(headers.platform, '3');
|
||||
assert.equal(headers.vName, '1.0.0');
|
||||
assert.equal(headers.dId, 'DEV');
|
||||
});
|
||||
|
||||
test('a GET without a query signs the bare path', () => {
|
||||
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 });
|
||||
assert.equal(signInput, `/p1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
|
||||
});
|
||||
|
||||
test('a POST sign input is path + compact(body) + timestamp + compact(header_ca)', () => {
|
||||
const body = { gameId: 1, uid: '123' };
|
||||
const { signInput, headers } = signedHeaders({
|
||||
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'POST', path: '/api/v1/game/attendance',
|
||||
body, timestampSeconds: 1700000000,
|
||||
});
|
||||
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
|
||||
assert.equal(signInput, `/api/v1/game/attendance{"gameId":1,"uid":"123"}1700000000${ca}`);
|
||||
assert.equal(headers.sign, ref('CTOK', signInput));
|
||||
});
|
||||
|
||||
test('a POST without a body signs "{}"', () => {
|
||||
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', timestampSeconds: 1 });
|
||||
assert.equal(signInput, `/p{}1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
|
||||
});
|
||||
|
||||
test('the signature is stable for equal inputs and moves with any single input', () => {
|
||||
const base = { cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', body: { x: 1 }, timestampSeconds: 5 };
|
||||
const a = signedHeaders(base).headers.sign;
|
||||
assert.equal(signedHeaders({ ...base }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, credToken: 'k2' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, dId: 'd2' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, timestampSeconds: 6 }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, body: { x: 2 } }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, path: '/q' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, method: 'GET', query: '' }).headers.sign, a);
|
||||
});
|
||||
|
||||
test('the mobile client identity headers the upstream expects', () => {
|
||||
const h = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }).headers;
|
||||
assert.match(h['User-Agent'], /^Skland\//);
|
||||
assert.equal(h['X-Requested-With'], 'com.hypergryph.skland');
|
||||
assert.equal(h['Accept-Encoding'], 'gzip');
|
||||
});
|
||||
|
||||
test('makeDeviceId: stable shape, a fresh value each call', () => {
|
||||
const a = makeDeviceId();
|
||||
const b = makeDeviceId();
|
||||
assert.match(a, /^B[0-9a-f]{32}$/);
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('deviceHeaders: the login device headers, cached for the process', () => {
|
||||
const h = deviceHeaders();
|
||||
assert.match(h['X-DeviceId'], /^[0-9a-f]{10}$/);
|
||||
assert.equal(h['X-DeviceType'], '2');
|
||||
assert.ok(h['X-DeviceModel'].length > 0);
|
||||
assert.equal(deviceHeaders(), h, 'one device per process (the upstream sees a stable device)');
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// The Docker packaging of the opt-in Skland account binding: docker-compose.yml, the Dockerfile's runtime stage and
|
||||
// .gitignore form one contract — the credentials live in one SQLite file on a named volume that the unprivileged
|
||||
// `node` user can write, and that file is never committed and never reachable over HTTP.
|
||||
//
|
||||
// No Docker daemon is needed (and CI has none): the files are read as text and the facts that make `docker compose up
|
||||
// -d` work are asserted. docker-compose.yml is parsed with a few lines of indentation-based reading rather than a YAML
|
||||
// dependency, so the assertions rest on the structure (which service, which key) instead of one loose regex.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
const linesOf = (text) => text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
|
||||
|
||||
/** The indentation of a line (a YAML block is defined by it). */
|
||||
const indentOf = (l) => l.length - l.replace(/^[ \t]*/, '').length;
|
||||
|
||||
/**
|
||||
* The lines of the block under `key:` at exactly `indent` — comments and blank lines dropped (they are prose, not
|
||||
* structure), a dedent ends it. Returns [] when the key is absent. Enough YAML to read this one file honestly.
|
||||
* @param {string[]} lines
|
||||
* @param {string} key
|
||||
* @param {number} indent
|
||||
*/
|
||||
function block(lines, key, indent) {
|
||||
const head = new RegExp(`^ {${indent}}${key}:`);
|
||||
const out = [];
|
||||
let inside = false;
|
||||
for (const l of lines) {
|
||||
if (!l.trim() || l.trim().startsWith('#')) continue;
|
||||
if (!inside) { if (indentOf(l) === indent && head.test(l)) inside = true; continue; }
|
||||
if (indentOf(l) <= indent) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The scalar of `key: value` inside a block, with surrounding quotes removed; null when unset. */
|
||||
function scalar(blockLines, key) {
|
||||
for (const l of blockLines) {
|
||||
const m = l.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`));
|
||||
if (m) return m[1].trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The `- item` entries of the list under `key:` inside a block (quotes removed). */
|
||||
function items(blockLines, key, indent) {
|
||||
return block(blockLines, key, indent)
|
||||
.map((l) => l.match(/^[ \t]*-[ \t]*(.*)$/)?.[1] ?? '')
|
||||
.filter(Boolean)
|
||||
.map((v) => v.trim().replace(/^(["'])(.*)\1$/, '$2'));
|
||||
}
|
||||
|
||||
const compose = linesOf(read('docker-compose.yml'));
|
||||
const services = block(compose, 'services', 0);
|
||||
const service = block(services, 'stronghold', 2);
|
||||
|
||||
test('docker-compose.yml: one stronghold service, built locally, tagged and restarted', () => {
|
||||
assert.deepEqual(services.filter((l) => indentOf(l) === 2 && /:$/.test(l.trim())).map((l) => l.trim()),
|
||||
['stronghold:'], 'exactly one service');
|
||||
const build = block(service, 'build', 4);
|
||||
assert.equal(scalar(build, 'context'), '.', 'builds the repository Dockerfile');
|
||||
assert.equal(scalar(service, 'image'), 'stronghold-protocol', 'tags the image');
|
||||
assert.equal(scalar(service, 'restart'), 'unless-stopped');
|
||||
// the art is opt-in: the compose file must not force a 550 MB download unless the operator uncomments it
|
||||
assert.equal(scalar(build, 'args'), null, 'FETCH_ASSETS stays the caller\'s choice');
|
||||
});
|
||||
|
||||
test('docker-compose.yml: publishes 3000:3000', () => {
|
||||
const ports = items(service, 'ports', 4);
|
||||
assert.ok(ports.includes('3000:3000'), `publishes 3000:3000 (got ${JSON.stringify(ports)})`);
|
||||
});
|
||||
|
||||
test('docker-compose.yml: SP_SKLAND=1 and SP_SKLAND_DB inside the mounted named volume', () => {
|
||||
const env = block(service, 'environment', 4);
|
||||
assert.equal(scalar(env, 'SP_SKLAND'), '1', 'the Skland module is on');
|
||||
const db = scalar(env, 'SP_SKLAND_DB');
|
||||
assert.ok(db, 'SP_SKLAND_DB is set');
|
||||
|
||||
// the database must sit inside a mount, and that mount must be the named volume (survives down / rebuilds)
|
||||
const mounts = items(service, 'volumes', 4);
|
||||
const named = mounts.find((m) => m.startsWith('skland-data:'));
|
||||
assert.ok(named, `the named volume skland-data is mounted (got ${JSON.stringify(mounts)})`);
|
||||
const target = named.slice(named.indexOf(':') + 1);
|
||||
assert.ok(target.startsWith('/'), `the volume has an absolute container path (${target})`);
|
||||
assert.ok(db.startsWith(`${target}/`), `SP_SKLAND_DB ${db} is inside the mounted volume ${target}`);
|
||||
// the volume is declared, so `docker compose up` creates it rather than failing on an unknown name
|
||||
assert.ok(block(compose, 'volumes', 0).some((l) => indentOf(l) === 2 && l.trim().startsWith('skland-data')),
|
||||
'the top-level volumes: section declares skland-data');
|
||||
});
|
||||
|
||||
test('docker-compose.yml: the credential file is outside every static mount (/public, /data)', () => {
|
||||
const db = scalar(block(service, 'environment', 4), 'SP_SKLAND_DB');
|
||||
for (const dir of ['/app/public', '/app/data', '/app/shared']) {
|
||||
assert.ok(!db.startsWith(`${dir}/`), `${db} is not under ${dir}: the static handler would serve it over HTTP`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Dockerfile: /app/skland-data exists and belongs to node before USER node', () => {
|
||||
const df = linesOf(read('Dockerfile'));
|
||||
const user = df.findIndex((l) => /^USER[ \t]+node[ \t]*$/.test(l));
|
||||
assert.ok(user > 0, 'the runtime stage still runs as USER node');
|
||||
const mkdir = df.findIndex((l) => /^RUN[^\n]*mkdir -p[^\n]*\/app\/skland-data\b/.test(l));
|
||||
assert.ok(mkdir >= 0, 'the database directory is created');
|
||||
assert.ok(mkdir < user, `mkdir (line ${mkdir + 1}) precedes USER node (line ${user + 1})`);
|
||||
assert.match(df[mkdir], /chown[ \t]+node:node[ \t]+\/app\/skland-data\b/, 'owned by node, so node can write it');
|
||||
// a VOLUME instruction would hand `docker run` an anonymous volume the host cannot find; the mount is explicit
|
||||
assert.ok(!df.some((l) => /^VOLUME\b/.test(l) && l.includes('/app/skland-data')),
|
||||
'no anonymous VOLUME for the credential directory');
|
||||
assert.match(read('Dockerfile'), /SP_SKLAND_DB/, 'the top comment documents the data path');
|
||||
});
|
||||
|
||||
test('the compose volume target is the directory the image prepares', () => {
|
||||
const named = items(service, 'volumes', 4).find((m) => m.startsWith('skland-data:'));
|
||||
const target = named.slice(named.indexOf(':') + 1);
|
||||
const df = linesOf(read('Dockerfile'));
|
||||
const mkdir = df.find((l) => l.includes('mkdir -p') && l.includes(target));
|
||||
assert.ok(mkdir, `the image creates ${target} before USER node, the directory compose mounts`);
|
||||
assert.match(mkdir, new RegExp(`mkdir -p[ \\t]+${target.replace(/\//g, '\\/')}\\b`));
|
||||
});
|
||||
|
||||
test('.gitignore: the runtime database and its SQLite side files are never committed', () => {
|
||||
const lines = linesOf(read('.gitignore')).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
||||
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
|
||||
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('docs/DEPLOY.md: the Docker section documents SP_SKLAND, SP_SKLAND_DB, the volume and the secret warning', () => {
|
||||
const doc = read('docs/DEPLOY.md');
|
||||
const start = doc.indexOf('## 3. Docker');
|
||||
const end = doc.indexOf('## 4. ', start);
|
||||
assert.ok(start >= 0 && end > start, 'the Docker section is still there');
|
||||
const docker = doc.slice(start, end);
|
||||
assert.match(docker, /`SP_SKLAND`/, 'names SP_SKLAND');
|
||||
assert.match(docker, /`SP_SKLAND_DB`/, 'names SP_SKLAND_DB');
|
||||
assert.match(docker, /docker compose up -d/, 'the one-command path');
|
||||
assert.match(docker, /skland-data/, 'names the volume');
|
||||
assert.match(docker, /密钥|凭据/, 'warns that the database holds credentials');
|
||||
assert.match(docker, /chmod|权限/, 'tells the operator to restrict the file mode');
|
||||
assert.match(docker, /备份/, 'tells the operator to back it up carefully');
|
||||
assert.match(docker, /HTTP/, 'warns against exposing it over HTTP');
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
// Real title and modal, stub account provider. No real account, SMS, authorization or CAPTCHA is exercised.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { SklandCaptchaError } from '../../server/skland/client.js';
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
|
||||
|
||||
test('title requires binding, reads the nickname, keeps channel choice and handles CAPTCHA, SMS and restored entry', {
|
||||
skip: enabled ? false : 'set SP_E2E=1 and CHROME_PATH (needs Chrome)', timeout: 60000,
|
||||
}, async () => {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', nickName: '官服博士', channelName: '官服', gameId: 1 },
|
||||
{ uid: '123', nickName: 'B服博士', channelName: 'B服', gameId: 2, isDefault: true },
|
||||
] }] };
|
||||
let polls = 0, scans = 0, passwordProof, smsSends = 0;
|
||||
const client = {
|
||||
genScan: async () => { scans++; return { scanId: `SCAN${scans}`, scanUrl: `hypergryph://scan_login?scanId=SCAN${scans}` }; },
|
||||
checkScan: async () => (++polls > 1 ? { state: 'confirmed', scanCode: 'CODE' } : { state: 'waiting', awaitingConfirmation: true }),
|
||||
tokenByScanCode: async () => ({ token: 'SCAN-TOKEN' }),
|
||||
getGrantCode: async () => 'GRANT',
|
||||
getCred: async () => ({ cred: 'CRED', credToken: 'SIGN' }),
|
||||
getBinding: async () => ({ binding, cred: 'CRED', credToken: 'SIGN' }),
|
||||
sendPhoneCode: async () => { smsSends++; },
|
||||
tokenByPhoneCode: async () => ({ token: 'SMS-TOKEN' }),
|
||||
tokenByPhonePassword: async (_phone, _password, captcha) => {
|
||||
if (!captcha) throw new SklandCaptchaError({ geetestId: 'FAKE', challenge: 'ORIGINAL' }, 'password');
|
||||
passwordProof = captcha; return { token: 'PASSWORD-TOKEN' };
|
||||
},
|
||||
getAccountBinding: async () => binding,
|
||||
};
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client } });
|
||||
let browser;
|
||||
try {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
|
||||
let page;
|
||||
const errors = [];
|
||||
const newPage = async () => {
|
||||
const tab = await browser.newPage();
|
||||
await tab.setViewport({ width: 1440, height: 1000 });
|
||||
tab.on('pageerror', (error) => errors.push(error.message));
|
||||
await tab.setRequestInterception(true);
|
||||
tab.on('request', (request) => {
|
||||
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
return tab;
|
||||
};
|
||||
page = await newPage();
|
||||
await page.goto(server.url);
|
||||
await page.waitForSelector('[data-testid="binding-start"]');
|
||||
const clickText = async (text) => {
|
||||
await page.waitForFunction((value) => [...document.querySelectorAll('.sp-binding button')]
|
||||
.some((button) => button.textContent.trim() === value && !button.disabled), {}, text);
|
||||
await page.evaluate((value) => [...document.querySelectorAll('.sp-binding button')]
|
||||
.find((button) => button.textContent.trim() === value).click(), text);
|
||||
};
|
||||
const open = async () => {
|
||||
await page.click(await page.$('[data-testid="account-binding-entry"]') ? '[data-testid="account-binding-entry"]' : '[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.sp-binding');
|
||||
await page.waitForFunction(() => window.__SP__?.net.status === 'online' && !document.querySelector('.sp-binding .btn[aria-busy="true"]'));
|
||||
};
|
||||
const callsign = () => page.evaluate(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent || '');
|
||||
const expectName = async (name) => {
|
||||
try {
|
||||
await page.waitForFunction((value) => (document.querySelector('[data-testid="bound-callsign"]')?.textContent || '') === value, { timeout: 12000 }, name);
|
||||
assert.equal(await page.$('.title-login input'), null, 'the callsign is never an input');
|
||||
assert.equal(!!await page.$('.sp-binding-callsign'), !!name, 'the callsign area only exists after binding');
|
||||
if (name) assert.ok(await page.$('.sp-binding-callsign__icon'), 'the nickname keeps its person icon');
|
||||
} catch (error) {
|
||||
console.error('Callsign state:', await page.evaluate(() => ({
|
||||
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
|
||||
action: document.querySelector('[data-testid="binding-start"]')?.textContent,
|
||||
playerId: window.__SP__?.store.get().me.playerId, connection: window.__SP__?.net.status,
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
|
||||
})), 'page errors:', errors);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const expectAction = async (text) => page.waitForFunction((value) => document.querySelector('[data-testid="binding-start"]')?.textContent.trim() === value, {}, text);
|
||||
await expectAction('绑定账号');
|
||||
await expectName('');
|
||||
assert.equal(await page.$('.title-login label'), null);
|
||||
// Old manual callsigns and a cached entered flag must not bypass the server binding check.
|
||||
await page.evaluate(() => { localStorage.setItem('sp.name', '旧的手填代号'); sessionStorage.setItem('sp.entered', '1'); });
|
||||
await page.reload();
|
||||
await expectAction('绑定账号');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await open();
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await page.waitForSelector('.sp-binding__qr');
|
||||
assert.equal(scans, 1, 'opening the scan tab automatically fetches a QR');
|
||||
const firstQr = await page.$eval('.sp-binding__qr', (image) => image.src);
|
||||
await clickText('重新获取二维码');
|
||||
await page.waitForFunction((previous) => {
|
||||
const image = document.querySelector('.sp-binding__qr'); return image && image.src !== previous;
|
||||
}, {}, firstQr);
|
||||
assert.equal(scans, 2, 'manual QR refresh remains available');
|
||||
await page.waitForFunction(() => document.querySelector('.sp-binding__scan [role="status"]')?.textContent.includes('已扫码'));
|
||||
await expectName('B服博士');
|
||||
await expectAction('开始');
|
||||
assert.ok(polls >= 2);
|
||||
await clickText('官服博士官服 · UID 123');
|
||||
await expectName('官服博士');
|
||||
await clickText('刷新角色');
|
||||
await expectName('官服博士');
|
||||
await clickText('完成');
|
||||
await open();
|
||||
assert.equal(await callsign(), '官服博士');
|
||||
await clickText('解绑账号');
|
||||
await expectName('');
|
||||
await expectAction('绑定账号');
|
||||
await clickText('密码');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
|
||||
await clickText('登录并绑定');
|
||||
await page.waitForSelector('.sp-binding__captcha');
|
||||
await page.evaluate(() => {
|
||||
window.initGeetest4 = (_options, callback) => {
|
||||
let success;
|
||||
callback({
|
||||
onSuccess(fn) { success = fn; }, onError() {}, onClose() {},
|
||||
onReady(fn) { setTimeout(fn, 0); }, showCaptcha() { success(); },
|
||||
getValidate() { return { captcha_id: 'FAKE', lot_number: 'TEST' }; }, destroy() {},
|
||||
});
|
||||
};
|
||||
});
|
||||
await clickText('开始人机验证');
|
||||
await expectName('B服博士');
|
||||
assert.equal(passwordProof.challenge, 'ORIGINAL');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false, 'binding never presses Start');
|
||||
fs.mkdirSync('.cache/skland-preview', { recursive: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/bound.png' });
|
||||
await clickText('解绑账号');
|
||||
await clickText('验证码');
|
||||
await clickText('发送验证码');
|
||||
await page.waitForFunction(() => document.querySelector('.sp-binding__notice')?.textContent.includes('验证码已发送'));
|
||||
assert.equal(smsSends, 1);
|
||||
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
|
||||
await clickText('登录并绑定');
|
||||
await expectName('B服博士');
|
||||
await clickText('完成');
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/entry-bound.png' });
|
||||
await page.reload();
|
||||
await expectName('B服博士');
|
||||
const accountId = () => page.evaluate(async () => {
|
||||
const response = await fetch('/skland/session/status', { headers: { authorization: 'Bearer ' + window.__SP__.store.get().me.token } });
|
||||
return (await response.json()).gamePlayerId;
|
||||
});
|
||||
const previousId = await accountId();
|
||||
await page.close(); page = await newPage(); await page.goto(server.url);
|
||||
await expectName('B服博士'); await expectAction('开始');
|
||||
assert.equal(await accountId(), previousId, 'a fresh tab restores the JWT-owned role without logging in or entering');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await open();
|
||||
// Keep the existing target alive while checking the landscape layout; device emulation can reload the app and
|
||||
// would test browser boot behavior rather than this feature's responsive CSS.
|
||||
await page.setViewport({ width: 844, height: 390 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/mobile.png' });
|
||||
const fits = await page.$eval('.sp-binding', (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const overlay = element.parentElement?.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, height: rect.height,
|
||||
overlay: overlay && { top: overlay.top, bottom: overlay.bottom, height: overlay.height }, viewport: [innerWidth, innerHeight] };
|
||||
});
|
||||
assert.equal(fits.left >= 0 && fits.right <= fits.viewport[0] && fits.top >= 0 && fits.bottom <= fits.viewport[1], true,
|
||||
`the modal fits a landscape phone: ${JSON.stringify(fits)}`);
|
||||
await clickText('解绑账号');
|
||||
await expectName('');
|
||||
await expectAction('绑定账号');
|
||||
await clickText('完成');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.sp-binding');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
// A login without a named role still cannot start.
|
||||
const namedRoles = binding.list[0].bindingList;
|
||||
binding.list[0].bindingList = [];
|
||||
await clickText('验证码');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
|
||||
await clickText('登录并绑定');
|
||||
await page.waitForSelector('.sp-binding__roles');
|
||||
await expectAction('绑定账号');
|
||||
await expectName('');
|
||||
binding.list[0].bindingList = namedRoles;
|
||||
await clickText('刷新角色');
|
||||
await expectName('B服博士');
|
||||
await clickText('完成');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.lobby-screen');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().me.name), 'B服博士');
|
||||
await page.reload();
|
||||
await page.waitForSelector('.lobby-screen');
|
||||
// Removing the binding on the server invalidates the cached entry on the next reload.
|
||||
await page.evaluate(async () => {
|
||||
await fetch('/skland/session/unbind', { method: 'POST', headers: {
|
||||
authorization: `Bearer ${window.__SP__.store.get().me.token}`, 'content-type': 'application/json',
|
||||
}, body: '{}' });
|
||||
});
|
||||
await page.reload();
|
||||
await expectAction('绑定账号');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
// The /skland/* HTTP surface and its per-player orchestration (server/skland/service.js, server/skland/http.js).
|
||||
//
|
||||
// The server is started for real (startServer with `skland: { storeFile: ':memory:', fetchImpl }`), so the wiring —
|
||||
// routes.js handing /skland/* to the module before its GET/HEAD gate, the JSON bodies, the store — is what is under
|
||||
// test; only the upstream HTTP is a stub. Each case drives one login path the way the panel would.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
/** The stub upstream (the shapes the community documentation records). */
|
||||
function stubUpstream(overrides = {}) {
|
||||
const calls = [];
|
||||
const j = (o) => ({ status: 200, json: async () => o });
|
||||
const answers = {
|
||||
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
|
||||
scanStatus: () => j({ status: 100, msg: '未扫码' }),
|
||||
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
sendCode: () => j({ status: 0, msg: 'OK' }),
|
||||
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
|
||||
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
|
||||
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
|
||||
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
|
||||
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 }] }] } }),
|
||||
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', isDefault: true }] }] } }),
|
||||
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
|
||||
};
|
||||
const routes = [
|
||||
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
|
||||
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
|
||||
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
|
||||
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
|
||||
['account/binding/v1/binding_list', 'accountBinding'],
|
||||
];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
const key = routes.find(([frag]) => url.includes(frag))?.[1];
|
||||
calls.push({ key, url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : null });
|
||||
const answer = overrides[key] ?? answers[key];
|
||||
if (!answer) throw new Error(`stub: no route for ${url}`);
|
||||
return answer(calls.at(-1));
|
||||
};
|
||||
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
|
||||
}
|
||||
|
||||
/** Start a server with the stubbed Skland module. */
|
||||
async function withServer(overrides, fn) {
|
||||
const up = stubUpstream(overrides);
|
||||
const srv = await startServer({
|
||||
port: 0, host: '127.0.0.1', quiet: true,
|
||||
skland: { storeFile: ':memory:', fetchImpl: up.fetchImpl, diagnosticKey: 'test-admin-only' },
|
||||
});
|
||||
const post = (path, body) => fetch(srv.url + path, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
const get = (path) => fetch(srv.url + path, { headers: { 'x-sp-diagnostic-key': 'test-admin-only' } }).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
try { await fn({ srv, post, get, up }); } finally { await srv.close(); }
|
||||
}
|
||||
|
||||
test('the server exposes the module when skland is configured', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
assert.ok(srv.skland, 'startServer returns the service');
|
||||
});
|
||||
});
|
||||
|
||||
test('the module is off by default, and /skland/* still answers one uniform JSON 404', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
assert.equal(srv.skland, null, 'no account store on an unconfigured server');
|
||||
const got = await fetch(srv.url + '/skland/status?player=p1');
|
||||
assert.equal(got.status, 404, 'GET /skland/* is not routed to a store');
|
||||
assert.equal((await got.json()).ok, false, 'the off-surface is JSON, not the HTML error page');
|
||||
|
||||
// A POST must not reach routes.js's GET/HEAD gate and answer "405 method not allowed" for a feature that is
|
||||
// simply off — the panel would show a cryptic method error instead of "not enabled".
|
||||
const posted = await fetch(srv.url + '/skland/scan/start', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ player: 'p1' }),
|
||||
});
|
||||
assert.equal(posted.status, 404, 'POST is 404 too, not 405');
|
||||
assert.match((await posted.json()).error, /SP_SKLAND|未启用/);
|
||||
} finally { await srv.close(); }
|
||||
});
|
||||
|
||||
test('GET /skland/status: an unknown player reads as unbound', async () => {
|
||||
await withServer({}, async ({ get }) => {
|
||||
const { status, body } = await get('/skland/status?player=p1');
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, { playerId: 'p1', status: 'unbound', bound: false, nickname: null, hgId: null, roles: [] });
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /skland/status without a player is a JSON 400', async () => {
|
||||
await withServer({}, async ({ get }) => {
|
||||
const { status, body } = await get('/skland/status');
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.ok, false, 'a JSON client must never be handed the HTML error page');
|
||||
assert.match(body.error, /player/);
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: start → unscanned → awaiting phone confirmation → confirmed binds the same session', async () => {
|
||||
const pending = [
|
||||
{ status: 100, msg: '未扫码' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
];
|
||||
await withServer({
|
||||
scanStatus: () => ({ status: 200, json: async () => pending.shift()
|
||||
?? { status: 0, msg: 'OK', data: { scanCode: 'SC1' } } }),
|
||||
binding: () => ({ status: 200, json: async () => ({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 },
|
||||
{ uid: '456', nickName: 'B服博士', channelName: 'B服', gameId: 2 },
|
||||
] }] } }) }),
|
||||
}, async ({ post, get, up }) => {
|
||||
const start = await post('/skland/scan/start', { player: 'p1' });
|
||||
assert.equal(start.status, 200);
|
||||
assert.equal(start.body.ok, true);
|
||||
assert.equal(start.body.scanUrl, 'hypergryph://scan_login?scanId=SCAN1');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const waiting = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(waiting.body.ok, true);
|
||||
assert.equal(waiting.body.state, 'waiting');
|
||||
assert.equal(waiting.body.awaitingConfirmation, i > 0, 'status 101 is preserved for the longer QR refresh window');
|
||||
assert.equal(waiting.body.scanUrl, start.body.scanUrl, 'the QR URL survives every pending poll');
|
||||
const scanning = await get('/skland/status?player=p1');
|
||||
assert.equal(scanning.body.status, 'scanning');
|
||||
assert.equal(scanning.body.bound, false);
|
||||
assert.equal(up.byKey('scanToken').length, 0, 'no token exchange before confirmation');
|
||||
}
|
||||
|
||||
const done = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(done.body.state, 'confirmed');
|
||||
assert.equal(done.body.status, 'bound');
|
||||
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.bound, true);
|
||||
assert.equal(status.body.nickname, '博士');
|
||||
assert.deepEqual(status.body.roles, [
|
||||
{ uid: '123', nickName: '博士', channelName: '官服', isDefault: false, gameId: 1 },
|
||||
{ uid: '456', nickName: 'B服博士', channelName: 'B服', isDefault: false, gameId: 2 },
|
||||
]);
|
||||
assert.equal(up.byKey('scanToken')[0].body.scanCode, 'SC1');
|
||||
assert.equal(up.byKey('scanToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 0, 'QR login keeps the Skland binding provider');
|
||||
assert.equal(up.byKey('genScan').length, 1, 'no replacement QR is needed after status 101');
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: a poll without a session answers state none', async () => {
|
||||
await withServer({}, async ({ post }) => {
|
||||
const r = await post('/skland/scan/poll', { player: 'never-started' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.state, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: a failing poll still names a state, so the client never reads `undefined`', async () => {
|
||||
// The panel showed "未知状态 · Unknown state: undefined" when the upstream died mid-scan: the answer was a bare
|
||||
// { ok: false } with no `state`, and a caller that only switches on `state` fell through to its unknown branch.
|
||||
await withServer({ scanStatus: () => { throw new Error('upstream down'); } }, async ({ post }) => {
|
||||
await post('/skland/scan/start', { player: 'p1' });
|
||||
const { body } = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.state, 'error', 'every poll answer carries a terminal state');
|
||||
assert.match(body.error, /upstream down/);
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: send → login binds the player', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
const sent = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
|
||||
assert.equal(sent.body.ok, true);
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
|
||||
|
||||
const login = await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
assert.equal(login.body.ok, true);
|
||||
assert.equal(login.body.nickname, '博士');
|
||||
assert.equal(login.body.roles.length, 1);
|
||||
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.bound, true);
|
||||
assert.equal(status.body.hgId, 'HG1', 'the hgId from the login is kept');
|
||||
assert.equal(up.byKey('grant').length, 1, 'exactly one grant per login');
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: 需要人机验证 is passed through with the geetest challenge', async () => {
|
||||
await withServer({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', data: { captcha: { gt: 'GT', challenge: 'CH', new_captcha: true } } }) }),
|
||||
}, async ({ post }) => {
|
||||
const r = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needCaptcha, true);
|
||||
assert.deepEqual(r.body.captcha, { gt: 'GT', challenge: 'CH', new_captcha: true });
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: a solved captcha travels back to the upstream', async () => {
|
||||
await withServer({}, async ({ post, up }) => {
|
||||
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
|
||||
await post('/skland/phone/send', { player: 'p1', phone: '13800000000', captcha: solved });
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: a fresh player can bind without a previous 验证码/扫码 login', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.nickname, '博士');
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal(up.byKey('phoneToken').length, 0);
|
||||
assert.equal(up.byKey('scanToken').length, 0);
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
assert.equal(r.body.roles[0].isDefault, true, 'official isDefault is preserved');
|
||||
assert.equal(up.byKey('cred').length, 0, 'no Skland credentials are acquired');
|
||||
assert.equal(up.byKey('binding').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: upstream device verification (109) is explained after attempting login', async () => {
|
||||
await withServer({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
|
||||
}, async ({ post, up, srv }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needDevice, true);
|
||||
assert.match(r.body.error, /验证码|扫码/);
|
||||
assert.equal(up.byKey('passwordToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
assert.equal(srv.skland._store.get('p1').access_token, null, 'failed login credentials are not persisted');
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: an incorrect password stays a password error without requiring a prior login', async () => {
|
||||
await withServer({
|
||||
passwordToken: () => ({ status: 200, json: async () => ({ status: 100, msg: '手机号或密码错误' }) }),
|
||||
}, async ({ post, up }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'wrong' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needDevice, undefined);
|
||||
assert.equal(r.body.error, '手机号或密码错误');
|
||||
assert.equal(up.byKey('passwordToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: interactive captcha proofs resume the password and account-grant steps separately', async () => {
|
||||
const challenge = { geetestId: 'TEST-ID', challenge: 'TEST-CHALLENGE', riskType: 'slide' };
|
||||
const passwordProof = { captcha_output: 'TEST-PASSWORD-PROOF', challenge: 'TEST-CHALLENGE' };
|
||||
const grantProof = { captcha_output: 'TEST-GRANT-PROOF', challenge: 'TEST-CHALLENGE' };
|
||||
const answer = (data) => ({ status: 200, json: async () => data });
|
||||
await withServer({
|
||||
passwordToken: ({ body }) => answer(body.captcha
|
||||
? { status: 0, data: { token: 'T-AFTER-CAPTCHA' } } : { status: 1, data: { captcha: challenge } }),
|
||||
grant: ({ body }) => answer(body.captcha
|
||||
? { status: 0, data: { token: 'BINDINGTOKEN' } } : { status: 1, data: { captcha: challenge } }),
|
||||
}, async ({ post, up, srv }) => {
|
||||
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
|
||||
const first = await post('/skland/password/login', login);
|
||||
assert.equal(first.body.needCaptcha, true);
|
||||
assert.equal(first.body.captchaStep, 'password');
|
||||
assert.deepEqual(first.body.captcha, challenge);
|
||||
assert.equal(up.byKey('grant').length, 0);
|
||||
const second = await post('/skland/password/login', { ...login, captcha: passwordProof, captchaStep: 'password' });
|
||||
assert.equal(second.body.needCaptcha, true);
|
||||
assert.equal(second.body.captchaStep, 'accountGrant');
|
||||
assert.deepEqual(up.byKey('passwordToken')[1].body.captcha, passwordProof);
|
||||
assert.equal(srv.skland._store.get('p1'), null, 'pending credentials are not persisted');
|
||||
const third = await post('/skland/password/login', { ...login, captcha: grantProof, captchaStep: 'accountGrant' });
|
||||
assert.equal(third.body.ok, true);
|
||||
assert.equal(third.body.roles.length, 1);
|
||||
assert.equal(up.byKey('passwordToken').length, 2, 'the successful password exchange is not repeated');
|
||||
assert.deepEqual(up.byKey('grant').map((c) => c.body.token), ['T-AFTER-CAPTCHA', 'T-AFTER-CAPTCHA']);
|
||||
assert.deepEqual(up.byKey('grant')[1].body.captcha, grantProof);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: unbind discards a pending grant captcha and cannot resurrect its login', async () => {
|
||||
await withServer({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 1, data: { captcha: { geetestId: 'TEST-ID' } } }) }),
|
||||
}, async ({ post, up }) => {
|
||||
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
|
||||
assert.equal((await post('/skland/password/login', login)).body.captchaStep, 'accountGrant');
|
||||
await post('/skland/unbind', { player: 'p1' });
|
||||
const retry = await post('/skland/password/login', { ...login, captcha: { captcha_output: 'TEST' }, captchaStep: 'accountGrant' });
|
||||
assert.equal(retry.body.ok, false);
|
||||
assert.match(retry.body.error, /过期/);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: a previous Skland binding switches to official account lookup without reusing its deviceToken', async () => {
|
||||
await withServer({}, async ({ post, up, srv }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.nickname, '博士');
|
||||
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' },
|
||||
'the password endpoint takes phone + password only');
|
||||
const grant = up.byKey('grant').at(-1);
|
||||
assert.equal(grant.body.deviceToken, undefined, 'official account lookup does not need the Skland deviceToken');
|
||||
assert.equal(grant.body.appCode, 'be36d44aa36bfb5b');
|
||||
assert.equal(grant.body.type, 1);
|
||||
assert.equal(grant.body.token, 'T-PW');
|
||||
const row = srv.skland._store.get('p1');
|
||||
assert.equal(row.binding_source, 'hypergryph');
|
||||
assert.equal(row.cred, null);
|
||||
assert.equal(row.cred_token, null);
|
||||
assert.equal(row.device_token, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: role refresh uses official account bindings and rebind via SMS restores the Skland source', async () => {
|
||||
await withServer({}, async ({ post, get, up, srv }) => {
|
||||
await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal((await get('/skland/roles?player=p1')).body.roles.length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 1, 'cached lookup does not call upstream');
|
||||
const refreshed = await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(refreshed.body.ok, true);
|
||||
assert.equal(refreshed.body.roles[0].isDefault, true);
|
||||
assert.equal(up.byKey('accountBinding').length, 2);
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
assert.equal(srv.skland._store.get('p1').binding_source, 'skland');
|
||||
await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(up.byKey('binding').length, 2);
|
||||
assert.equal(up.byKey('accountBinding').length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('解绑 clears the credentials and the status reads unbound again', async () => {
|
||||
await withServer({}, async ({ post, get }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal((await post('/skland/unbind', { player: 'p1' })).body.ok, true);
|
||||
const after = await get('/skland/status?player=p1');
|
||||
assert.equal(after.body.bound, false);
|
||||
assert.equal(after.body.status, 'unbound');
|
||||
assert.deepEqual(after.body.roles, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('players are isolated: one binding never leaks into another', async () => {
|
||||
await withServer({}, async ({ post, get }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal((await get('/skland/status?player=p2')).body.bound, false, 'p2 stays unbound');
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /skland/roles returns the cached binding, and ?refresh=1 re-queries upstream', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
const cached = await get('/skland/roles?player=p1');
|
||||
assert.equal(cached.body.ok, true);
|
||||
assert.equal(cached.body.roles.length, 1);
|
||||
assert.equal(up.byKey('binding').length, 1, 'the cached read does not hit the upstream');
|
||||
|
||||
const refreshed = await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(refreshed.body.ok, true);
|
||||
assert.equal(up.byKey('binding').length, 2, 'refresh re-queries');
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed 验证码 login records the error on the status', async () => {
|
||||
await withServer({
|
||||
phoneToken: () => ({ status: 200, json: async () => ({ status: 1, msg: '验证码错误' }) }),
|
||||
}, async ({ post, get }) => {
|
||||
const r = await post('/skland/phone/login', { player: 'p1', phone: '138', code: '000000' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.error, '验证码错误');
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.status, 'error');
|
||||
assert.equal(status.body.error, '验证码错误');
|
||||
assert.equal(status.body.bound, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unknown /skland endpoint is a JSON 404 and a bad method a JSON 405', async () => {
|
||||
await withServer({}, async ({ post, srv }) => {
|
||||
const unknown = await post('/skland/nope', { player: 'p1' });
|
||||
assert.equal(unknown.status, 404);
|
||||
assert.equal(unknown.body.ok, false);
|
||||
|
||||
const del = await fetch(srv.url + '/skland/status?player=p1', { method: 'DELETE', headers: { 'x-sp-diagnostic-key': 'test-admin-only' } });
|
||||
assert.equal(del.status, 405);
|
||||
const body = await del.json();
|
||||
assert.equal(body.ok, false, 'the method error is JSON too');
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed JSON body is a 400, not a crash', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
const res = await fetch(srv.url + '/skland/phone/login', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: '{not json',
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the rest of the server is unaffected: /healthz and / still answer', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
const health = await fetch(srv.url + '/healthz');
|
||||
assert.equal(health.status, 200);
|
||||
assert.equal((await health.json()).ok, true);
|
||||
const home = await fetch(srv.url + '/');
|
||||
assert.ok([200, 404].includes(home.status), 'the static mount still owns /');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// test/skland/panel.test.js — public/skland.html, the standalone Skland binding panel (scan / SMS / password).
|
||||
//
|
||||
// The panel is one self-contained HTML file with an inline module script (no build step, no browser test), so this
|
||||
// test reads it as TEXT and pins only the contract facts that would silently rot: the endpoints it calls, that the QR
|
||||
// encodes the scanUrl deep link (NOT the scan session id), that captcha challenges are handled, that a 404 reads as
|
||||
// "the module is off", and that the 2 s polling loop always gets cleared.
|
||||
// String checks like these are deliberately few and each one names the failure it prevents.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const HTML_PATH = path.join(ROOT, 'public', 'skland.html');
|
||||
|
||||
describe('the Skland binding panel (public/skland.html)', () => {
|
||||
assert.ok(existsSync(HTML_PATH), 'the panel is a real file the static server can serve');
|
||||
const html = readFileSync(HTML_PATH, 'utf8');
|
||||
|
||||
test('is one whole document: a doctype, an inline module script and a closed html tag', () => {
|
||||
// A stray `</script>` or an unclosed tag is exactly the kind of damage a text-only test can catch.
|
||||
assert.match(html, /^<!doctype html>/i);
|
||||
assert.equal((html.match(/<script\b/g) || []).length, (html.match(/<\/script>/g) || []).length, 'every script opens and closes once');
|
||||
assert.match(html, /<script(?![^>]*\bsrc=)[^>]*type="module"/i, 'the panel logic is an inline module (no build step)');
|
||||
assert.match(html, /<\/body>\s*<\/html>\s*$/, 'the document ends where it should');
|
||||
});
|
||||
|
||||
test('every endpoint the panel needs is referenced', () => {
|
||||
// Each is a distinct login/status step of server/skland/http.js; dropping one breaks that path silently.
|
||||
for (const endpoint of [
|
||||
'/skland/status', '/skland/scan/start', '/skland/scan/poll', '/skland/phone/send',
|
||||
'/skland/phone/login', '/skland/password/login', '/skland/unbind', '/skland/roles',
|
||||
]) {
|
||||
assert.ok(html.includes(endpoint), `the panel calls ${endpoint}`);
|
||||
}
|
||||
assert.match(html, /\/skland\/roles\?player=.*refresh=1/, 'a bound refresh re-queries the roles');
|
||||
});
|
||||
|
||||
test('the QR code encodes scanUrl — never the scanId', () => {
|
||||
// The upstream deep link is `hypergryph://scan_login?…` (server/skland/service.js startScan). Encoding the opaque
|
||||
// scanId would render a QR that no phone can act on, and nothing else in the flow would fail loudly.
|
||||
assert.ok(html.includes('/vendor/qrcode.js'), 'the vendored library is loaded as a classic script');
|
||||
const addData = html.match(/qr\.addData\(\s*([^)]*)\)/);
|
||||
assert.ok(addData, 'the generator is fed one payload');
|
||||
assert.match(addData[1], /scanUrl/, 'the payload is the scanUrl deep link');
|
||||
assert.doesNotMatch(addData[1], /scanId/, 'never the scan session id');
|
||||
assert.match(html, /renderQr\(\s*r\.scanUrl\s*\)/, 'the value comes from the scan/start answer');
|
||||
assert.match(html, /typeof qrcode !== 'function'/, 'a missing vendor file is detected…');
|
||||
assert.match(html, /createSvgTag|createImgTag/, '…and the present library renders a tag');
|
||||
assert.match(html, /textContent\s*=\s*String\(scanUrl\)|value\s*=\s*String\(scanUrl\)/, '…with the deep link as selectable text');
|
||||
});
|
||||
|
||||
test('the QR frame step is named exactly as the server sends it', () => {
|
||||
// service.js pollScan answers state ∈ none|waiting|confirmed|expired; 'none' and 'expired' mean start over.
|
||||
for (const state of ["'waiting'", "'confirmed'", "'expired'", "'none'"]) {
|
||||
assert.ok(html.includes(`state === ${state}`), `the poll handles state ${state}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 404 (or the 405 a POST gets) on /skland/* reads as "the module is off", not as a broken page', () => {
|
||||
// The module is opt-in (SP_SKLAND=1). With it off /skland/* is not routed at all: a GET falls through to the
|
||||
// static server's 404 page, but a POST hits routes.js's GET/HEAD gate first and gets a 405 HTML page. Both are
|
||||
// the bilingual error page, while an enabled module always answers JSON (errors included) — so the panel keys on
|
||||
// the HTML content type as well, and only a plain 404 check would show a confusing "method not allowed".
|
||||
assert.match(html, /res\.status === 404/);
|
||||
assert.match(html, /text\/html/, 'an HTML answer is recognised as the wrong server…');
|
||||
assert.match(html, /该功能未启用\s*·\s*the module is off/);
|
||||
assert.ok(html.includes('id="module-off"'), 'the notice has a place to render');
|
||||
assert.match(html, /setModuleOff\(\)/, 'the notice is raised, not just logged');
|
||||
});
|
||||
|
||||
test('needCaptcha is handled', () => {
|
||||
// needCaptcha: send → geetest challenge, then resend with the solved fields (service.js sendPhoneCode).
|
||||
assert.ok(html.includes('needCaptcha'));
|
||||
assert.match(html, /needCaptcha[\s\S]{0,200}renderCaptcha\(/, 'the challenge data is displayed');
|
||||
assert.ok(html.includes('/js/skland/captcha.js'), 'the shared interactive widget supplies the proof');
|
||||
assert.ok(html.includes('pw-captcha-box'), 'password login has its own challenge control');
|
||||
});
|
||||
|
||||
test('a poll answer is never read as `undefined`: ok:false is honoured and `state` is always named', () => {
|
||||
// The panel once showed "未知状态 · Unknown state: undefined" because the server answered a failed poll with a
|
||||
// bare { ok: false } and pollOnce only switched on `state`. Both halves are pinned here.
|
||||
assert.match(html, /r\.ok === false/, 'pollOnce checks ok:false before it reads state');
|
||||
assert.match(html, /r\.state === 'error'/, "the server's terminal error state is handled");
|
||||
assert.match(html, /未知状态[\s\S]{0,160}brief\(JSON\.stringify\(r\)\)/, 'the unknown branch shows the real body');
|
||||
});
|
||||
|
||||
test('the poll runs about every 2 s and its timer is always cleared', () => {
|
||||
// A leaked interval keeps asking the server after the tab is hidden or the section is left (service.js deletes the
|
||||
// scan session on the first non-waiting answer, so a leaked poll can only ever report state 'none' — noise).
|
||||
const ms = html.match(/POLL_MS\s*=\s*(\d+)/);
|
||||
assert.ok(ms, 'the interval is a named constant');
|
||||
assert.equal(Number(ms[1]), 2000, 'the documented ~2 s');
|
||||
assert.match(html, /setInterval\([\s\S]{0,80}?POLL_MS\s*\)/, 'the timer uses that constant');
|
||||
assert.match(html, /clearInterval\(pollTimer\)/, 'the single cleanup path clears the interval');
|
||||
assert.match(html, /document\.hidden\)\s*return/, 'the poll never starts on a hidden tab');
|
||||
assert.match(html, /document\.hidden\)\s*pollStop\(\)/, 'a tab that goes hidden stops the poll');
|
||||
assert.ok(html.includes("addEventListener('visibilitychange'"), 'visibility changes are watched');
|
||||
assert.ok(html.includes("addEventListener('pagehide'"), 'leaving the page stops the poll too');
|
||||
});
|
||||
|
||||
test('the identity shown is the ?player= / localStorage one, labelled as a placeholder', () => {
|
||||
assert.ok(html.includes('URLSearchParams'), 'the player id is read from the URL');
|
||||
assert.ok(html.includes('sp.skland.player'), 'and kept in localStorage under a clear key');
|
||||
assert.match(html, /placeholder/i, 'the page says the id is a placeholder until the session provides one');
|
||||
});
|
||||
|
||||
test('no external network calls and no inline secrets', () => {
|
||||
// The page must work on an offline LAN box: only same-origin /vendor and /skland URLs are allowed. No tokens,
|
||||
// passwords or phone numbers are baked in either.
|
||||
assert.doesNotMatch(html, /https?:\/\//, 'no absolute http(s) URL anywhere');
|
||||
assert.doesNotMatch(html, /\b(?:token|secret|password)\s*[:=]\s*['"][A-Za-z0-9]{8,}['"]/i, 'no hardcoded credential');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Two isolated browser contexts exercise real title/login/recovery UI against an entirely stubbed provider.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
|
||||
|
||||
test('a separately logged-in device restores the match without local saves, and stale or duplicated tabs do not take it back',
|
||||
{ skip: !enabled, timeout: 90_000 }, async () => {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', channelName: '官服', nickName: '跨设备博士', isDefault: true },
|
||||
] }] };
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
|
||||
skland: { storeFile: ':memory:', client: {
|
||||
genScan: async () => ({ scanId: 'TEST', scanUrl: 'hypergryph://scan_login?scanId=TEST' }),
|
||||
checkScan: async () => ({ state: 'waiting' }),
|
||||
tokenByPhonePassword: async () => ({ token: 'FAKE' }),
|
||||
getAccountBinding: async () => binding,
|
||||
} } });
|
||||
let browser;
|
||||
const errors = [];
|
||||
try {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
|
||||
const firstContext = await browser.createBrowserContext(), secondContext = await browser.createBrowserContext();
|
||||
const pageIn = async (context) => {
|
||||
const page = await context.newPage();
|
||||
await page.setViewport({ width: 1440, height: 1000 });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
return page;
|
||||
};
|
||||
const clickDialog = async (page, label) => {
|
||||
// QR loading can disable the tabs between separate readiness and click calls. Check/click atomically.
|
||||
await page.waitForFunction((text) => {
|
||||
const button = [...document.querySelectorAll('.sp-binding button')]
|
||||
.find((element) => element.textContent.trim() === text && !element.disabled);
|
||||
if (!button) return false;
|
||||
button.click(); return true;
|
||||
}, {}, label);
|
||||
};
|
||||
const login = async (page) => {
|
||||
await page.goto(server.url);
|
||||
await page.waitForSelector('[data-testid="binding-start"]');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await clickDialog(page, '密码');
|
||||
await page.waitForSelector('.sp-binding input[type="tel"]:not([disabled])');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
|
||||
await clickDialog(page, '登录并绑定');
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent === '跨设备博士');
|
||||
await clickDialog(page, '完成');
|
||||
};
|
||||
const a = await pageIn(firstContext), b = await pageIn(secondContext);
|
||||
await login(a);
|
||||
await a.click('[data-testid="resume-account-match"]');
|
||||
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="status"]')?.textContent === '当前角色没有可恢复的对局。');
|
||||
await a.keyboard.press('Escape');
|
||||
await a.waitForSelector('.sp-account-resume', { hidden: true });
|
||||
await a.click('[data-testid="binding-start"]');
|
||||
await a.waitForSelector('.lobby-screen');
|
||||
await a.evaluate(async () => {
|
||||
await window.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
||||
await window.__SP__.net.request('room.start');
|
||||
});
|
||||
await a.waitForFunction(() => window.__SP__.store.get().match.private && window.__SP__.store.get().room?.inMatch);
|
||||
const previous = await a.evaluate(() => {
|
||||
const s = window.__SP__.store.get();
|
||||
return { code: s.room.code, playerId: s.me.playerId, token: s.me.token, private: s.match.private };
|
||||
});
|
||||
await login(b);
|
||||
assert.equal(await b.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('sp.matches') || '{}')).length), 0);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().match.private), null);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online');
|
||||
assert.equal(await b.$('[data-testid="resume-local-match"]'), null, 'account UI no longer offers a local-token recovery');
|
||||
await b.click('[data-testid="resume-account-match"]');
|
||||
await b.waitForSelector('[data-testid="confirm-account-resume"]');
|
||||
assert.match(await b.$eval('[data-testid="confirm-account-resume"]', (el) => el.textContent), new RegExp(previous.code));
|
||||
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online', 'opening the picker does not take over');
|
||||
fs.mkdirSync('.cache/skland-preview', { recursive: true });
|
||||
await b.$eval('.modal', (element) => Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)));
|
||||
await b.screenshot({ path: '.cache/skland-preview/account-recovery.png' });
|
||||
await b.setViewport({ width: 844, height: 390 });
|
||||
const fits = await b.$eval('.sp-account-resume', (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight;
|
||||
});
|
||||
assert.equal(fits, true, 'the recovery picker fits a landscape phone');
|
||||
const readable = await b.$eval('.sp-account-resume', (element) => ({
|
||||
font: parseFloat(getComputedStyle(element.querySelector('.modal__body')).fontSize),
|
||||
hit: element.querySelector('[data-testid="confirm-account-resume"]').getBoundingClientRect().height,
|
||||
}));
|
||||
assert.ok(readable.font >= 14 && readable.hit >= 44, 'small-screen recovery stays readable and tappable');
|
||||
await b.screenshot({ path: '.cache/skland-preview/account-recovery-mobile.png' });
|
||||
await b.setViewport({ width: 1440, height: 1000 });
|
||||
await b.click('[data-testid="confirm-account-resume"]');
|
||||
await b.waitForFunction((code) => {
|
||||
const s = window.__SP__.store.get();
|
||||
return s.session.entered && s.room?.code === code && !!s.match.private;
|
||||
}, {}, previous.code);
|
||||
const restored = await b.evaluate(() => window.__SP__.store.get());
|
||||
assert.equal(restored.me.playerId, previous.playerId);
|
||||
assert.notEqual(restored.me.token, previous.token);
|
||||
for (const key of ['funds', 'shop', 'hand', 'board']) assert.deepEqual(restored.match.private[key], previous.private[key]);
|
||||
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED' && window.__SP__.net.status === 'closed');
|
||||
const continueHere = async (page) => {
|
||||
await page.waitForFunction(() => {
|
||||
const button = [...document.querySelectorAll('.conn-banner button')]
|
||||
.find((element) => element.textContent.trim() === '在此页面继续' && !element.disabled);
|
||||
if (!button) return false;
|
||||
button.click(); return true;
|
||||
});
|
||||
await page.waitForFunction((code) => {
|
||||
const s = window.__SP__.store.get();
|
||||
return window.__SP__.net.status === 'online' && s.session.entered && s.room?.code === code;
|
||||
}, {}, previous.code);
|
||||
};
|
||||
await continueHere(a);
|
||||
await b.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
||||
await continueHere(b);
|
||||
const heldToken = await b.evaluate(() => window.__SP__.store.get().me.token);
|
||||
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
||||
await a.reload();
|
||||
await a.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false, 'an old saved entry cannot silently retake the role');
|
||||
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online');
|
||||
const duplicate = await pageIn(secondContext);
|
||||
await duplicate.evaluateOnNewDocument((token) => {
|
||||
sessionStorage.setItem('sp.token', token); sessionStorage.setItem('sp.entered', '1');
|
||||
}, heldToken);
|
||||
await duplicate.goto(server.url);
|
||||
await duplicate.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await duplicate.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online', 'a duplicated tab does not take over either');
|
||||
// The match can end while the other device has its recovery dialog open.
|
||||
await a.click('[data-testid="resume-account-match"]');
|
||||
await a.waitForSelector('[data-testid="confirm-account-resume"]');
|
||||
await b.evaluate(() => window.__SP__.net.request('g.leave'));
|
||||
await a.click('[data-testid="confirm-account-resume"]');
|
||||
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="alert"]')?.textContent === '此对局已结束或过期,请刷新后重试。');
|
||||
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await a.$('[data-testid="confirm-account-resume"]'), null);
|
||||
// An active page that loses its server session must not stay in an anonymous, unusable lobby.
|
||||
const current = server.registry.byToken(heldToken);
|
||||
server.registry.remove(current);
|
||||
await b.evaluate(() => window.__SP__.net.reconnectNow());
|
||||
await b.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.deepEqual(errors, []);
|
||||
} catch (error) {
|
||||
const pages = browser ? await browser.pages() : [];
|
||||
console.error('Recovery browser state:', await Promise.all(pages.map((page) => page.evaluate(() => ({
|
||||
status: window.__SP__?.net.status, entered: window.__SP__?.store.get().session.entered,
|
||||
playerId: window.__SP__?.store.get().me.playerId,
|
||||
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
|
||||
})).catch(() => null))), 'page errors:', errors);
|
||||
throw error;
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Independent devices have independent login cookies and no shared browser storage. No provider network calls.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { roleKey } from '../../shared/accountIdentity.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { accountPlayerId } from '../../server/skland/auth.js';
|
||||
|
||||
const role = (uid = '123', channelName = '官服', extra = {}) => ({ uid, channelName, nickName: '同名博士', ...extra });
|
||||
|
||||
async function fixture(t) {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
|
||||
skland: { storeFile: ':memory:' } });
|
||||
const clients = [];
|
||||
t.after(async () => { await Promise.all(clients.map((client) => client.close())); await server.close(); });
|
||||
const connect = async (cookie, token) => {
|
||||
const client = await TestClient.connect(server.url.replace('http:', 'ws:') + '/ws', {
|
||||
wsOptions: { headers: cookie ? { cookie } : {} },
|
||||
});
|
||||
clients.push(client);
|
||||
const welcome = await client.hello('Transport', token);
|
||||
return { client, token: welcome.token, welcome };
|
||||
};
|
||||
const request = async (device, endpoint, body, headers = {}) => {
|
||||
const response = await fetch(server.url + '/skland/session/' + endpoint, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { authorization: 'Bearer ' + device.token, ...(device.cookie ? { cookie: device.cookie } : {}),
|
||||
'content-type': 'application/json', ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
if (cookie) device.cookie = cookie.split(';')[0];
|
||||
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||
};
|
||||
const device = async (roles = [role()]) => {
|
||||
const d = await connect();
|
||||
server.skland._store.update('@session/' + d.welcome.playerId, { status: 'bound', nickname: roles[0].nickName,
|
||||
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
|
||||
const result = await request(d, 'status');
|
||||
assert.equal(result.status, 200);
|
||||
d.state = result.body;
|
||||
return d;
|
||||
};
|
||||
const enter = async (d, id) => {
|
||||
const result = await request(d, id ? 'resume' : 'enter', id ? { id } : {});
|
||||
assert.equal(result.status, 200, JSON.stringify(result.body));
|
||||
await d.client.close();
|
||||
Object.assign(d, await connect(d.cookie, result.body.token));
|
||||
assert.equal(d.welcome.playerId, result.body.gamePlayerId);
|
||||
assert.notEqual(d.token, result.body.token, 'the accepted socket rotates the account token');
|
||||
return d;
|
||||
};
|
||||
const start = async (d, mode = 'solo') => {
|
||||
await enter(d);
|
||||
assert.equal((await d.client.request({ t: 'room.create', mode, difficulty: 'NORMAL' })).t, 'ok');
|
||||
const state = await d.client.waitFor('room.state');
|
||||
assert.equal((await d.client.request({ t: 'room.start' })).t, 'ok');
|
||||
const room = server.lobby.rooms.get(state.code);
|
||||
assert.ok(room.match, 'uses the real Match implementation');
|
||||
await d.client.waitFor('m.public');
|
||||
return room;
|
||||
};
|
||||
const disconnect = async (d) => {
|
||||
const session = server.registry.byToken(d.token);
|
||||
const closed = once(session.ws, 'close');
|
||||
await d.client.close();
|
||||
await closed;
|
||||
};
|
||||
return { server, connect, request, device, enter, start, disconnect };
|
||||
}
|
||||
|
||||
test('a fresh device queries the selected role and explicitly resumes the same live match and private state', async (t) => {
|
||||
const h = await fixture(t), a = await h.device();
|
||||
const room = await h.start(a), match = room.match, player = match.players.get(a.welcome.playerId);
|
||||
player.funds = 37;
|
||||
player.shop.freeRefreshes = 2;
|
||||
const before = player.privateView(), oldToken = a.token;
|
||||
const b = await h.device([role(123, '官服', { nickName: '已改名', gameId: null })]);
|
||||
assert.notEqual(a.cookie, b.cookie, 'the device logs in independently, not by copying a JWT');
|
||||
assert.equal(a.state.gamePlayerId, b.state.gamePlayerId, 'nickname and optional metadata do not select another identity');
|
||||
assert.equal(a.client.isOpen, true, 'login alone does not take over the active device');
|
||||
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
const result = await h.request(b, 'recovery');
|
||||
const item = result.body.recovery;
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.cache, /no-store/);
|
||||
assert.equal(item.code, room.code); assert.equal(item.inMatch, true); assert.equal(item.connected, true);
|
||||
assert.equal(Object.hasOwn(item, 'token'), false, 'the read-only summary exposes no reconnect credential');
|
||||
assert.equal(a.client.isOpen, true, 'querying a match is not resuming it');
|
||||
await h.enter(b, item.id);
|
||||
assert.equal((await a.client.closed).code, 4001, 'only successful explicit recovery replaces the old device');
|
||||
assert.equal(b.welcome.playerId, a.welcome.playerId);
|
||||
assert.equal(room.match, match);
|
||||
assert.equal((await b.client.waitFor('room.state')).code, room.code);
|
||||
assert.deepEqual(await b.client.waitFor('m.private'), before);
|
||||
assert.equal(h.server.registry.byToken(oldToken), null, 'the old device token was revoked');
|
||||
const stale = await h.connect(a.cookie, oldToken);
|
||||
assert.notEqual(stale.welcome.playerId, b.welcome.playerId, 'an old device cannot silently take the role back');
|
||||
assert.equal(b.client.isOpen, true);
|
||||
assert.equal((await stale.client.request({ t: 'g.ready' })).t, 'error');
|
||||
});
|
||||
|
||||
test('disconnected solo and co-op matches use the existing account-independent reconnect windows', async (t) => {
|
||||
for (const mode of ['solo', 'coop']) await t.test(mode, async (t) => {
|
||||
const h = await fixture(t), a = await h.device();
|
||||
const room = await h.start(a, mode), session = h.server.registry.byId(a.welcome.playerId);
|
||||
await h.disconnect(a);
|
||||
const at = session.disconnectedAt;
|
||||
assert.ok(at != null);
|
||||
assert.equal(h.server.registry.windowOf(session), mode === 'solo' ? 86_400_000 : 600_000);
|
||||
const b = await h.device();
|
||||
const item = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.equal(item.connected, false);
|
||||
assert.equal(session.disconnectedAt, at, 'looking at a recovery never renews its deadline');
|
||||
await h.enter(b, item.id);
|
||||
assert.equal((await b.client.waitFor('room.state')).code, room.code);
|
||||
assert.equal(session.disconnectedAt, null);
|
||||
assert.equal(session.connected, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('recovery never follows a supplied player, nickname, UID, region or foreign recovery selector', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), room = await h.start(a);
|
||||
const item = (await h.request(a, 'recovery')).body.recovery;
|
||||
const strangers = [await h.device([role('456')]), await h.device([role('123', 'B服')]),
|
||||
await h.device([role('123'), role('456', '官服', { isDefault: true })])];
|
||||
for (const b of strangers) {
|
||||
assert.equal((await h.request(b, 'recovery?player=' + a.welcome.playerId)).body.recovery, null);
|
||||
const result = await h.request(b, 'resume', { id: item.id, player: a.welcome.playerId,
|
||||
uid: '123', channelName: '官服', name: a.welcome.name });
|
||||
assert.equal(result.status, 409); assert.equal(result.body.code, 'resume_expired');
|
||||
const stolen = await h.connect(b.cookie, a.token);
|
||||
assert.notEqual(stolen.welcome.playerId, a.welcome.playerId);
|
||||
assert.equal(stolen.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
}
|
||||
const multi = strangers[2];
|
||||
assert.equal((await h.request(multi, 'role/select', { roleKey: roleKey(role('123')) })).status, 200);
|
||||
assert.equal((await h.request(multi, 'recovery')).body.recovery.code, room.code);
|
||||
const anonymous = await h.connect();
|
||||
assert.equal((await h.request(anonymous, 'recovery')).status, 401);
|
||||
assert.equal((await h.request(anonymous, 'resume', { id: item.id })).status, 401);
|
||||
for (const endpoint of ['enter', 'resume']) {
|
||||
assert.equal((await h.request(multi, endpoint, { id: item.id }, { origin: 'https://attacker.invalid' })).status, 403);
|
||||
}
|
||||
assert.equal(a.client.isOpen, true);
|
||||
});
|
||||
|
||||
test('a stale or expired recovery is refused without creating a session; later entry disposes the old seat first', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), room = await h.start(a);
|
||||
const session = h.server.registry.byId(a.welcome.playerId), item = (await h.request(a, 'recovery')).body.recovery;
|
||||
await h.disconnect(a);
|
||||
const at = session.disconnectedAt + h.server.registry.windowOf(session) + 1;
|
||||
h.server.registry.now = () => at; h.server.network.now = () => at;
|
||||
const b = await h.device();
|
||||
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
|
||||
const stale = await h.request(b, 'resume', { id: item.id });
|
||||
assert.equal(stale.status, 409); assert.equal(stale.body.code, 'resume_expired');
|
||||
assert.equal(h.server.registry.byId(session.playerId), session, 'a failed restore does not mint a fresh game');
|
||||
await h.enter(b);
|
||||
assert.notEqual(h.server.registry.byId(session.playerId), session);
|
||||
assert.equal(session.roomCode, null);
|
||||
assert.equal(room.disposed, true, 'the expired solo match is abandoned, not orphaned under the stable playerId');
|
||||
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
|
||||
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
});
|
||||
|
||||
test('a room that starts a later match invalidates the old selector without stealing its connected seat', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), b = await h.device();
|
||||
await h.enter(a);
|
||||
assert.equal((await a.client.request({ t: 'room.create', mode: 'solo', difficulty: 'NORMAL' })).t, 'ok');
|
||||
const first = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.equal(first.inMatch, false);
|
||||
assert.equal((await a.client.request({ t: 'room.start' })).t, 'ok');
|
||||
assert.equal((await h.request(b, 'resume', { id: first.id })).status, 409);
|
||||
const current = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.notEqual(current.id, first.id); assert.equal(current.code, first.code);
|
||||
assert.equal(a.client.isOpen, true);
|
||||
await h.enter(b, current.id);
|
||||
assert.equal((await a.client.closed).code, 4001);
|
||||
});
|
||||
|
||||
|
||||
test('stable account identities and normal fields fit every existing client message limit', () => {
|
||||
const playerId = accountPlayerId(role()), fieldId = 'n:' + playerId;
|
||||
const perPlayer = { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, unitsEnd: [] };
|
||||
for (const message of [
|
||||
{ t: 'room.kick', seat: 1, playerId },
|
||||
{ t: 'room.removeSpectator', playerId },
|
||||
{ t: 'g.watch', fieldId, playerId },
|
||||
{ t: 'b.result', battleId: 'match.1', result: { reason: 'cleared', time: 1, perPlayer: { [playerId]: perPlayer } } },
|
||||
]) assert.equal(validateC2S(message), null, message.t + ' accepts the account identity');
|
||||
assert.equal(playerId, accountPlayerId(role(123, ' 官服 ', { nickName: '新名字', gameId: 2 })));
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createQrLogin } from '../../public/js/skland/scan.js';
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||
function fakeClock() {
|
||||
let time = 0, id = 0;
|
||||
const tasks = new Map();
|
||||
return {
|
||||
now: () => time,
|
||||
timers: {
|
||||
setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; },
|
||||
clearTimeout: (key) => tasks.delete(key),
|
||||
},
|
||||
count: () => tasks.size,
|
||||
async advance(ms) {
|
||||
const target = time + ms;
|
||||
for (;;) {
|
||||
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > target) break;
|
||||
time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush();
|
||||
}
|
||||
time = target; await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) {
|
||||
const clock = fakeClock(), calls = [], images = [], pending = [], errors = [];
|
||||
let confirmed = 0, starts = 0;
|
||||
const scan = createQrLogin({ ...clock,
|
||||
request: async (path) => {
|
||||
calls.push({ path, at: clock.now() });
|
||||
return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll();
|
||||
},
|
||||
encode: async (url) => `image:${url}`, onQr: (value) => images.push(value),
|
||||
onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); },
|
||||
onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); },
|
||||
});
|
||||
return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed };
|
||||
}
|
||||
|
||||
test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => {
|
||||
const f = fixture(); f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.deepEqual(f.images, ['image:QR1']);
|
||||
await f.clock.advance(9999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]);
|
||||
await f.clock.advance(10000); assert.equal(f.starts(), 3);
|
||||
f.scan.stop(); assert.equal(f.clock.count(), 0);
|
||||
});
|
||||
|
||||
test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => {
|
||||
if (++attempts === 1) throw new Error('status offline');
|
||||
});
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline');
|
||||
await f.clock.advance(10000);
|
||||
assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000);
|
||||
assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval');
|
||||
f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too');
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => {
|
||||
let f;
|
||||
f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 }));
|
||||
f.scan.setActive(true); await f.clock.advance(10000);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
f.scan.setActive(false); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(50000); assert.equal(f.calls.length, 1);
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('confirmation wins over a manual refresh queued during an in-flight poll', async () => {
|
||||
let resolvePoll;
|
||||
const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll');
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(120000); assert.equal(f.starts(), 1);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => {
|
||||
let resolvePoll;
|
||||
const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop();
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0);
|
||||
|
||||
const retry = fixture(() => { throw new Error('offline'); });
|
||||
retry.scan.setActive(true); await retry.clock.advance(2000);
|
||||
assert.equal(retry.errors[0].message, 'offline');
|
||||
await retry.clock.advance(9999); assert.equal(retry.starts(), 1);
|
||||
await retry.clock.advance(1); assert.equal(retry.starts(), 2);
|
||||
retry.scan.stop();
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { WebSocket } from 'ws';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { ACCOUNT_COOKIE, LOGIN_TTL } from '../../server/skland/auth.js';
|
||||
import { roleKey } from '../../shared/accountIdentity.js';
|
||||
import { PROTOCOL_VERSION } from '../../shared/constants.js';
|
||||
|
||||
const role = (uid = '123', channelName = '官服', gameId = 1) => ({ uid, channelName, gameId, nickName: '同名博士' });
|
||||
async function request(server, endpoint, { token, cookie, body, headers = {} } = {}) {
|
||||
const response = await fetch(server.url + endpoint, { method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}),
|
||||
'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: response.status, body: await response.json(), setCookie: response.headers.get('set-cookie') };
|
||||
}
|
||||
async function login(server, roles = [role()]) {
|
||||
const session = server.registry.create('temporary');
|
||||
server.skland._store.update('@session/' + session.playerId, { status: 'bound', nickname: roles[0].nickName,
|
||||
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
|
||||
const result = await request(server, '/skland/session/status', { token: session.token });
|
||||
assert.equal(result.status, 200);
|
||||
return { token: session.token, cookie: result.setCookie.split(';')[0], state: result.body, setCookie: result.setCookie };
|
||||
}
|
||||
async function socket(server, account = {}) {
|
||||
const ws = new WebSocket(server.url.replace('http:', 'ws:') + '/ws', { headers: account.cookie ? { cookie: account.cookie } : {} });
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
let rid = 0;
|
||||
const send = (message) => new Promise((resolve, reject) => {
|
||||
const id = ++rid;
|
||||
const timer = setTimeout(() => { ws.off('message', receive); reject(new Error('socket reply timeout')); }, 3000);
|
||||
const receive = (data) => { const reply = JSON.parse(data);
|
||||
if (reply.rid === id) { clearTimeout(timer); ws.off('message', receive); resolve(reply); } };
|
||||
ws.on('message', receive); ws.send(JSON.stringify({ ...message, rid: id }));
|
||||
});
|
||||
const entry = account.cookie && await request(server, '/skland/session/enter', { ...account, body: {} });
|
||||
const welcome = await send({ t: 'hello', name: '伪造昵称', token: entry?.body.token || account.token, version: PROTOCOL_VERSION });
|
||||
return { ws, send, welcome };
|
||||
}
|
||||
|
||||
test('account HTTP rejects forged JWT/roles and cross-origin access; logout revokes replay and diagnostics are closed', async () => {
|
||||
const authSecret = 'test-only-key-never-a-real-deployment-123456789';
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', authSecret } });
|
||||
try {
|
||||
const a = await login(server), b = await login(server, [role('456')]);
|
||||
assert.match(a.setCookie, /HttpOnly/); assert.match(a.setCookie, /SameSite=Strict/); assert.match(a.setCookie, /Max-Age=2592000/);
|
||||
assert.equal((await request(server, '/skland/session/status', { cookie: a.cookie })).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status?token=' + a.token, { cookie: a.cookie })).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status?player=' + encodeURIComponent(b.state.playerId), a)).body.playerId, a.state.playerId);
|
||||
assert.equal((await request(server, '/skland/session/role/select', { ...a, body: { roleKey: roleKey(role('456')) } })).status, 403);
|
||||
for (const headers of [{ origin: 'https://attacker.invalid' }, { 'sec-fetch-site': 'cross-site' }]) {
|
||||
assert.equal((await request(server, '/skland/session/status', { ...a, headers })).status, 403);
|
||||
}
|
||||
for (const url of ['/skland/status?player=' + a.state.playerId, '/skland/roles?player=someone']) {
|
||||
assert.equal((await request(server, url, a)).status, 403);
|
||||
}
|
||||
const jwt = a.cookie.slice(ACCOUNT_COOKIE.length + 1).split('.'), claims = JSON.parse(Buffer.from(jwt[1], 'base64url'));
|
||||
assert.ok(!JSON.stringify(claims).includes('access_token'));
|
||||
for (const forged of [
|
||||
[jwt[0], Buffer.from(JSON.stringify({ ...claims, sub: b.state.playerId })).toString('base64url'), jwt[2]],
|
||||
[Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), jwt[1], jwt[2]],
|
||||
]) assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${forged.join('.')}` })).status, 401);
|
||||
for (const invalid of [{ iss: 'another-server' }, { aud: 'another-app' }, { iat: claims.exp + 1 }, { role: roleKey(role('456')) }]) {
|
||||
const body = Buffer.from(JSON.stringify({ ...claims, ...invalid })).toString('base64url');
|
||||
const input = jwt[0] + '.' + body;
|
||||
const signed = input + '.' + createHmac('sha256', authSecret).update(input).digest('base64url');
|
||||
assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${signed}` })).status, 401);
|
||||
}
|
||||
await request(server, '/skland/session/unbind', { ...b, body: { player: a.state.playerId } });
|
||||
assert.equal((await request(server, '/skland/session/status', a)).body.bound, true, 'B only unbinds B');
|
||||
await request(server, '/skland/session/unbind', { ...a, body: {} });
|
||||
assert.equal((await request(server, '/skland/session/status', a)).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status', { token: a.token })).body.bound, false, 'old transport cannot reissue JWT');
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('late provider replies cannot restore credentials after unbind or replace a newer scan', async () => {
|
||||
let resolveScan, resolveBinding, announceBinding, scans = 0, tokenExchanges = 0;
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [role()] }] };
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client: {
|
||||
genScan: async () => ({ scanId: String(++scans), scanUrl: 'test:qr' }),
|
||||
checkScan: async () => new Promise((resolve) => { resolveScan = resolve; }),
|
||||
tokenByScanCode: async () => { tokenExchanges++; return { token: 'TEST' }; },
|
||||
tokenByPhonePassword: async () => ({ token: 'TEST' }),
|
||||
getAccountBinding: async () => { announceBinding(); return new Promise((resolve) => { resolveBinding = resolve; }); },
|
||||
} } });
|
||||
try {
|
||||
const service = server.skland;
|
||||
for (const action of [() => service.unbind('pending'), () => service.startScan('pending')]) {
|
||||
await service.startScan('pending');
|
||||
const pending = service.pollScan('pending');
|
||||
await action(); resolveScan({ state: 'confirmed', scanCode: 'OLD' });
|
||||
assert.equal((await pending).state, 'none');
|
||||
assert.equal(service.status('pending').bound, false);
|
||||
}
|
||||
assert.equal(tokenExchanges, 0);
|
||||
const reachedBinding = new Promise((resolve) => { announceBinding = resolve; });
|
||||
const password = service.loginByPhonePassword('pending', '13800000000', 'test');
|
||||
await reachedBinding; service.unbind('pending'); resolveBinding(binding);
|
||||
assert.equal((await password).ok, false);
|
||||
assert.equal(service._store.get('pending').access_token, null);
|
||||
assert.equal(service.status('pending').bound, false);
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('JWT and bindings survive restart, expire after 30 days, and credential files cannot be downloaded', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-account-'));
|
||||
let at = Date.now(), server;
|
||||
const options = { port: 0, host: '127.0.0.1', quiet: true, publicDir: dir,
|
||||
skland: { storeFile: path.join(dir, 'private.db'), authNow: () => at } };
|
||||
try {
|
||||
server = await startServer(options); const a = await login(server);
|
||||
for (const name of ['private.db', 'PRIVATE.DB', 'private.db-wal', 'private.db-shm']) assert.equal((await fetch(server.url + '/' + name)).status, 404);
|
||||
try { fs.symlinkSync(path.join(dir, 'private.db'), path.join(dir, 'alias.txt'));
|
||||
assert.equal((await fetch(server.url + '/alias.txt')).status, 404);
|
||||
} catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; }
|
||||
await server.close(); server = await startServer(options);
|
||||
const fresh = server.registry.create('new transport');
|
||||
const restored = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
||||
assert.equal(restored.body.gamePlayerId, a.state.gamePlayerId); assert.equal(restored.body.playerId, a.state.playerId);
|
||||
assert.equal(restored.body.bound, true);
|
||||
at += (LOGIN_TTL + 1) * 1000;
|
||||
const expired = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
||||
assert.equal(expired.status, 401); assert.match(expired.setCookie, /Max-Age=0/);
|
||||
} finally { await server?.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('game identity uses UID plus region, permits duplicate nicknames, and blocks anonymous game requests', async () => {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:' } });
|
||||
const sockets = [];
|
||||
try {
|
||||
const accounts = [await login(server), await login(server, [role('456')]), await login(server, [role('123', 'B服', 2)])];
|
||||
for (const account of accounts) sockets.push(await socket(server, account));
|
||||
const welcomes = sockets.map((client) => client.welcome);
|
||||
assert.ok(welcomes.every((welcome) => welcome.name === '同名博士'));
|
||||
assert.equal(new Set(welcomes.map((welcome) => welcome.playerId)).size, 3);
|
||||
assert.equal((await sockets[0].send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
|
||||
const anonymous = await socket(server, { token: welcomes[0].token }); sockets.push(anonymous);
|
||||
assert.notEqual(anonymous.welcome.playerId, welcomes[0].playerId);
|
||||
assert.equal((await anonymous.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
||||
assert.equal((await request(server, '/skland/session/status', { token: welcomes[0].token, cookie: accounts[1].cookie })).status, 403);
|
||||
const reconnect = await socket(server, accounts[1]); sockets.push(reconnect);
|
||||
assert.equal(reconnect.welcome.playerId, welcomes[1].playerId);
|
||||
await request(server, '/skland/session/unbind', { token: reconnect.welcome.token, cookie: accounts[1].cookie, body: {} });
|
||||
assert.equal((await reconnect.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
||||
} finally { for (const client of sockets) client.ws.terminate(); await server.close(); }
|
||||
});
|
||||
|
||||
test('a disabled server keeps the account surface off', async () => {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try { assert.equal((await fetch(server.url + '/skland/session/status')).status, 404); }
|
||||
finally { await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// The per-player credential store (server/skland/store.js): node:sqlite in WAL mode, one row per player.
|
||||
//
|
||||
// The store is what survives a restart, so the round-trip of every column and the 解绑 semantics are pinned here.
|
||||
// The file-backed cases also assert the WAL side files exist, which is what makes the SQLite path a "high-performance
|
||||
// local database" rather than a map that happens to be persisted.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createSklandStore } from '../../server/skland/store.js';
|
||||
|
||||
const tempDir = () => mkdtempSync(join(tmpdir(), 'sp-skland-'));
|
||||
|
||||
test('a player row starts unbound and ensure() is idempotent', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.ensure('p1');
|
||||
store.ensure('p1');
|
||||
const row = store.get('p1');
|
||||
assert.equal(store.count(), 1);
|
||||
assert.equal(row.player_id, 'p1');
|
||||
assert.equal(row.status, 'unbound');
|
||||
assert.equal(row.cred, null);
|
||||
assert.equal(row.binding, null);
|
||||
assert.ok(row.created_at > 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('get() of an unknown player is null; update() creates the row', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
assert.equal(store.get('nobody'), null);
|
||||
store.update('p2', { status: 'bound', cred: 'C', cred_token: 'CT' });
|
||||
assert.equal(store.get('p2').status, 'bound');
|
||||
assert.equal(store.count(), 1, 'update() creates exactly one row');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('the whole login chain round-trips, including the binding JSON', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] };
|
||||
store.update('p1', {
|
||||
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
|
||||
user_id: 'U', d_id: 'D', nickname: '博士', binding, status: 'bound',
|
||||
});
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.access_token, 'AT');
|
||||
assert.equal(row.device_token, 'DT');
|
||||
assert.equal(row.hg_id, 'HG');
|
||||
assert.equal(row.cred, 'C');
|
||||
assert.equal(row.cred_token, 'CT');
|
||||
assert.equal(row.d_id, 'D');
|
||||
assert.equal(row.nickname, '博士');
|
||||
assert.deepEqual(row.binding, binding, 'binding is stored as JSON and parsed back');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('update() merges only the named fields', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', { cred: 'C', status: 'bound', nickname: 'N' });
|
||||
store.update('p1', { cred_token: 'CT' });
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.cred, 'C');
|
||||
assert.equal(row.nickname, 'N');
|
||||
assert.equal(row.status, 'bound');
|
||||
assert.equal(row.cred_token, 'CT');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('unbind() forgets every credential and the binding source but keeps the row', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', {
|
||||
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
|
||||
user_id: 'U', d_id: 'D', nickname: 'N', binding: { list: [] }, binding_source: 'hypergryph', status: 'bound',
|
||||
});
|
||||
store.unbind('p1');
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.status, 'unbound');
|
||||
for (const field of ['access_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname']) {
|
||||
assert.equal(row[field], null, `${field} is cleared`);
|
||||
}
|
||||
assert.equal(row.binding, null);
|
||||
assert.equal(row.device_token, null, 'the device token is cleared with the rest of the credentials');
|
||||
assert.equal(row.binding_source, 'skland', 'unbound rows return to the legacy default');
|
||||
assert.equal(row.player_id, 'p1', 'the row stays so a rebind is cheap');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a legacy database migrates without losing QR bindings and persists the official account source', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'legacy.db');
|
||||
try {
|
||||
const initial = createSklandStore({ file });
|
||||
initial.update('old-player', { cred: 'OLD-CRED', device_token: 'OLD-DEVICE', status: 'bound', binding: { list: [] } });
|
||||
initial.close();
|
||||
const legacy = new DatabaseSync(file);
|
||||
legacy.exec('ALTER TABLE skland_players DROP COLUMN binding_source');
|
||||
legacy.close();
|
||||
|
||||
const migrated = createSklandStore({ file });
|
||||
assert.equal(migrated.get('old-player').cred, 'OLD-CRED');
|
||||
assert.equal(migrated.get('old-player').device_token, 'OLD-DEVICE');
|
||||
assert.equal(migrated.get('old-player').binding_source, 'skland');
|
||||
migrated.update('password-player', { access_token: 'HG-TOKEN', binding_source: 'hypergryph', status: 'bound', binding: { list: [] } });
|
||||
migrated.close();
|
||||
const reopened = createSklandStore({ file });
|
||||
assert.equal(reopened.get('password-player').binding_source, 'hypergryph');
|
||||
assert.equal(reopened.get('password-player').access_token, 'HG-TOKEN');
|
||||
assert.equal(reopened.get('password-player').cred, null);
|
||||
assert.equal(reopened.get('old-player').status, 'bound');
|
||||
reopened.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('remove() drops the row entirely', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', { status: 'bound' });
|
||||
store.remove('p1');
|
||||
assert.equal(store.get('p1'), null);
|
||||
assert.equal(store.count(), 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('list() returns every row, newest update first', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('a', { status: 'bound' });
|
||||
store.update('b', { status: 'unbound' });
|
||||
const ids = store.list().map((r) => r.player_id).sort();
|
||||
assert.deepEqual(ids, ['a', 'b']);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a file-backed store persists across instances and uses WAL', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'skland.db');
|
||||
try {
|
||||
const first = createSklandStore({ file });
|
||||
first.update('p1', { cred: 'C', status: 'bound', binding: { list: [{ appCode: 'arknights', bindingList: [] }] } });
|
||||
assert.equal(first.journalMode(), 'wal', 'WAL mode is on (readers do not block the writer)');
|
||||
first.close();
|
||||
assert.ok(existsSync(file), 'the database file exists on disk');
|
||||
|
||||
const second = createSklandStore({ file });
|
||||
const row = second.get('p1');
|
||||
assert.equal(row.cred, 'C', 'the credentials survive a restart');
|
||||
assert.equal(row.status, 'bound');
|
||||
assert.deepEqual(row.binding.list[0].appCode, 'arknights');
|
||||
second.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the parent directory of the database file is created', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'nested', 'deeper', 'skland.db');
|
||||
try {
|
||||
const store = createSklandStore({ file });
|
||||
store.update('p1', { status: 'bound' });
|
||||
assert.ok(existsSync(file));
|
||||
store.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -40,6 +40,9 @@ export const VENDOR_FILES = Object.freeze([
|
||||
['node_modules/htm/dist/htm.module.js', 'htm.module.js'],
|
||||
['node_modules/three/build/three.core.js', 'three.core.js', true],
|
||||
['node_modules/three/build/three.module.js', 'three.module.js', true],
|
||||
// the 森空岛 binding panel (public/skland.html) renders the scan login QR client-side; the UMD build sets a global
|
||||
// `qrcode(typeNumber, errorCorrectionLevel)` when loaded as a classic script, so this one is not an ES module.
|
||||
['node_modules/qrcode-generator/dist/qrcode.js', 'qrcode.js'],
|
||||
]);
|
||||
|
||||
export function vendor({ log = console.log, warn = console.warn } = {}) {
|
||||
|
||||
Reference in new issue
Block a user