feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(B,游戏零改动): - POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩 - 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听) - claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁 - PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET) - web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4) 键盘双端(A,游戏代码零改动): - gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault - settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始) - 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示 - live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言 回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过 P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
This commit is contained in:
+103
-41
@@ -2,6 +2,11 @@
|
||||
|
||||
Node 零依赖(仅用内置模块)的静态文件 + 用户系统 API 服务器。
|
||||
|
||||
P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.md` 方案 B 与
|
||||
`03-data-sync.md`):首次访问 0 输入 0 点击自动建匿名档案(自动名「玩家一/二/…」),
|
||||
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
|
||||
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -11,78 +16,126 @@ tools/node/bin/node web/server/server.js
|
||||
# 或用封装脚本(打印局域网地址)
|
||||
bash web/serve.sh
|
||||
|
||||
# 自定义端口(默认 8000)
|
||||
PORT=8001 tools/node/bin/node web/server/server.js
|
||||
# 自定义端口(默认 8000)/ 数据目录(默认 web/server/data/,测试用)
|
||||
PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
|
||||
```
|
||||
|
||||
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`)
|
||||
- 数据目录:`web/server/data/`(首次启动自动创建)
|
||||
- 静态根目录:`web/`(`/` 即游戏大厅 `index.html`);`web/server/` 目录一律 404(内含 token 与 PIN 哈希)
|
||||
- 数据目录:自动创建;检测到 v1 旧文件(`users.json`/`sessions.json`)时启动即单向迁移(原文件保留不动)
|
||||
|
||||
## ⚠ 安全说明
|
||||
|
||||
这是**家庭局域网玩具级认证**,请勿暴露到公网:
|
||||
家庭局域网级认证(比之前加固了一档,但仍**请勿暴露公网**):
|
||||
|
||||
- PIN 以**明文**存储在 `data/users.json`;
|
||||
- token 永不过期,明文存于 `data/sessions.json`;
|
||||
- HTTP 明文传输,无 TLS、无防爆破、无速率限制。
|
||||
|
||||
用途仅限于同一 WiFi 下的家庭成员各自存档。公网 `game.xiangsilian.com` **不跑本服务**,
|
||||
只托管静态文件(见 `docs/deploy.md`)。要公网开用户系统请换正经认证方案。
|
||||
- PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文;
|
||||
- token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长;
|
||||
- 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖);
|
||||
- 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 +
|
||||
防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。
|
||||
|
||||
## API
|
||||
|
||||
所有 API 请求/响应均为 JSON(`Content-Type: application/json`)。
|
||||
错误响应统一为 `{ "error": "描述" }` + 相应 HTTP 状态码。
|
||||
需要鉴权的端点用请求头 `Authorization: Bearer <token>`。
|
||||
|
||||
### POST /api/register
|
||||
### POST /api/device —— 匿名自动建档(核心)
|
||||
|
||||
注册新用户。
|
||||
无参(或带已固化设备的旧 token 重附着)。
|
||||
|
||||
- 请求:`{ "username": "ming", "pin": "1234" }`
|
||||
- `username`:1~24 位字母/数字/中文/`_`/`-`
|
||||
- `pin`:4~6 位数字
|
||||
- 成功:`201 { "ok": true, "username": "ming" }`
|
||||
- 失败:`400`(格式非法)/ `409 { "error": "用户名已被注册" }`
|
||||
- 请求:`{}` 或 `{ "token": "<本机已有的 token>" }`
|
||||
- 成功(新建):`201 { "ok": true, "profileId": "p_a1b2c3d4e5f6", "token": "<48 位随机 hex>", "name": "玩家一", "claimed": false }`
|
||||
- `name` 由服务器按档案序号自动命名:玩家一、玩家二、玩家三……(可听词表)
|
||||
- `token` 即设备身份,客户端存 localStorage,之后一切鉴权用它
|
||||
- 成功(重附着):`200 { ..., "attached": true }`——幂等,不新建档案、不占频控额度
|
||||
- 失败:`429`(同 IP 当日新建数超限)
|
||||
|
||||
### POST /api/login
|
||||
大厅(`web/index.html`)首访静默调用本端点建档;纯静态公网形态(无 API)自动降级为
|
||||
本地档案(`web/profiles.js` 同一套逻辑,token 为 null)。
|
||||
|
||||
登录,返回 token。
|
||||
### GET /api/profile —— 当前档案信息
|
||||
|
||||
- 请求:`{ "username": "ming", "pin": "1234" }`
|
||||
- 成功:`200 { "ok": true, "token": "<48 位随机 hex>", "username": "ming" }`
|
||||
- 失败:`401 { "error": "用户名或 PIN 错误" }`
|
||||
- 请求头:Bearer
|
||||
- 成功:`200 { "profileId": "p_…", "name": "玩家一", "claimed": false, "createdAt": "…" }`
|
||||
|
||||
token 存内存并落盘 `data/sessions.json`(服务器重启后会话仍有效)。
|
||||
### PUT /api/profile —— 改名
|
||||
|
||||
### GET /api/progress?game=xxx
|
||||
- 请求头:Bearer;请求:`{ "name": "爸爸" }`(1~24 位字母/数字/中文/`_`/`-`)
|
||||
- 成功:`200 { "ok": true, "name": "爸爸" }`
|
||||
- 失败:`400`(格式非法)/ `401` / `409`(该名字已被其他**已认领**档案占用;匿名档案重名不限制)
|
||||
|
||||
读取某游戏的进度数据。
|
||||
### POST /api/claim —— 认领(命名 + 设 PIN)/ 换设备登录(合并)
|
||||
|
||||
- 请求头:`Authorization: Bearer <token>`
|
||||
- 成功:`200 { "game": "escape", "data": { ... } }`(无存档时 `data` 为 `null`)
|
||||
- 失败:`401`(token 缺失/无效)/ `400`(缺少 game 参数)
|
||||
携带当前设备 token(Bearer 或 body.token)。请求:`{ "name"?, "pin"? }`,
|
||||
`pin` 为 4~6 位数字。三种语义:
|
||||
|
||||
### PUT /api/progress
|
||||
1. **只设 PIN**(`{pin}`):当前档案原地升级为可跨设备登录(`claimed: true`)。
|
||||
2. **命名(±PIN)**(`{name, pin?}`,且该名字尚无已认领档案):当前匿名档案原地
|
||||
命名(+设 PIN),进度零迁移——Firebase「匿名账号升级不换 UID」模式。
|
||||
3. **登录/合并**(`{name, pin}`,且该名字已有已认领档案):验 PIN 后把**当前匿名
|
||||
档案**的进度按桶 LWW(updatedAt 新者胜,同值比 rev)合并进目标档案,当前 token
|
||||
改绑目标档案,匿名档案删除。返回 `{ "ok": true, "merged": true, "mergedGames": 3, "profileId": "…", "name": "爸爸", "claimed": true }`。
|
||||
|
||||
写入某游戏的进度数据(覆盖该 game 键)。
|
||||
- 失败:`400`(格式)/ `401`(PIN 缺失或不正确)/ `409`(当前档案已设 PIN,需先建新匿名档案)
|
||||
|
||||
- 请求头:`Authorization: Bearer <token>`
|
||||
- 请求:`{ "game": "escape", "data": { "summary": "已逃脱 2 次 / 最快 3:25", ... } }`
|
||||
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定用 `_settings` 存用户设置
|
||||
- `data`:任意 JSON 值;大厅卡片会展示 `data.summary` 字符串(若存在)
|
||||
- 成功:`200 { "ok": true, "game": "escape" }`
|
||||
- 失败:`401` / `400`
|
||||
### POST /api/login —— 旧客户端兼容
|
||||
|
||||
- 请求:`{ "username": "uuu", "pin": "0858" }`
|
||||
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu" }`
|
||||
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
|
||||
|
||||
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
|
||||
|
||||
- 请求:`{ "username": "ming", "pin": "1234" }` → `201 { "ok": true, "username": "ming", "profileId": "…", "token": "…" }`
|
||||
- 直接创建已认领档案(不合并当前匿名档案的进度)。
|
||||
|
||||
### POST /api/logout —— 吊销当前 token
|
||||
|
||||
- 请求头:Bearer;成功:`200 { "ok": true }`
|
||||
|
||||
### GET /api/progress —— 读进度
|
||||
|
||||
- 单桶(旧形状,游戏兼容,如 radio 直接读 `data`):
|
||||
`GET /api/progress?game=rpg` → `200 { "game": "rpg", "data": { … }, "updatedAt": "…", "rev": 2 }`
|
||||
(无存档时 `data` 为 `null`)
|
||||
- 全桶(大厅用,一次拉回替代旧的 8 次串行 GET):
|
||||
`GET /api/progress` → `200 { "profileId": "…", "name": "…", "games": { "rpg": { "updatedAt": "…", "rev": 2, "data": { … } }, … } }`
|
||||
- 失败:`401` / `400`(game 名非法)
|
||||
|
||||
### PUT /api/progress —— 写进度(baseUpdatedAt LWW 守卫)
|
||||
|
||||
- 请求:`{ "game": "rpg", "data": { … }, "baseUpdatedAt": "<上次已知服务器桶 updatedAt>" }`
|
||||
- `game`:1~32 位字母/数字/`_`/`-`;大厅约定 `_settings` 桶存用户设置(玩家级)
|
||||
- `data`:任意 JSON 值原样透传(宽容收);大厅卡片展示 `data.summary`(客户端约定)
|
||||
- `baseUpdatedAt` **可省略**:视为 legacy 客户端无条件放行(服务器 console 警告)——
|
||||
各游戏存量 PUT 不带此字段,兼容不破
|
||||
- 成功:`200 { "ok": true, "game": "rpg", "updatedAt": "<服务器时间>", "rev": 3 }`
|
||||
- `updatedAt` 由**服务器时钟**打点(不信任客户端墙钟)
|
||||
- `409`(LWW 冲突,携带的 baseUpdatedAt 落后于服务器桶):
|
||||
`{ "error": "…", "game": "rpg", "server": { "updatedAt": "…", "rev": 4, "data": { … } } }`
|
||||
——客户端应比较 `data.updatedAt` 择优后带新基线重试(大厅已实现)
|
||||
|
||||
## 数据格式
|
||||
|
||||
```
|
||||
web/server/data/
|
||||
├── users.json { "<username>": "<pin>" }
|
||||
├── sessions.json { "<token>": "<username>" }
|
||||
└── <username>.json { "<game>": <data>, ... } 一用户一文件
|
||||
├── profiles.json { "seq": 3, "profiles": { "<pid>": { "name", "pinHash", "createdAt" } } }
|
||||
├── tokens.json { "<token>": { "profileId", "createdAt", "lastSeen", "expiresAt" } }
|
||||
├── profiles/
|
||||
│ └── <pid>.json { "id": "<pid>", "games": { "<game>": { "updatedAt", "rev", "data" } } }
|
||||
├── users.json (v1 遗留,迁移源,保留不动)
|
||||
├── sessions.json (v1 遗留,迁移源,保留不动)
|
||||
└── uuu.json (v1 遗留进度,已迁至 profiles/uuu.json,保留不动)
|
||||
```
|
||||
|
||||
- `pinHash`:`null`(匿名档案)或 `"scrypt$16384$8$1$<salt-hex>$<hash-hex>"`
|
||||
- 一档案一进度文件,按游戏分桶;`rev` 单调递增,作 updatedAt 同值的破平局保险
|
||||
- v1 → v2 启动迁移(单向,幂等跳过):`users.json` → `profiles.json`(pid=旧用户名,
|
||||
PIN 立即哈希)、`sessions.json` → `tokens.json`(补 180 天过期)、
|
||||
`<username>.json` → `profiles/<username>.json`(各桶补 `updatedAt`/`rev`)
|
||||
|
||||
所有写入按文件串行排队(Promise 链),tmp + rename 原子落盘,并发请求不会写坏文件。
|
||||
**单实例约束**:两个 server.js 进程共享同一 data/ 会绕过写队列。
|
||||
|
||||
## 静态服务
|
||||
|
||||
@@ -91,4 +144,13 @@ web/server/data/
|
||||
- 目录自动解析 `index.html`;
|
||||
- 支持简化版 HTTP Range(单段 `bytes=start-end` / `start-` / `-suffix`),
|
||||
返回 206 + `Content-Range`,供音频元素拖动进度;
|
||||
- 路径穿越防护:请求路径限制在 `web/` 内。
|
||||
- 路径穿越防护:请求路径限制在 `web/` 内;`web/server/` 目录(token/PIN 哈希/进度数据)一律 404。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖)
|
||||
```
|
||||
|
||||
覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
|
||||
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H4)。
|
||||
|
||||
+514
-66
@@ -1,25 +1,41 @@
|
||||
/**
|
||||
* server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。
|
||||
*
|
||||
* 功能:
|
||||
* 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。
|
||||
* P0-B 无感用户系统(research/platform/01-identity.md 方案 B + 03-data-sync.md §3):
|
||||
*
|
||||
* 1. 静态托管 web/ 根目录(正确 MIME,简化版 Range 供音频拖动)。
|
||||
* web/server/ 目录(含 data/ 的 token 与 PIN 哈希)一律 404,不对外提供。
|
||||
*
|
||||
* 2. JSON API:
|
||||
* - POST /api/register {username, pin} 注册(用户名 + 4~6 位数字 PIN)
|
||||
* - POST /api/login {username, pin} 登录,返回 token(随机 hex,
|
||||
* 内存 + 落盘 data/sessions.json)
|
||||
* - GET /api/progress?game=xxx 读进度(需 Authorization: Bearer token)
|
||||
* - PUT /api/progress {game, data} 写进度(同上)
|
||||
* - POST /api/device {token?} 匿名自动建档 → {profileId, token, name}(自动名
|
||||
* 「玩家一/二/…」);带有效 token 时为重附着(幂等)
|
||||
* - GET /api/profile 当前档案信息(Bearer)
|
||||
* - PUT /api/profile {name} 改名(Bearer)
|
||||
* - POST /api/claim {name?, pin?} 认领:给当前档案命名和/或设 PIN(scrypt 哈希)。
|
||||
* 若名字+PIN 已有档案 → 验 PIN 后把当前匿名档案的
|
||||
* 进度按桶 LWW 合并进目标档案并改绑 token(换设备登录)
|
||||
* - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录)
|
||||
* - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim)
|
||||
* - POST /api/logout 吊销当前 token(Bearer)
|
||||
* - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容)
|
||||
* - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2)
|
||||
* - PUT /api/progress {game, data, baseUpdatedAt?}
|
||||
* 写桶。baseUpdatedAt 与服务器桶不符 → 409 + 服务器桶
|
||||
* (LWW 冲突,客户端择优重试);缺省视为 legacy 放行
|
||||
* (console 警告——各游戏存量 PUT 不带此字段)。
|
||||
*
|
||||
* 数据落盘(data/ 目录自动创建):
|
||||
* data/users.json { username: pin }
|
||||
* data/sessions.json { token: username }
|
||||
* data/<username>.json { <game>: <data> } 进度,一用户一文件
|
||||
* 并发写入:按文件串行 Promise 队列。
|
||||
* 3. 数据落盘(data/ 目录自动创建;env SG_DATA_DIR 可覆盖,供测试):
|
||||
* data/profiles.json { seq, profiles: { <pid>: {name, pinHash, createdAt} } }
|
||||
* data/tokens.json { <token>: {profileId, createdAt, lastSeen, expiresAt} }
|
||||
* data/profiles/<pid>.json { id, games: { <game>: {updatedAt, rev, data} } }(桶级包装)
|
||||
* v1 兼容迁移(启动时单向,原文件保留不动):
|
||||
* users.json {username: pin明文} → profiles.json(PIN 立即 scrypt 哈希,pid=旧用户名)
|
||||
* sessions.json {token: username} → tokens.json(补 180 天滑动过期)
|
||||
* <username>.json {game: data} → profiles/<username>.json(每桶补 updatedAt/rev)
|
||||
*
|
||||
* 启动:node web/server/server.js (端口 env PORT,默认 8000)
|
||||
*
|
||||
* ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、
|
||||
* HTTP 明文传输。请勿暴露到公网。详见 web/server/README.md。
|
||||
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
|
||||
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
|
||||
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
@@ -29,10 +45,19 @@ const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8000;
|
||||
const ROOT = path.resolve(__dirname, '..'); // web/
|
||||
const DATA_DIR = path.join(__dirname, 'data'); // web/server/data/
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
||||
const ROOT = path.resolve(__dirname, '..'); // web/
|
||||
const DATA_DIR = process.env.SG_DATA_DIR
|
||||
? path.resolve(process.env.SG_DATA_DIR)
|
||||
: path.join(__dirname, 'data'); // web/server/data/
|
||||
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
|
||||
const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
|
||||
const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json'); // v1 兼容
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); // v1 兼容
|
||||
|
||||
const DEVICE_LIMIT = Number(process.env.SG_DEVICE_LIMIT) || 10; // per-IP 每日匿名建档上限
|
||||
const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
|
||||
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -57,13 +82,14 @@ const MIME = {
|
||||
|
||||
const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
|
||||
const PIN_RE = /^\d{4,6}$/;
|
||||
const TOKEN_RE = /^[0-9a-f]{16,128}$/i;
|
||||
|
||||
// ---------------- 状态 ----------------
|
||||
|
||||
/** @type {Record<string, string>} username -> pin */
|
||||
let users = {};
|
||||
/** @type {Record<string, string>} token -> username */
|
||||
let sessions = {};
|
||||
/** @type {{ seq: number, profiles: Record<string, {name: string, pinHash: string|null, createdAt: string}> }} */
|
||||
let profileIndex = { seq: 0, profiles: {} };
|
||||
/** @type {Record<string, {profileId: string, createdAt: string, lastSeen: string, expiresAt: string}>} */
|
||||
let tokens = {};
|
||||
|
||||
/** 按文件串行的写入队列:file -> Promise 链尾 */
|
||||
const writeQueues = new Map();
|
||||
@@ -92,9 +118,169 @@ function writeJson(file, obj) {
|
||||
});
|
||||
}
|
||||
|
||||
function progressFile(username) {
|
||||
// username 已被 USERNAME_RE 限制为安全字符,可直接作文件名
|
||||
return path.join(DATA_DIR, `${username}.json`);
|
||||
// ---------------- PIN:scrypt 哈希(node:crypto 内置,零依赖) ----------------
|
||||
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
|
||||
|
||||
function hashPin(pin) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(pin, salt, SCRYPT.keylen, SCRYPT);
|
||||
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('hex')}$${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
/** 恒时比较;v1 明文 PIN(不以 scrypt$ 开头)直接比对(调用方负责透明升级为哈希)。 */
|
||||
function verifyPin(pin, stored) {
|
||||
if (typeof stored !== 'string' || !stored || typeof pin !== 'string') return false;
|
||||
if (!stored.startsWith('scrypt$')) return stored === pin;
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6) return false;
|
||||
try {
|
||||
const [, N, r, p, saltHex, hashHex] = parts;
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
const actual = crypto.scryptSync(pin, Buffer.from(saltHex, 'hex'), expected.length, {
|
||||
N: Number(N), r: Number(r), p: Number(p),
|
||||
});
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 档案 / token ----------------
|
||||
|
||||
/** pid 兼容两种形态:新生 p_<hex> 与迁移档案(pid=旧用户名,已被 USERNAME_RE 限制为安全文件名字符)。 */
|
||||
function profileGamesFile(pid) {
|
||||
if (typeof pid !== 'string' || !USERNAME_RE.test(pid)) throw new Error('bad pid');
|
||||
return path.join(PROFILES_DIR, `${pid}.json`);
|
||||
}
|
||||
|
||||
async function loadGamesDoc(pid) {
|
||||
const doc = await readJson(profileGamesFile(pid), null);
|
||||
if (doc && typeof doc === 'object' && doc.games && typeof doc.games === 'object') return doc;
|
||||
return { id: pid, games: {} };
|
||||
}
|
||||
|
||||
/** 读-改-写整体进队列(fn 收到当前 doc,返回 null 跳过落盘)。 */
|
||||
function withGamesDoc(pid, fn) {
|
||||
const file = profileGamesFile(pid);
|
||||
return enqueueWrite(file, async () => {
|
||||
const doc = await loadGamesDoc(pid);
|
||||
const mutated = await fn(doc);
|
||||
if (mutated === false) return null;
|
||||
const tmp = file + '.tmp';
|
||||
await fsp.writeFile(tmp, JSON.stringify(doc, null, 2));
|
||||
await fsp.rename(tmp, file);
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
function persistProfileIndex() {
|
||||
return writeJson(PROFILES_FILE, profileIndex);
|
||||
}
|
||||
|
||||
function cleanupTokens() {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [tok, entry] of Object.entries(tokens)) {
|
||||
if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < now) {
|
||||
delete tokens[tok];
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function persistTokens() {
|
||||
const removed = cleanupTokens();
|
||||
if (removed) console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
|
||||
return writeJson(TOKENS_FILE, tokens);
|
||||
}
|
||||
|
||||
function issueToken(profileId) {
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const now = new Date().toISOString();
|
||||
tokens[token] = {
|
||||
profileId,
|
||||
createdAt: now,
|
||||
lastSeen: now,
|
||||
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
|
||||
};
|
||||
persistTokens();
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Bearer 鉴权 + 滑动续期(节流落盘)。返回 {token, profileId, profile} 或 null。 */
|
||||
function authProfile(req) {
|
||||
const h = req.headers.authorization || '';
|
||||
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
|
||||
if (!m) return null;
|
||||
const token = m[1].toLowerCase();
|
||||
return authByToken(token);
|
||||
}
|
||||
|
||||
function authByToken(token) {
|
||||
const entry = tokens[token];
|
||||
if (!entry || !profileIndex.profiles[entry.profileId]) return null;
|
||||
if (Date.parse(entry.expiresAt) < Date.now()) {
|
||||
delete tokens[token];
|
||||
persistTokens();
|
||||
return null;
|
||||
}
|
||||
if (Date.now() - Date.parse(entry.lastSeen) > TOKEN_TOUCH_MS) {
|
||||
entry.lastSeen = new Date().toISOString();
|
||||
entry.expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
|
||||
persistTokens();
|
||||
}
|
||||
return { token, profileId: entry.profileId, profile: profileIndex.profiles[entry.profileId] };
|
||||
}
|
||||
|
||||
function newProfileId() {
|
||||
for (;;) {
|
||||
const pid = 'p_' + crypto.randomBytes(6).toString('hex');
|
||||
if (!profileIndex.profiles[pid]) return pid;
|
||||
}
|
||||
}
|
||||
|
||||
/** 桶级 LWW 裁决(03 §3.4):updatedAt 新者胜,同值比 rev。 */
|
||||
function newerBucket(a, b) {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
|
||||
return (a.rev || 0) >= (b.rev || 0) ? a : b;
|
||||
}
|
||||
|
||||
function findClaimedByName(name) {
|
||||
for (const [pid, p] of Object.entries(profileIndex.profiles)) {
|
||||
if (p.pinHash && p.name === name) return pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */
|
||||
function cnNum(n) {
|
||||
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||||
if (!Number.isInteger(n) || n <= 0 || n > 99) return String(n);
|
||||
if (n < 10) return d[n];
|
||||
const t = Math.floor(n / 10);
|
||||
const u = n % 10;
|
||||
return (t > 1 ? d[t] : '') + '十' + (u ? d[u] : '');
|
||||
}
|
||||
|
||||
// ---------------- per-IP 频控(仅匿名建档) ----------------
|
||||
|
||||
const deviceCreations = new Map(); // ip -> {day, count}
|
||||
|
||||
function allowDeviceCreate(req) {
|
||||
const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const rec = deviceCreations.get(ip);
|
||||
if (!rec || rec.day !== day) {
|
||||
deviceCreations.set(ip, { day, count: 1 });
|
||||
return true;
|
||||
}
|
||||
if (rec.count >= DEVICE_LIMIT) return false;
|
||||
rec.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- HTTP 工具 ----------------
|
||||
@@ -127,11 +313,11 @@ function readBody(req) {
|
||||
});
|
||||
}
|
||||
|
||||
function authUser(req) {
|
||||
const h = req.headers.authorization || '';
|
||||
const m = h.match(/^Bearer\s+([0-9a-f]+)$/i);
|
||||
if (!m) return null;
|
||||
return sessions[m[1]] || null;
|
||||
async function parseBody(req) {
|
||||
const raw = await readBody(req);
|
||||
if (!raw) return {};
|
||||
const obj = JSON.parse(raw);
|
||||
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
||||
}
|
||||
|
||||
// ---------------- API ----------------
|
||||
@@ -139,63 +325,247 @@ function authUser(req) {
|
||||
async function handleApi(req, res, url) {
|
||||
const route = `${req.method} ${url.pathname}`;
|
||||
|
||||
// ---- 匿名自动建档 / 重附着 ----
|
||||
if (route === 'POST /api/device') {
|
||||
const body = await parseBody(req);
|
||||
if (body.token !== undefined) {
|
||||
if (typeof body.token !== 'string' || !TOKEN_RE.test(body.token)) {
|
||||
return sendJson(res, 400, { error: 'token 格式非法' });
|
||||
}
|
||||
const auth = authByToken(body.token.toLowerCase());
|
||||
if (auth) {
|
||||
return sendJson(res, 200, {
|
||||
ok: true, attached: true, profileId: auth.profileId, token: auth.token,
|
||||
name: auth.profile.name, claimed: !!auth.profile.pinHash,
|
||||
});
|
||||
}
|
||||
// 失效 token:视作无参,重新建档(下方统一处理)
|
||||
}
|
||||
if (!allowDeviceCreate(req)) {
|
||||
return sendJson(res, 429, { error: `本机今日匿名档案创建数已达上限(${DEVICE_LIMIT})` });
|
||||
}
|
||||
const pid = newProfileId();
|
||||
profileIndex.seq += 1;
|
||||
const profile = {
|
||||
name: '玩家' + cnNum(profileIndex.seq),
|
||||
pinHash: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
profileIndex.profiles[pid] = profile;
|
||||
await persistProfileIndex();
|
||||
const token = issueToken(pid);
|
||||
return sendJson(res, 201, { ok: true, profileId: pid, token, name: profile.name, claimed: false });
|
||||
}
|
||||
|
||||
// ---- 档案信息 / 改名 ----
|
||||
if (route === 'GET /api/profile') {
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const p = auth.profile;
|
||||
return sendJson(res, 200, {
|
||||
profileId: auth.profileId, name: p.name, claimed: !!p.pinHash, createdAt: p.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (route === 'PUT /api/profile') {
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const { name } = await parseBody(req);
|
||||
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
|
||||
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
|
||||
}
|
||||
const holder = findClaimedByName(name);
|
||||
if (holder && holder !== auth.profileId) {
|
||||
return sendJson(res, 409, { error: '该名字已被其他已认领档案使用' });
|
||||
}
|
||||
auth.profile.name = name;
|
||||
await persistProfileIndex();
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
}
|
||||
|
||||
// ---- 认领:命名 / 设 PIN / 名字+PIN 登录(合并) ----
|
||||
if (route === 'POST /api/claim') {
|
||||
const body = await parseBody(req);
|
||||
const auth = authProfile(req) || (typeof body.token === 'string' && TOKEN_RE.test(body.token)
|
||||
? authByToken(body.token.toLowerCase())
|
||||
: null);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const { name, pin } = body;
|
||||
if (pin !== undefined && pin !== null && (typeof pin !== 'string' || !PIN_RE.test(pin))) {
|
||||
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
|
||||
}
|
||||
const profile = auth.profile;
|
||||
|
||||
// 只设 PIN(不改名):当前档案升级为可跨设备登录
|
||||
if (name === undefined || name === null || name === '') {
|
||||
if (!pin) return sendJson(res, 400, { error: '缺少 name 或 pin' });
|
||||
profile.pinHash = hashPin(pin);
|
||||
await persistProfileIndex();
|
||||
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name: profile.name, claimed: true });
|
||||
}
|
||||
|
||||
if (typeof name !== 'string' || !USERNAME_RE.test(name)) {
|
||||
return sendJson(res, 400, { error: '名字需为 1~24 位字母/数字/中文/_/-' });
|
||||
}
|
||||
const targetPid = findClaimedByName(name);
|
||||
|
||||
// 目标不存在(或就是自己):原地命名 + 设 PIN,进度零迁移
|
||||
if (!targetPid || targetPid === auth.profileId) {
|
||||
profile.name = name;
|
||||
if (pin) profile.pinHash = hashPin(pin);
|
||||
await persistProfileIndex();
|
||||
return sendJson(res, 200, { ok: true, profileId: auth.profileId, name, claimed: !!profile.pinHash });
|
||||
}
|
||||
|
||||
// 目标已存在:验 PIN → 当前匿名档案按桶 LWW 合并进目标 → token 改绑
|
||||
if (profile.pinHash) {
|
||||
return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「新玩家」建匿名档案再登录' });
|
||||
}
|
||||
if (!pin) return sendJson(res, 401, { error: `「${name}」已设 PIN,请输入 PIN 登录` });
|
||||
const target = profileIndex.profiles[targetPid];
|
||||
if (!verifyPin(pin, target.pinHash)) {
|
||||
return sendJson(res, 401, { error: 'PIN 不正确' });
|
||||
}
|
||||
if (!target.pinHash.startsWith('scrypt$')) {
|
||||
target.pinHash = hashPin(pin); // v1 明文透明升级(迁移漏网兜底)
|
||||
}
|
||||
const srcPid = auth.profileId;
|
||||
let mergedGames = 0;
|
||||
await withGamesDoc(targetPid, (dst) => {
|
||||
// 源/目标两文件各自串行;合并按桶 LWW(newerBucket),重复执行幂等
|
||||
return loadGamesDoc(srcPid).then((src) => {
|
||||
for (const [game, bucket] of Object.entries(src.games)) {
|
||||
const cur = dst.games[game];
|
||||
if (!cur || newerBucket(cur, bucket) !== cur) {
|
||||
dst.games[game] = bucket;
|
||||
mergedGames += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
tokens[auth.token].profileId = targetPid; // token 改绑目标档案
|
||||
await persistTokens();
|
||||
delete profileIndex.profiles[srcPid]; // 匿名源档案出索引(其 games 文件成孤儿,惰性清理见下)
|
||||
await persistProfileIndex();
|
||||
enqueueWrite(profileGamesFile(srcPid), async () => {
|
||||
await fsp.unlink(profileGamesFile(srcPid)).catch(() => {});
|
||||
});
|
||||
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
|
||||
return sendJson(res, 200, {
|
||||
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ----
|
||||
if (route === 'POST /api/login') {
|
||||
const { username, pin } = await parseBody(req);
|
||||
const pid = typeof username === 'string' ? findClaimedByName(username) : null;
|
||||
if (!pid || typeof pin !== 'string' || !verifyPin(pin, profileIndex.profiles[pid].pinHash)) {
|
||||
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
|
||||
}
|
||||
const profile = profileIndex.profiles[pid];
|
||||
if (!profile.pinHash.startsWith('scrypt$')) {
|
||||
profile.pinHash = hashPin(pin); // 明文透明升级
|
||||
await persistProfileIndex();
|
||||
}
|
||||
const token = issueToken(pid);
|
||||
return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name });
|
||||
}
|
||||
|
||||
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
|
||||
if (route === 'POST /api/register') {
|
||||
const { username, pin } = JSON.parse(await readBody(req));
|
||||
const { username, pin } = await parseBody(req);
|
||||
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
|
||||
return sendJson(res, 400, { error: '用户名需为 1~24 位字母/数字/中文/_/-' });
|
||||
}
|
||||
if (typeof pin !== 'string' || !PIN_RE.test(pin)) {
|
||||
return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' });
|
||||
}
|
||||
if (users[username] !== undefined) {
|
||||
if (findClaimedByName(username)) {
|
||||
return sendJson(res, 409, { error: '用户名已被注册' });
|
||||
}
|
||||
users[username] = pin;
|
||||
await writeJson(USERS_FILE, users);
|
||||
return sendJson(res, 201, { ok: true, username });
|
||||
const pid = newProfileId();
|
||||
profileIndex.profiles[pid] = { name: username, pinHash: hashPin(pin), createdAt: new Date().toISOString() };
|
||||
profileIndex.seq += 1;
|
||||
await persistProfileIndex();
|
||||
const token = issueToken(pid);
|
||||
return sendJson(res, 201, { ok: true, username, profileId: pid, token });
|
||||
}
|
||||
|
||||
if (route === 'POST /api/login') {
|
||||
const { username, pin } = JSON.parse(await readBody(req));
|
||||
if (typeof username !== 'string' || typeof pin !== 'string'
|
||||
|| users[username] === undefined || users[username] !== pin) {
|
||||
return sendJson(res, 401, { error: '用户名或 PIN 错误' });
|
||||
}
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
sessions[token] = username;
|
||||
await writeJson(SESSIONS_FILE, sessions);
|
||||
return sendJson(res, 200, { ok: true, token, username });
|
||||
// ---- 登出(吊销当前 token) ----
|
||||
if (route === 'POST /api/logout') {
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
delete tokens[auth.token];
|
||||
await persistTokens();
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// ---- 进度:读单桶 / 读全桶 ----
|
||||
if (route === 'GET /api/progress') {
|
||||
const username = authUser(req);
|
||||
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const game = url.searchParams.get('game');
|
||||
if (!game) return sendJson(res, 400, { error: '缺少 game 参数' });
|
||||
const all = await readJson(progressFile(username), {});
|
||||
return sendJson(res, 200, { game, data: all[game] ?? null });
|
||||
const doc = await loadGamesDoc(auth.profileId);
|
||||
if (!game) {
|
||||
// 全桶一次拉回(大厅用;03 §3.3——替代旧的 8 次串行 GET)
|
||||
const games = {};
|
||||
for (const [g, bucket] of Object.entries(doc.games)) {
|
||||
games[g] = { updatedAt: bucket.updatedAt, rev: bucket.rev, data: bucket.data };
|
||||
}
|
||||
return sendJson(res, 200, { profileId: auth.profileId, name: auth.profile.name, games });
|
||||
}
|
||||
if (!/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
|
||||
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
|
||||
}
|
||||
const bucket = doc.games[game];
|
||||
// 形状兼容旧客户端:{game, data}(radio 直接读 j.data)
|
||||
return sendJson(res, 200, {
|
||||
game,
|
||||
data: bucket ? bucket.data : null,
|
||||
updatedAt: bucket ? bucket.updatedAt : null,
|
||||
rev: bucket ? bucket.rev : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 进度:写桶(baseUpdatedAt LWW 守卫) ----
|
||||
if (route === 'PUT /api/progress') {
|
||||
const username = authUser(req);
|
||||
if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const { game, data } = JSON.parse(await readBody(req));
|
||||
const auth = authProfile(req);
|
||||
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
|
||||
const { game, data, baseUpdatedAt } = await parseBody(req);
|
||||
if (typeof game !== 'string' || !/^[A-Za-z0-9_\-]{1,32}$/.test(game)) {
|
||||
return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' });
|
||||
}
|
||||
if (data === undefined) {
|
||||
return sendJson(res, 400, { error: '缺少 data 字段' });
|
||||
}
|
||||
const file = progressFile(username);
|
||||
// 读-改-写整体进队列,避免并发写互相覆盖
|
||||
await enqueueWrite(file, async () => {
|
||||
const all = await readJson(file, {});
|
||||
all[game] = data;
|
||||
const tmp = file + '.tmp';
|
||||
await fsp.writeFile(tmp, JSON.stringify(all, null, 2));
|
||||
await fsp.rename(tmp, file);
|
||||
if (baseUpdatedAt !== undefined && typeof baseUpdatedAt !== 'string') {
|
||||
return sendJson(res, 400, { error: 'baseUpdatedAt 需为字符串(上次已知服务器桶 updatedAt)' });
|
||||
}
|
||||
const pid = auth.profileId;
|
||||
let conflict = null;
|
||||
let written = null;
|
||||
await withGamesDoc(pid, (doc) => {
|
||||
const bucket = doc.games[game];
|
||||
if (baseUpdatedAt !== undefined && bucket && bucket.updatedAt !== baseUpdatedAt) {
|
||||
conflict = bucket; // 409 在队列外返回(响应不能在队列回调里写两次)
|
||||
return false;
|
||||
}
|
||||
if (baseUpdatedAt === undefined) {
|
||||
console.warn(`[server] legacy PUT(缺 baseUpdatedAt,放行): pid=${pid} game=${game}`);
|
||||
}
|
||||
const now = new Date().toISOString(); // 服务器时钟裁决(03 §3.1)
|
||||
doc.games[game] = { updatedAt: now, rev: (bucket ? bucket.rev || 0 : 0) + 1, data };
|
||||
written = doc.games[game];
|
||||
});
|
||||
return sendJson(res, 200, { ok: true, game });
|
||||
if (conflict) {
|
||||
return sendJson(res, 409, {
|
||||
error: '进度已被其他设备更新(LWW 冲突),请择优后重试',
|
||||
game,
|
||||
server: { updatedAt: conflict.updatedAt, rev: conflict.rev, data: conflict.data },
|
||||
});
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, game, updatedAt: written.updatedAt, rev: written.rev });
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { error: '未知 API 端点' });
|
||||
@@ -215,6 +585,12 @@ async function serveStatic(req, res, url) {
|
||||
res.writeHead(403).end('Forbidden');
|
||||
return;
|
||||
}
|
||||
// web/server/(token / PIN 哈希 / 进度数据)一律不对外提供(规范化后判定,防穿越变体)
|
||||
const serverDir = path.join(ROOT, 'server') + path.sep;
|
||||
if (filePath.startsWith(serverDir)) {
|
||||
res.writeHead(404).end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
@@ -269,12 +645,83 @@ async function serveStatic(req, res, url) {
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
|
||||
// ---------------- v1 → v2 迁移(启动时,单向,原文件保留) ----------------
|
||||
|
||||
async function migrate() {
|
||||
await fsp.mkdir(PROFILES_DIR, { recursive: true });
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// users.json(v1 {username: pin明文})→ profiles.json(pid=旧用户名,PIN 立即哈希)
|
||||
if (!fs.existsSync(PROFILES_FILE)) {
|
||||
const idx = { seq: 0, profiles: {} };
|
||||
const oldUsers = await readJson(USERS_FILE, null);
|
||||
let migrated = 0;
|
||||
if (oldUsers && typeof oldUsers === 'object') {
|
||||
for (const [name, pin] of Object.entries(oldUsers)) {
|
||||
if (typeof name !== 'string' || !USERNAME_RE.test(name)) continue;
|
||||
idx.profiles[name] = {
|
||||
name,
|
||||
pinHash: typeof pin === 'string' && pin.startsWith('scrypt$') ? pin : hashPin(String(pin)),
|
||||
createdAt: now,
|
||||
};
|
||||
idx.seq += 1;
|
||||
// 旧进度文件 <username>.json(扁平 {game: data})→ profiles/<username>.json 桶包装
|
||||
const oldProg = await readJson(path.join(DATA_DIR, `${name}.json`), null);
|
||||
const games = {};
|
||||
if (oldProg && typeof oldProg === 'object') {
|
||||
for (const [g, d] of Object.entries(oldProg)) {
|
||||
if (typeof g === 'string' && /^[A-Za-z0-9_\-]{1,32}$/.test(g)) {
|
||||
games[g] = { updatedAt: now, rev: 1, data: d };
|
||||
}
|
||||
}
|
||||
}
|
||||
await writeJson(profileGamesFile(name), { id: name, games });
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
await writeJson(PROFILES_FILE, idx);
|
||||
if (migrated > 0) {
|
||||
console.log(`[server] v1 迁移: users.json → profiles.json(${migrated} 个档案,PIN 已 scrypt 哈希;原文件保留不动)`);
|
||||
}
|
||||
}
|
||||
profileIndex = await readJson(PROFILES_FILE, { seq: 0, profiles: {} });
|
||||
if (!profileIndex || typeof profileIndex !== 'object' || !profileIndex.profiles) {
|
||||
profileIndex = { seq: 0, profiles: {} };
|
||||
}
|
||||
profileIndex.seq = Number(profileIndex.seq) || 0;
|
||||
|
||||
// sessions.json(v1 {token: username})→ tokens.json(补 180 天滑动过期)
|
||||
if (!fs.existsSync(TOKENS_FILE)) {
|
||||
const migratedTokens = {};
|
||||
const oldSessions = await readJson(SESSIONS_FILE, null);
|
||||
let n = 0;
|
||||
if (oldSessions && typeof oldSessions === 'object') {
|
||||
for (const [tok, user] of Object.entries(oldSessions)) {
|
||||
if (typeof tok === 'string' && TOKEN_RE.test(tok) && profileIndex.profiles[user]) {
|
||||
migratedTokens[tok.toLowerCase()] = {
|
||||
profileId: user, createdAt: now, lastSeen: now,
|
||||
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
|
||||
};
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
await writeJson(TOKENS_FILE, migratedTokens);
|
||||
if (n > 0) console.log(`[server] v1 迁移: sessions.json → tokens.json(${n} 条 token 补 180 天过期;原文件保留不动)`);
|
||||
}
|
||||
tokens = await readJson(TOKENS_FILE, {});
|
||||
}
|
||||
|
||||
// ---------------- 启动 ----------------
|
||||
|
||||
async function main() {
|
||||
await fsp.mkdir(DATA_DIR, { recursive: true });
|
||||
users = await readJson(USERS_FILE, {});
|
||||
sessions = await readJson(SESSIONS_FILE, {});
|
||||
await migrate();
|
||||
const removed = cleanupTokens();
|
||||
if (removed) {
|
||||
console.log(`[server] 清扫过期/悬空 token ${removed} 条`);
|
||||
await persistTokens();
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -301,6 +748,7 @@ async function main() {
|
||||
console.log(`[server] SoundGame 服务器已启动: http://localhost:${PORT}/`);
|
||||
console.log(`[server] 静态根目录: ${ROOT}`);
|
||||
console.log(`[server] 数据目录: ${DATA_DIR}`);
|
||||
console.log(`[server] 档案数: ${Object.keys(profileIndex.profiles).length},token 数: ${Object.keys(tokens).length}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* api-smoke.mjs — 无感用户系统(P0-B)零依赖冒烟测试。
|
||||
*
|
||||
* 覆盖(完成标准第 1 条):
|
||||
* A. POST /api/device 匿名建档(自动名玩家一/二…)与 token 重附着
|
||||
* B. 进度 PUT-GET-LWW:legacy 放行、baseUpdatedAt 守卫 409、全桶 GET
|
||||
* C. claim 命名/设 PIN、改名、PIN-only 认领
|
||||
* D. claim-merge(换设备登录):旧档案与新设备档案按桶 LWW 合并、token 改绑
|
||||
* E. 旧客户端 login/register 兼容 + logout 吊销
|
||||
* F. v1 迁移:users.json/sessions.json/<username>.json → profiles.json/tokens.json/
|
||||
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
|
||||
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
|
||||
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
|
||||
*
|
||||
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
|
||||
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
BUCKETS, SETTINGS_KEY,
|
||||
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
|
||||
nextLocalProfileName, readRegistry, restoreSnapshot, setCurrentProfile,
|
||||
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
|
||||
} from '../../profiles.js';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SERVER = path.join(HERE, '..', 'server.js');
|
||||
const NODE = process.execPath;
|
||||
|
||||
// ---------------- 服务器实例管理 ----------------
|
||||
|
||||
function tmpDataDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'soundgame-smoke-'));
|
||||
}
|
||||
|
||||
async function startServer({ dataDir = tmpDataDir(), env = {} } = {}) {
|
||||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||
const child = spawn(NODE, [SERVER], {
|
||||
env: { ...process.env, PORT: String(port), SG_DATA_DIR: dataDir, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const logs = [];
|
||||
child.stdout.on('data', (c) => logs.push(String(c)));
|
||||
child.stderr.on('data', (c) => logs.push(String(c)));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const deadline = Date.now() + 15000;
|
||||
for (;;) {
|
||||
if (child.exitCode !== null) throw new Error(`服务器提前退出:\n${logs.join('')}`);
|
||||
if (Date.now() > deadline) throw new Error('服务器启动超时');
|
||||
try {
|
||||
const r = await fetch(base + '/');
|
||||
if (r.ok) break;
|
||||
} catch (_) { /* 尚未监听 */ }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return {
|
||||
base,
|
||||
dataDir,
|
||||
logs,
|
||||
stop() {
|
||||
return new Promise((resolve) => {
|
||||
child.on('exit', resolve);
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} resolve(); }, 2000);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function call(base, method, apiPath, { token, body } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const resp = await fetch(base + apiPath, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
return { status: resp.status, json };
|
||||
}
|
||||
|
||||
// ---------------- H. 大厅分桶逻辑单测(纯函数,无需服务器) ----------------
|
||||
|
||||
test('H1. 快照回灌/倒灌:玩家一/玩家二进度互不污染', () => {
|
||||
const storage = mockStorage();
|
||||
// 玩家一在线:rpg 进度 + 设置
|
||||
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
|
||||
storage.setItem(SETTINGS_KEY, JSON.stringify({ voice: 'male', speed: 1.7 }));
|
||||
storage.setItem('soundgame.rpg.tutIntroSeen', '1'); // 教学标记:不入桶(跨档案共享,01 §4.6)
|
||||
writeRegistry(storage, [
|
||||
{ id: 'p_aaa', name: '玩家一', token: 't1' },
|
||||
{ id: 'p_bbb', name: '玩家二', token: 't2' },
|
||||
]);
|
||||
setCurrentProfile(storage, 'p_aaa');
|
||||
|
||||
// 切走:回灌玩家一
|
||||
stashSnapshot(storage, 'p_aaa');
|
||||
assert.equal(storage.getItem('soundgame.progress.rpg'), JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
|
||||
assert.ok(!JSON.parse(storage.getItem(snapshotKeyOf('p_aaa'))).hasOwnProperty('soundgame.rpg.tutIntroSeen'), '教学标记不应入快照');
|
||||
|
||||
// 切入玩家二:倒灌前先清空活动 key → 无残留串档
|
||||
restoreSnapshot(storage, 'p_bbb');
|
||||
assert.equal(storage.getItem('soundgame.progress.rpg'), null, '切到空档案必须清掉玩家一的 rpg 存档');
|
||||
assert.equal(storage.getItem(SETTINGS_KEY), null, '设置桶同样隔离');
|
||||
assert.equal(storage.getItem('soundgame.rpg.tutIntroSeen'), '1', '教学标记保留(跨档案共享是既定行为)');
|
||||
|
||||
// 玩家二游玩后切回玩家一:进度原样回归
|
||||
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T11:00:00Z', summary: '新手', lv: 1 }));
|
||||
stashSnapshot(storage, 'p_bbb');
|
||||
restoreSnapshot(storage, 'p_aaa');
|
||||
assert.deepEqual(JSON.parse(storage.getItem('soundgame.progress.rpg')), { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 });
|
||||
});
|
||||
|
||||
test('H2. decideBucket 桶级 LWW 裁决', () => {
|
||||
const newer = { updatedAt: '2026-09-09T12:00:00Z', rev: 3, data: { updatedAt: '2026-09-09T12:00:00Z', summary: 'new' } };
|
||||
const older = { updatedAt: '2026-09-09T11:00:00Z', rev: 2, data: { updatedAt: '2026-09-09T11:00:00Z', summary: 'old' } };
|
||||
// 服务器缺 → 本地上云
|
||||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', null), 'push-local');
|
||||
assert.equal(decideBucket(null, null), 'keep');
|
||||
// 本地缺 → 服务器倒灌(修「7/8 游戏只 PUT 不 GET」由大厅统一做)
|
||||
assert.equal(decideBucket(null, older), 'take-server');
|
||||
// 两边都有:data.updatedAt 新者胜
|
||||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T12:00:00Z"}', older), 'push-local');
|
||||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', older), 'take-server');
|
||||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T11:00:00Z"}', older), 'keep');
|
||||
// 无时间戳(settings / simon 最高分):内容等价 keep,否则服务器胜(对齐旧「登录拉设置」行为)
|
||||
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'male' } }), 'keep');
|
||||
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'female' } }), 'take-server');
|
||||
assert.equal(decideBucket('5', { updatedAt: 'x', rev: 1, data: 5 }), 'keep'); // simon.best 标量
|
||||
// 本地损坏 → 服务器兜底
|
||||
assert.equal(decideBucket('{broken', older), 'take-server');
|
||||
});
|
||||
|
||||
test('H3. 注册表 / 旧登录态收养 / 本地自动名 / 游戏侧 auth 视图', () => {
|
||||
const storage = mockStorage();
|
||||
assert.deepEqual(readRegistry(storage), []);
|
||||
assert.equal(getCurrentProfile(storage), null);
|
||||
assert.equal(nextLocalProfileName([]), '玩家一');
|
||||
assert.equal(nextLocalProfileName([{ id: 'a', name: 'x' }, { id: 'b', name: 'y' }]), '玩家三');
|
||||
|
||||
// v1 旧登录态识别
|
||||
storage.setItem('soundgame.auth', JSON.stringify({ username: 'uuu', token: 'ab'.repeat(24) }));
|
||||
const legacy = migrateLegacyAuth(storage);
|
||||
assert.equal(legacy.username, 'uuu');
|
||||
assert.equal(migrateLegacyAuth(mockStorage()), null);
|
||||
|
||||
// 有 token → 游戏兼容视图 {username, token};无 token(纯本地档案)→ 移除
|
||||
applyAuth(storage, { id: 'p1', name: '玩家一', token: 'tk' });
|
||||
assert.deepEqual(JSON.parse(storage.getItem('soundgame.auth')), { username: '玩家一', token: 'tk' });
|
||||
applyAuth(storage, { id: 'L1', name: '玩家二', token: null });
|
||||
assert.equal(storage.getItem('soundgame.auth'), null);
|
||||
});
|
||||
|
||||
test('H4. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
|
||||
const games = BUCKETS.map((b) => b.game).sort();
|
||||
assert.deepEqual(games, ['_settings', 'dungeon', 'escape', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
|
||||
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
|
||||
});
|
||||
|
||||
function mockStorage() {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k),
|
||||
_map: m,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- A-G. API 冒烟(共享一个服务器实例的顺序场景) ----------------
|
||||
|
||||
test('A-G. API 全链路(device → progress LWW → claim → 合并 → 兼容 → 防护)', async () => {
|
||||
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '100' } });
|
||||
try {
|
||||
// ---- A. 匿名建档 ----
|
||||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
assert.equal(r.status, 201);
|
||||
const A = { profileId: r.json.profileId, token: r.json.token };
|
||||
assert.match(A.profileId, /^p_[0-9a-f]{12}$/);
|
||||
assert.match(A.token, /^[0-9a-f]{48}$/);
|
||||
assert.equal(r.json.name, '玩家一');
|
||||
assert.equal(r.json.claimed, false);
|
||||
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const B = { profileId: r.json.profileId, token: r.json.token };
|
||||
assert.equal(r.json.name, '玩家二');
|
||||
|
||||
// token 重附着(幂等,不新建档案、不消耗 seq)
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: { token: A.token } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.attached, true);
|
||||
assert.equal(r.json.profileId, A.profileId);
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
assert.equal(r.json.name, '玩家三');
|
||||
|
||||
// ---- B. 进度 PUT-GET-LWW ----
|
||||
const rpgSave = { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 };
|
||||
// legacy PUT(无 baseUpdatedAt,存量游戏路径)→ 放行
|
||||
r = await call(srv.base, 'PUT', '/api/progress', { token: A.token, body: { game: 'rpg', data: rpgSave } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.json.updatedAt && r.json.rev === 1);
|
||||
|
||||
// 带 baseUpdatedAt 的守卫写
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: A.token,
|
||||
body: { game: 'rpg', data: { ...rpgSave, lv: 8 }, baseUpdatedAt: r.json.updatedAt },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.rev, 2);
|
||||
const aRpgAt = r.json.updatedAt;
|
||||
|
||||
// 过期基线 → 409 + 服务器桶(客户端择优重试用)
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: A.token,
|
||||
body: { game: 'rpg', data: { ...rpgSave, lv: 1 }, baseUpdatedAt: '2000-01-01T00:00:00.000Z' },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.json.server.data.lv, 8, '409 应携带服务器当前桶');
|
||||
assert.equal(r.json.server.updatedAt, aRpgAt);
|
||||
|
||||
// 单桶 GET:形状兼容旧客户端(radio 直接读 j.data)
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: A.token });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.game, 'rpg');
|
||||
assert.equal(r.json.data.lv, 8);
|
||||
assert.equal(r.json.updatedAt, aRpgAt);
|
||||
|
||||
// 全桶 GET(大厅一次拉回)
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: A.token });
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.json.games.rpg && r.json.games.rpg.data.lv === 8);
|
||||
assert.ok(r.json.games._settings === undefined || r.json.games._settings.data !== undefined);
|
||||
// 未写过 radio → GET 单桶 data 为 null(radio 新设备开局路径)
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=radio', { token: A.token });
|
||||
assert.equal(r.json.data, null);
|
||||
|
||||
// 未授权
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=rpg');
|
||||
assert.equal(r.status, 401);
|
||||
|
||||
// ---- C. claim:命名 + 设 PIN ----
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: A.token, body: { name: '爸爸', pin: '1234' } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.profileId, A.profileId);
|
||||
assert.equal(r.json.name, '爸爸');
|
||||
assert.equal(r.json.claimed, true);
|
||||
|
||||
r = await call(srv.base, 'GET', '/api/profile', { token: A.token });
|
||||
assert.equal(r.json.name, '爸爸');
|
||||
assert.equal(r.json.claimed, true);
|
||||
|
||||
// 改名(PUT profile)
|
||||
r = await call(srv.base, 'PUT', '/api/profile', { token: A.token, body: { name: '妈妈' } });
|
||||
assert.equal(r.status, 200);
|
||||
// 已认领名字被抢注 → 409
|
||||
r = await call(srv.base, 'PUT', '/api/profile', { token: B.token, body: { name: '妈妈' } });
|
||||
assert.equal(r.status, 409);
|
||||
|
||||
// PIN-only 认领(给当前档案补设 PIN,不改名)
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: B.token, body: { pin: '5678' } });
|
||||
assert.equal(r.status, 200);
|
||||
r = await call(srv.base, 'GET', '/api/profile', { token: B.token });
|
||||
assert.equal(r.json.claimed, true);
|
||||
|
||||
// ---- D. claim-merge:换设备登录,按桶 LWW 合并 ----
|
||||
// 设备 C(新设备匿名档案):dungeon 桶 + 比妈妈更新的 rpg 桶
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const C = { profileId: r.json.profileId, token: r.json.token };
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: C.token,
|
||||
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const cDungeonAt = r.json.updatedAt;
|
||||
await new Promise((res) => setTimeout(res, 20)); // 服务器时钟至少推进
|
||||
const cRpg = { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 };
|
||||
r = await call(srv.base, 'PUT', '/api/progress', { token: C.token, body: { game: 'rpg', data: cRpg } });
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// 用「妈妈 + 1234」在设备 C 登录 → 匿名档案 C 合并进妈妈的档案,token 改绑
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: C.token, body: { name: '妈妈', pin: '1234' } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.merged, true);
|
||||
assert.equal(r.json.profileId, A.profileId, 'C 的 token 应改绑到妈妈的档案');
|
||||
const CTOKEN = C.token; // token 不变,指向已变
|
||||
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: CTOKEN });
|
||||
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:C 的 rpg 更新,应胜出');
|
||||
assert.equal(r.json.games.dungeon.data.floor, 3, '合并应带入 C 独有的 dungeon 桶');
|
||||
|
||||
// 旧 token(妈妈原设备 A)不受影响,且两端看到同一档案
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: A.token });
|
||||
assert.equal(r.json.data.floor, 3);
|
||||
|
||||
// 错误 PIN → 401
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const D = { token: r.json.token };
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈', pin: '0000' } });
|
||||
assert.equal(r.status, 401);
|
||||
// 无 PIN 直接抢已认领名 → 401
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈' } });
|
||||
assert.equal(r.status, 401);
|
||||
|
||||
// ---- E. 旧客户端兼容:register / login / logout ----
|
||||
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
|
||||
assert.equal(r.status, 201);
|
||||
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
|
||||
assert.equal(r.status, 409);
|
||||
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '9999' } });
|
||||
assert.equal(r.status, 401);
|
||||
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '4321' } });
|
||||
assert.equal(r.status, 200);
|
||||
const oldToken = r.json.token;
|
||||
assert.equal(r.json.username, '老王');
|
||||
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
|
||||
assert.equal(r.json.name, '老王');
|
||||
// logout 吊销
|
||||
r = await call(srv.base, 'POST', '/api/logout', { token: oldToken });
|
||||
assert.equal(r.status, 200);
|
||||
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
|
||||
assert.equal(r.status, 401);
|
||||
|
||||
// ---- G1. /server/ 数据目录静态封禁 ----
|
||||
let resp = await fetch(srv.base + '/server/data/profiles.json');
|
||||
assert.equal(resp.status, 404, 'profiles.json 不应可被静态读取');
|
||||
resp = await fetch(srv.base + '/x/../server/data/tokens.json');
|
||||
assert.ok(resp.status === 404 || resp.status === 403, '穿越变体也应被拦');
|
||||
resp = await fetch(srv.base + '/');
|
||||
assert.equal(resp.status, 200, '大厅不受影响');
|
||||
|
||||
// 落盘检查:PIN 哈希而非明文
|
||||
const idx = JSON.parse(fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8'));
|
||||
for (const p of Object.values(idx.profiles)) {
|
||||
assert.ok(p.pinHash === null || p.pinHash.startsWith('scrypt$'), 'PIN 必须以 scrypt$ 哈希存储');
|
||||
}
|
||||
assert.ok(!fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8').includes('"1234"'));
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('F. v1 迁移:users.json/sessions.json/<username>.json → 档案分桶(旧用户无感)', async () => {
|
||||
const dataDir = tmpDataDir();
|
||||
const oldToken = '7c20d0f3534a31893157529e1f71c7e2c28e26fd07c8d8fc'; // 现网实测形状
|
||||
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ uuu: '0858' }, null, 2));
|
||||
fs.writeFileSync(path.join(dataDir, 'sessions.json'), JSON.stringify({ [oldToken]: 'uuu' }, null, 2));
|
||||
fs.writeFileSync(path.join(dataDir, 'uuu.json'), JSON.stringify({
|
||||
_settings: { voice: 'male', speed: 1, bgm: 1, guide: 'full' },
|
||||
rpg: { schema: 1, summary: '第一章·Lv3', lv: 3 },
|
||||
}, null, 2));
|
||||
|
||||
const srv = await startServer({ dataDir });
|
||||
try {
|
||||
// 旧 token 继续有效(迁移进 tokens.json,补 180 天过期)
|
||||
let r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: oldToken });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.data.lv, 3, '旧进度数据应完整保留(桶包装、data 透传)');
|
||||
r = await call(srv.base, 'GET', '/api/progress?game=_settings', { token: oldToken });
|
||||
assert.equal(r.json.data.voice, 'male');
|
||||
|
||||
// 旧客户端 用户名+PIN 登录兼容(映射到档案)
|
||||
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0000' } });
|
||||
assert.equal(r.status, 401);
|
||||
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0858' } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.profileId, 'uuu', 'pid 沿用旧用户名(进度文件零改名)');
|
||||
const t2 = r.json.token;
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: t2 });
|
||||
assert.equal(r.json.games.rpg.data.lv, 3);
|
||||
|
||||
// 新设备用 claim 登录旧档案 → 合并路径可用(匿名档案进度并入 uuu)
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
const dev = r.json;
|
||||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||||
token: dev.token,
|
||||
body: { game: 'dungeon', data: { schema: 1, summary: '第 2 层', floor: 2 } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
r = await call(srv.base, 'POST', '/api/claim', { token: dev.token, body: { name: 'uuu', pin: '0858' } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.merged, true);
|
||||
r = await call(srv.base, 'GET', '/api/progress', { token: dev.token });
|
||||
assert.equal(r.json.games.rpg.data.lv, 3, 'uuu 旧进度在合并后保留');
|
||||
assert.equal(r.json.games.dungeon.data.floor, 2, '新设备进度并入');
|
||||
|
||||
// 迁移落盘断言:PIN 已哈希、原 v1 文件保留不动
|
||||
const idxRaw = fs.readFileSync(path.join(dataDir, 'profiles.json'), 'utf8');
|
||||
assert.ok(idxRaw.includes('scrypt$'), '迁移后 PIN 应为 scrypt 哈希');
|
||||
assert.ok(!idxRaw.includes('0858'), '明文 PIN 不应出现在 profiles.json');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'users.json'), 'utf8')).uuu, '0858', 'v1 原文件保留');
|
||||
assert.ok(fs.existsSync(path.join(dataDir, 'profiles', 'uuu.json')), '进度迁至 profiles/uuu.json');
|
||||
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
|
||||
assert.ok(tokensDisk[oldToken] && tokensDisk[oldToken].profileId === 'uuu');
|
||||
assert.ok(Date.parse(tokensDisk[oldToken].expiresAt) > Date.now(), '旧 token 补 180 天过期');
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('G2. per-IP 匿名建档频控', async () => {
|
||||
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '2' } });
|
||||
try {
|
||||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
assert.equal(r.status, 201);
|
||||
const firstToken = r.json.token;
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
assert.equal(r.status, 201);
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||||
assert.equal(r.status, 429);
|
||||
// 重附着(带有效 token)不受频控影响
|
||||
r = await call(srv.base, 'POST', '/api/device', { body: { token: firstToken } });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.attached, true);
|
||||
} finally {
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user