// server/skland/http.js — the /skland/* HTTP surface (JSON). Wired into the main request listener by routes.js when // (i18n-ignore-file: the answers are bilingual by design, 中文 · English, the same convention as server/http/* // — docs/I18N.md; the panel renders its own labels and shows these strings verbatim as the diagnostic) // the skland module is enabled (opts.skland / env SP_SKLAND=1). **Every** answer of this surface is JSON, including // the errors: a JSON client must never be handed the module-level HTML error page (http/common.js sendError), so a // local `fail()` writes { ok: false, error } with the status instead. POST bodies are read with a size cap; the // security headers come from the outer listener. // // Endpoints (docs/skland.md): // GET /skland/status?player=… the player's binding state // POST /skland/scan/start {player} start a scan session → { scanId, scanUrl } // POST /skland/scan/poll {player} one poll; finishes the login when the phone confirmed // POST /skland/phone/send {player, phone, captcha?} send the SMS code // POST /skland/phone/login {player, phone, code} 验证码 login → bind // POST /skland/password/login {player, phone, password} 密码 login → bind // POST /skland/unbind {player} forget the credentials // GET /skland/roles?player=…&refresh=1 the bound arknights roles (cached) import { sendJson } from '../http/common.js'; import { SESSION_BINDING_PREFIX } from './session.js'; /** The cap on a POST body (a phone + code + player id is small). */ const MAX_BODY = 64 * 1024; /** Read the request body as JSON (async iterable; node:http IncomingMessage supports it). */ export async function readJsonBody(req) { let size = 0; const chunks = []; for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY) throw new Error('body too large'); chunks.push(chunk); } if (!chunks.length) return {}; return JSON.parse(Buffer.concat(chunks).toString('utf8')); } /** * @param {{ service: ReturnType, sessionPlayer?: Function }} deps * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, * parts: { rawPath: string, query: string }) => Promise} * resolves true when the request was a /skland/* route (handled here). */ export function createSklandHttp({ service, sessionPlayer }) { const queryParam = (parts, name) => { for (const pair of parts.query.split('&')) { const [k, v] = pair.split('='); if (decodeURIComponent(k || '') === name) return decodeURIComponent(v || ''); } return ''; }; /** Every failure of this surface is JSON — never the module-level HTML error page. */ const fail = (req, res, status, error) => sendJson(req, res, status, { ok: false, error }); async function handle(req, res, parts) { if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false; const scoped = parts.rawPath.startsWith('/skland/session/'); const path = scoped ? parts.rawPath.replace('/skland/session/', '/skland/') : parts.rawPath; const ownedPlayer = scoped ? sessionPlayer?.(req) : null; if (scoped && !ownedPlayer) { fail(req, res, 401, '游戏会话已失效,请重新连接 · Game session expired'); return true; } const allowed = (player) => scoped || (!player.startsWith(SESSION_BINDING_PREFIX) && !player.startsWith('@account/')); try { if (req.method === 'GET' && path === '/skland/status') { const player = ownedPlayer || queryParam(parts, 'player') || ''; if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; } sendJson(req, res, 200, service.status(player)); return true; } if (req.method === 'GET' && path === '/skland/roles') { const player = ownedPlayer || queryParam(parts, 'player') || ''; if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } const refresh = queryParam(parts, 'refresh') === '1'; if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; } try { sendJson(req, res, 200, { ok: true, roles: await service.roles(player, refresh) }); } catch (e) { sendJson(req, res, 200, { ok: false, error: e.message }); } return true; } if (req.method !== 'POST') { fail(req, res, 405, '不支持的请求方法 · Method not allowed'); return true; } let body; try { body = await readJsonBody(req); } catch { fail(req, res, 400, '请求体无效 · Bad request'); return true; } const player = ownedPlayer || String(body.player || ''); if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } if (path === '/skland/scan/start') { if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } try { const qr = await service.startScan(player); sendJson(req, res, 200, { ok: true, ...qr }); } catch (e) { sendJson(req, res, 200, { ok: false, error: e.message }); } return true; } if (path === '/skland/scan/poll') { if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } try { sendJson(req, res, 200, { ok: true, ...(await service.pollScan(player)) }); } catch (e) { // A poll that fails still names a terminal state: a caller stops on anything but 'waiting', and a bare // { ok: false } would leave it reading `state === undefined` (the panel showed "未知状态" for that). sendJson(req, res, 200, { ok: false, state: 'error', error: e.message }); } return true; } if (path === '/skland/phone/send') { if (!player || !body.phone) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } try { sendJson(req, res, 200, await service.sendPhoneCode(player, String(body.phone), body.captcha)); } catch { fail(req, res, 500, '服务器内部错误 · Internal error'); } return true; } if (path === '/skland/phone/login') { if (!player || !body.phone || !body.code) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } sendJson(req, res, 200, await service.loginByPhoneCode(player, String(body.phone), String(body.code))); return true; } if (path === '/skland/password/login') { if (!player || !body.phone || !body.password) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } sendJson(req, res, 200, await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, typeof body.captchaStep === 'string' ? body.captchaStep : undefined)); return true; } if (path === '/skland/unbind') { if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } sendJson(req, res, 200, service.unbind(player)); return true; } fail(req, res, 404, '未知的 /skland 接口 · Unknown endpoint'); } catch { fail(req, res, 500, '服务器内部错误 · Internal error'); } return true; } return handle; } /** * The handler mounted when the module is OFF. Without it the path would fall through to the static mount (a 404 for * GET) and to routes.js's GET/HEAD gate (a **405 HTML** for POST) — an inconsistent surface for one API. This one * answers every /skland/* request the same way, JSON like the enabled surface, so a client can always parse the * "not enabled" answer. * @returns {(req, res, parts: { rawPath: string }) => Promise} */ export function createSklandOffHandler() { return async (req, res, parts) => { if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false; sendJson(req, res, 404, { ok: false, error: '未启用森空岛绑定 · the Skland module is off (set SP_SKLAND=1)' }); return true; }; }