Files
ark-stronghold-protocol/server/skland/http.js
T
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

159 lines
8.2 KiB
JavaScript

// 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<typeof import('./service.js').createSklandService>, sessionPlayer?: Function }} deps
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
* parts: { rawPath: string, query: string }) => Promise<boolean>}
* 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<boolean>}
*/
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;
};
}