/** * server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。 * * 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/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 不带此字段)。 * * 3. 数据落盘(data/ 目录自动创建;env SG_DATA_DIR 可覆盖,供测试): * data/profiles.json { seq, profiles: { : {name, pinHash, createdAt} } } * data/tokens.json { : {profileId, createdAt, lastSeen, expiresAt} } * data/profiles/.json { id, games: { : {updatedAt, rev, data} } }(桶级包装) * v1 兼容迁移(启动时单向,原文件保留不动): * users.json {username: pin明文} → profiles.json(PIN 立即 scrypt 哈希,pid=旧用户名) * sessions.json {token: username} → tokens.json(补 180 天滑动过期) * .json {game: data} → profiles/.json(每桶补 updatedAt/rev) * * 安全水位(家庭局域网):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'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); 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 = 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', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8', }; 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 {{ seq: number, profiles: Record }} */ let profileIndex = { seq: 0, profiles: {} }; /** @type {Record} */ let tokens = {}; /** 按文件串行的写入队列:file -> Promise 链尾 */ const writeQueues = new Map(); function enqueueWrite(file, fn) { const tail = writeQueues.get(file) || Promise.resolve(); const next = tail.then(fn, fn); // 前序失败不阻塞后续 writeQueues.set(file, next.catch(() => {})); return next; } async function readJson(file, fallback) { try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch (_) { return fallback; } } /** 队列化原子写(tmp + rename)。 */ function writeJson(file, obj) { return enqueueWrite(file, async () => { const tmp = file + '.tmp'; await fsp.writeFile(tmp, JSON.stringify(obj, null, 2)); await fsp.rename(tmp, file); }); } // ---------------- 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_ 与迁移档案(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 工具 ---------------- function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > 1024 * 1024) { // 1MB 上限 reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } 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 ---------------- 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 } = 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 (findClaimedByName(username)) { return sendJson(res, 409, { error: '用户名已被注册' }); } 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 }); } // ---- 登出(吊销当前 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 auth = authProfile(req); if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' }); const game = url.searchParams.get('game'); 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 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 字段' }); } 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]; }); 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 端点' }); } // ---------------- 静态文件 ---------------- async function serveStatic(req, res, url) { let pathname; try { pathname = decodeURIComponent(url.pathname); } catch (_) { return sendJson(res, 400, { error: '非法 URL' }); } let filePath = path.normalize(path.join(ROOT, pathname)); if (!filePath.startsWith(ROOT + path.sep) && filePath !== ROOT) { 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 { stat = await fsp.stat(filePath); if (stat.isDirectory()) { filePath = path.join(filePath, 'index.html'); stat = await fsp.stat(filePath); } } catch (_) { res.writeHead(404).end('Not Found'); return; } const mime = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; const headers = { 'Content-Type': mime, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache', }; // 简化版 Range:仅支持单个 bytes=start-end / start- / -suffix const range = req.headers.range; if (range) { const m = range.match(/^bytes=(\d*)-(\d*)$/); if (m && (m[1] !== '' || m[2] !== '')) { let start, end; if (m[1] === '') { // 后缀范围:最后 N 字节 const n = Number(m[2]); start = Math.max(stat.size - n, 0); end = stat.size - 1; } else { start = Number(m[1]); end = m[2] === '' ? stat.size - 1 : Math.min(Number(m[2]), stat.size - 1); } if (start > end || start >= stat.size) { res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); return; } res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Content-Length': end - start + 1, }); if (req.method === 'HEAD') return res.end(); fs.createReadStream(filePath, { start, end }).pipe(res); return; } } res.writeHead(200, { ...headers, 'Content-Length': stat.size }); if (req.method === 'HEAD') return res.end(); 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; // 旧进度文件 .json(扁平 {game: data})→ profiles/.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 }); await migrate(); const removed = cleanupTokens(); if (removed) { console.log(`[server] 清扫过期/悬空 token ${removed} 条`); await persistTokens(); } const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (url.pathname.startsWith('/api/')) { return await handleApi(req, res, url); } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end('Method Not Allowed'); return; } return await serveStatic(req, res, url); } catch (e) { if (e instanceof SyntaxError) { return sendJson(res, 400, { error: '请求体不是合法 JSON' }); } console.error('[server] 未处理异常:', e); if (!res.headersSent) sendJson(res, 500, { error: '服务器内部错误' }); else res.end(); } }); server.listen(PORT, () => { 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}`); }); } main().catch((e) => { console.error('[server] 启动失败:', e); process.exit(1); });