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:
+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}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user