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
159 lines
8.2 KiB
JavaScript
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;
|
|
};
|
|
}
|