// i18n-ignore-file: API errors are diagnostics; the account UI supplies translated labels. import { sendJson } from '../http/common.js'; import { readJsonBody } from './http.js'; import { ACCOUNT_PREFIX } from './auth.js'; import { SESSION_BINDING_PREFIX } from './session.js'; export function createAccountHttp({ service, auth, registry, recovery, diagnostic }) { const endpoints = { status: 'GET', roles: 'GET', recovery: 'GET', enter: 'POST', resume: 'POST', 'scan/start': 'POST', 'scan/poll': 'POST', 'phone/send': 'POST', 'phone/login': 'POST', 'password/login': 'POST', 'role/select': 'POST', unbind: 'POST' }; return async (req, res, parts) => { const fail = (status, error, code) => { sendJson(req, res, status, { ok: false, error, ...(code ? { code } : {}) }); return true; }; if (!auth.originAllowed(req)) return fail(403, '跨站请求已拒绝 · Cross-origin request denied'); if (auth.limited(req)) return fail(429, '请求过于频繁 · Too many requests'); if (!parts.rawPath.startsWith('/skland/session/')) { if (!auth.diagnosticAllowed(req)) return fail(403, '诊断接口需要管理员密钥 · Diagnostic API requires an admin key'); return diagnostic(req, res, parts); } const endpoint = parts.rawPath.slice('/skland/session/'.length); if (!Object.hasOwn(endpoints, endpoint)) return fail(404, '未知接口 · Unknown endpoint'); if (req.method !== endpoints[endpoint]) return fail(405, '不支持的请求方法 · Method not allowed'); const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || ''); const session = match && registry.byToken(match[1]); if (!session || registry.isExpired(session, registry.now())) return fail(401, '游戏会话已失效 · Game session expired'); const query = new URLSearchParams(parts.query); let body = {}; if (req.method === 'POST') { try { body = await readJsonBody(req); } catch { return fail(400, '请求体无效 · Bad request'); } if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, '请求体无效 · Bad request'); } // Reading a POST body yields: logout, expiry or a device takeover may invalidate either credential meanwhile. if (registry.byToken(match[1]) !== session) return fail(401, '游戏会话已失效 · Game session expired'); let login; try { login = auth.authenticate(req); } catch { auth.setCookie(req, res, null); return fail(401, '登录已过期,请重新绑定 · Login expired'); } if (session.playerId.startsWith('a_') && (!login || auth.state(login).gamePlayerId !== session.playerId)) { return fail(403, '身份不匹配 · Identity mismatch'); } // Claimed identity fields never choose the target row; all ownership comes from authenticated credentials. const pendingPlayer = SESSION_BINDING_PREFIX + session.playerId; const issue = () => { login = auth.publish(pendingPlayer); if (login) auth.setCookie(req, res, login); }; if (!login && service.status(pendingPlayer).bound) issue(); // Migrate an existing binding owned by this transport. const player = login ? ACCOUNT_PREFIX + login.subject : pendingPlayer; const syncSelection = () => { if (!login) return; const updated = auth.ensureSelection(login); if (updated !== login) { login = updated; auth.setCookie(req, res, login); } }; try { let answer; if (endpoint === 'status') { syncSelection(); answer = login ? auth.state(login) : service.status(player); } else if (endpoint === 'recovery' || endpoint === 'enter' || endpoint === 'resume') { if (!login) return fail(401, '请先绑定账号 · Account binding required'); syncSelection(); const playerId = auth.state(login).gamePlayerId; if (!playerId) return fail(409, '请选择有效角色 · A playable role is required'); const item = recovery.describe(playerId); if (endpoint === 'recovery') answer = { ok: true, recovery: item }; else { if (endpoint === 'resume' && (!item || typeof body.id !== 'string' || body.id !== item.id)) { return fail(409, '此对局已结束或过期,请刷新后重试 · Recovery is no longer available', 'resume_expired'); } const target = auth.gameSession(login); answer = { ok: true, gamePlayerId: target.playerId, token: target.token, name: target.name }; } } else if (endpoint === 'roles') { if (!login) return fail(401, '请先绑定账号 · Account binding required'); answer = { ok: true, roles: await service.roles(player, query.get('refresh') === '1') }; syncSelection(); } else if (endpoint === 'role/select') { if (!login) return fail(401, '请先绑定账号 · Account binding required'); const selected = auth.select(login, body.roleKey); if (!selected) return fail(403, '无法选择该角色 · Role not owned or currently in a room'); login = selected; auth.setCookie(req, res, login); answer = { ok: true, ...auth.state(login) }; } else if (endpoint === 'unbind') { if (login) { const role = auth.roleOf(login), current = role && registry.byId(auth.state(login).gamePlayerId); if (current?.roomCode) return fail(409, '请先离开同盟 · Leave your room first'); auth.revoke(login); } service.unbind(pendingPlayer); auth.setCookie(req, res, null); answer = { ok: true }; } else { if (login) return fail(409, '请先解绑当前账号 · Unbind the current account first'); if (endpoint === 'scan/start') answer = { ok: true, ...await service.startScan(player) }; else if (endpoint === 'scan/poll') { answer = { ok: true, ...await service.pollScan(player) }; if (answer.state === 'confirmed' && !answer.error) issue(); } else if (endpoint === 'phone/send') { if (!body.phone) return fail(400, '缺少手机号 · Missing phone'); answer = await service.sendPhoneCode(player, String(body.phone), body.captcha); } else if (endpoint === 'phone/login') { if (!body.phone || !body.code) return fail(400, '缺少登录信息 · Missing login fields'); answer = await service.loginByPhoneCode(player, String(body.phone), String(body.code)); if (answer.ok) issue(); } else if (endpoint === 'password/login') { if (!body.phone || !body.password) return fail(400, '缺少登录信息 · Missing login fields'); answer = await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, body.captchaStep); if (answer.ok) issue(); } } sendJson(req, res, 200, answer); } catch (error) { sendJson(req, res, 200, { ok: false, ...(endpoint === 'scan/poll' ? { state: 'error' } : {}), error: error.message }); } return true; }; }