实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -19,9 +19,10 @@ import { hashedFileOpts } from './assetIndex.js';
|
||||
* `/media/~<hash>/bgm/act1` (a served manifest's `/assets/~<hash>/audio/bgm/act1.mp3`, public/js/media.js): static.js
|
||||
* strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes
|
||||
* (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it.
|
||||
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null }} [hashing]
|
||||
* @param {{ urlHash?: string | null, assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* isPrivate?: (file: string) => Promise<boolean> }} [hashing]
|
||||
*/
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) {
|
||||
export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null, isPrivate = async () => false } = {}) {
|
||||
const root = path.join(path.resolve(publicDir), 'assets', 'audio');
|
||||
const segments = String(rest || '').split('/').filter((s) => s.length > 0);
|
||||
if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
@@ -48,6 +49,7 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo
|
||||
stat = await fsp.stat(absPath);
|
||||
} catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
// serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared.
|
||||
// Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day),
|
||||
// or that of its content-hashed form.
|
||||
|
||||
+11
-2
@@ -2,6 +2,8 @@
|
||||
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
|
||||
//
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * /skland/* → the Skland module's own listener when it is enabled (server/skland/http.js; SP_SKLAND=1). It owns
|
||||
// its methods (POST), so it runs before the GET/HEAD gate below;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
|
||||
@@ -48,16 +50,23 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* rawPath: string, query: string) => Promise<void>,
|
||||
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean }} deps
|
||||
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean,
|
||||
* skland?: ((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* parts: { rawPath: string, query: string }) => Promise<boolean>) | null }} deps
|
||||
* trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY)
|
||||
* skland: the /skland/* handler when the module is enabled (server/skland/http.js; SP_SKLAND=1). It owns its paths
|
||||
* and its methods (POST), so it runs before the GET/HEAD method gate.
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
||||
*/
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) {
|
||||
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) {
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url || '/';
|
||||
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
||||
const parts = splitUrl(url);
|
||||
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (skland && (parts.rawPath === '/skland' || parts.rawPath.startsWith('/skland/'))) {
|
||||
if (await skland(req, res, parts)) return;
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
||||
|
||||
+14
-4
@@ -29,6 +29,7 @@
|
||||
// Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { MEDIA_PREFIX } from '../../shared/media.js';
|
||||
import { splitHashedPath } from '../../shared/assetPaths.js';
|
||||
@@ -62,7 +63,7 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object, privateFiles?: string[],
|
||||
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
||||
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} dirs
|
||||
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs);
|
||||
@@ -70,7 +71,14 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog,
|
||||
assetIndex = null, views = null }) {
|
||||
assetIndex = null, views = null, privateFiles = [] }) {
|
||||
const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
|
||||
const privatePaths = new Set(privateFiles.map(canonical));
|
||||
for (const file of privateFiles) {
|
||||
try { privatePaths.add(canonical(realpathSync(file))); } catch { /* Side files may not exist yet. */ }
|
||||
}
|
||||
const isPrivate = async (file) => privatePaths.size > 0 && (privatePaths.has(canonical(file))
|
||||
|| privatePaths.has(canonical(await fsp.realpath(file))));
|
||||
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
@@ -107,13 +115,13 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; }
|
||||
const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null;
|
||||
const st = abs ? await fsp.stat(abs).catch(() => null) : null;
|
||||
if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
|
||||
if (!st || !st.isFile() || await isPrivate(abs)) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
|
||||
await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex });
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex, isPrivate });
|
||||
return;
|
||||
}
|
||||
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
|
||||
@@ -137,6 +145,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
return;
|
||||
}
|
||||
let absPath = path.join(mount.dir, ...segments);
|
||||
if (privatePaths.has(canonical(absPath))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
let stat;
|
||||
@@ -183,6 +192,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
}
|
||||
const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]);
|
||||
const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {});
|
||||
if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts);
|
||||
};
|
||||
}
|
||||
+55
-3
@@ -17,6 +17,9 @@
|
||||
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
||||
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
|
||||
// WebSocket, since the start (GET /healthz?traffic=1)
|
||||
// skland/ the opt-in Skland account binding (SP_SKLAND=1): /skland/* — QR / 验证码 / 密码 login, one
|
||||
// SQLite row per player. server/skland/http.js is handed to routes.js as its own listener (it
|
||||
// owns POST); docs/skland.md
|
||||
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
||||
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
||||
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
||||
@@ -29,6 +32,7 @@
|
||||
// The server only auto-listens when this file is the process entry point.
|
||||
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { getData, loadData } from './data.js';
|
||||
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
|
||||
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
||||
@@ -42,6 +46,13 @@ import { createAssetIndex } from './http/assetIndex.js';
|
||||
import { createManifestViews } from './http/assetViews.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
||||
import { createSklandStore } from './skland/store.js';
|
||||
import { createSklandService } from './skland/service.js';
|
||||
import { createSklandHttp, createSklandOffHandler } from './skland/http.js';
|
||||
import { createSessionPlayerResolver } from './skland/session.js';
|
||||
import { createAccountAuth } from './skland/auth.js';
|
||||
import { createAccountHttp } from './skland/accountHttp.js';
|
||||
import { createAccountRecovery } from './skland/recovery.js';
|
||||
|
||||
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
|
||||
export {
|
||||
@@ -60,8 +71,12 @@ export {
|
||||
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
||||
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
|
||||
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
|
||||
* skland?: boolean | { storeFile?: string, fetchImpl?: typeof fetch, client?: object, scanTimeoutMs?: number,
|
||||
* authSecret?: string, publicOrigin?: string, diagnosticKey?: string, authNow?: () => number },
|
||||
* }} [opts]
|
||||
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
|
||||
* skland: the Skland account binding (server/skland/), off by default (SP_SKLAND=1 turns it on). `storeFile` picks
|
||||
* the SQLite file (SP_SKLAND_DB, default data/skland.db); `fetchImpl` replaces the upstream HTTP (tests).
|
||||
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
@@ -83,14 +98,20 @@ export async function startServer(opts = {}) {
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
||||
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
|
||||
// The Skland account binding (server/skland/): off unless asked for (SP_SKLAND=1, or opts.skland). Its own SQLite
|
||||
// file (SP_SKLAND_DB, default <dataDir>/skland.db) holds one row per player; the routes are /skland/* (POST).
|
||||
const skland = createSklandModule(opts, { log, registry, network, lobby });
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views,
|
||||
privateFiles: skland?.privateFiles || [] });
|
||||
// Off still owns /skland/*: one uniform JSON 404 beats a static 404 for GET and a 405 for POST (skland/http.js).
|
||||
const sklandHttp = skland ? skland.http : createSklandOffHandler();
|
||||
const startedAt = Date.now();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
resetBuildTag();
|
||||
buildTag();
|
||||
|
||||
const traffic = createTrafficCounter();
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy }));
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy, skland: sklandHttp }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log, traffic });
|
||||
|
||||
@@ -142,6 +163,7 @@ export async function startServer(opts = {}) {
|
||||
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
|
||||
network.close();
|
||||
assetIndex?.close();
|
||||
try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); }
|
||||
await new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
server.closeIdleConnections?.();
|
||||
@@ -152,7 +174,37 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, skland: skland ? skland.service : null, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skland module (server/skland/), or null when it is off. Off is the default: an unconfigured server keeps no
|
||||
* account credentials at all. Turn it on with `SP_SKLAND=1` (or `opts.skland`), which every caller of startServer can
|
||||
* pass — the HTTP tests inject their own store file and a stub `fetchImpl`, so nothing here reaches the network.
|
||||
* @param {Record<string, any>} opts
|
||||
* @param {{ log: object, registry: import('./net.js').SessionRegistry, network: import('./net.js').Network }} deps
|
||||
* @returns {{ service: any, http: any, privateFiles: string[], close: () => void } | null}
|
||||
*/
|
||||
function createSklandModule(opts, { log, registry, network, lobby }) {
|
||||
const cfg = opts.skland;
|
||||
const enabled = !!cfg || process.env.SP_SKLAND === '1';
|
||||
if (!enabled) return null;
|
||||
const conf = typeof cfg === 'object' ? cfg : {};
|
||||
const file = conf.storeFile || process.env.SP_SKLAND_DB || path.join(ROOT, 'data', 'skland.db');
|
||||
const store = createSklandStore({ file, log });
|
||||
const service = createSklandService({ store, fetchImpl: conf.fetchImpl, client: conf.client, scanTimeoutMs: conf.scanTimeoutMs });
|
||||
const auth = createAccountAuth({ file: store.file, service, registry, secret: conf.authSecret || process.env.SP_AUTH_SECRET,
|
||||
publicOrigin: conf.publicOrigin || process.env.SP_PUBLIC_ORIGIN, diagnosticKey: conf.diagnosticKey || process.env.SP_SKLAND_DIAGNOSTIC_KEY,
|
||||
trustProxy: network.opts.trustProxy, now: conf.authNow, onExpire: (session) => lobby.onExpire(session) });
|
||||
network.accountPolicy = auth.gamePolicy;
|
||||
log.info?.(`[skland] account binding on — ${store.file}`);
|
||||
return {
|
||||
service,
|
||||
http: createAccountHttp({ service, auth, registry, recovery: createAccountRecovery({ registry, lobby }),
|
||||
diagnostic: createSklandHttp({ service, sessionPlayer: createSessionPlayerResolver(registry) }) }),
|
||||
privateFiles: store.file === ':memory:' ? [] : [store.file, store.file + '-wal', store.file + '-shm'],
|
||||
close: () => { auth.close(); store.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
|
||||
@@ -184,6 +184,16 @@ export class SessionRegistry {
|
||||
if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token);
|
||||
}
|
||||
|
||||
/** Rotate an authenticated session's bearer after attaching a new socket. @param {Session} s */
|
||||
rotateToken(s) {
|
||||
const previous = s.token;
|
||||
if (this.byTokenMap.get(previous) === s) this.byTokenMap.delete(previous);
|
||||
let token;
|
||||
do token = newToken(); while (token === previous || this.byTokenMap.has(token));
|
||||
s.token = token;
|
||||
this.byTokenMap.set(token, s);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer
|
||||
* (the lobby may only extend it, never shorten it).
|
||||
@@ -526,6 +536,8 @@ export class Network {
|
||||
/** @type {Map<string, number>} open sockets per client network key */
|
||||
this.connsPerKey = new Map();
|
||||
this.closed = false;
|
||||
// Optional fork account policy; transport and the upstream game protocol remain usable when it is absent.
|
||||
this.accountPolicy = null;
|
||||
this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);
|
||||
this.heartbeatTimer.unref?.();
|
||||
const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4)));
|
||||
@@ -559,6 +571,7 @@ export class Network {
|
||||
if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; }
|
||||
const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts);
|
||||
this.conns.set(ws, conn);
|
||||
if (this.accountPolicy) conn.account = this.accountPolicy.connection(req);
|
||||
if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1);
|
||||
ws.on('message', (data, isBinary) => {
|
||||
try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); }
|
||||
@@ -612,6 +625,9 @@ export class Network {
|
||||
}
|
||||
if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; }
|
||||
if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; }
|
||||
if (this.accountPolicy && !this.accountPolicy.authorized(conn)) {
|
||||
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account binding required')); return;
|
||||
}
|
||||
if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; }
|
||||
|
||||
let res;
|
||||
@@ -640,6 +656,11 @@ export class Network {
|
||||
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`));
|
||||
return;
|
||||
}
|
||||
if (this.accountPolicy) {
|
||||
const account = this.accountPolicy.hello(conn, msg);
|
||||
if (!account) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account authentication required')); return; }
|
||||
msg = { ...msg, ...account };
|
||||
}
|
||||
const name = sanitizeName(msg.name);
|
||||
if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; }
|
||||
|
||||
@@ -670,6 +691,7 @@ export class Network {
|
||||
session.lastSeen = now;
|
||||
session.addr = conn.ip;
|
||||
session.limitKey = conn.key;
|
||||
this.accountPolicy?.attached?.(conn, { repeat });
|
||||
|
||||
let extra = null;
|
||||
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { TokenBucket, isLocalIp, sanitizeName } from '../net.js';
|
||||
import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js';
|
||||
|
||||
export const ACCOUNT_PREFIX = '@account/';
|
||||
export const ACCOUNT_COOKIE = 'sp_account';
|
||||
export const LOGIN_TTL = 30 * 24 * 60 * 60;
|
||||
const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game';
|
||||
const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b);
|
||||
// 128 digest bits in base64url keep `n:<playerId>` within g.watch's existing 32-character field limit.
|
||||
export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url');
|
||||
|
||||
export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) {
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec(`PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_logins (
|
||||
id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0
|
||||
);`);
|
||||
if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes');
|
||||
db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url'));
|
||||
const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value);
|
||||
const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null;
|
||||
const signature = (value) => createHmac('sha256', key).update(value).digest();
|
||||
const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName);
|
||||
const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null;
|
||||
const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000)
|
||||
&& service.status(ACCOUNT_PREFIX + login.subject).bound;
|
||||
const buckets = new Map();
|
||||
function limited(req) {
|
||||
const id = req.socket?.remoteAddress || '?';
|
||||
if (!buckets.has(id)) {
|
||||
if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value);
|
||||
buckets.set(id, new TokenBucket(3, 120, now()));
|
||||
}
|
||||
return !buckets.get(id).take(now());
|
||||
}
|
||||
function originAllowed(req) {
|
||||
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
|
||||
if (!req.headers.origin) return true; // Non-browser clients still need both credentials.
|
||||
const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress))
|
||||
&& req.headers['x-forwarded-proto'] === 'https');
|
||||
return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`);
|
||||
}
|
||||
function cookieValue(req) {
|
||||
const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '='));
|
||||
if (entries.length > 1) throw new Error('invalid account cookie');
|
||||
return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null;
|
||||
}
|
||||
function verify(token) {
|
||||
try {
|
||||
if (typeof token !== 'string' || token.length > 4096) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
|
||||
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
|
||||
if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null;
|
||||
if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null;
|
||||
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
const at = Math.floor(now() / 1000);
|
||||
if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at
|
||||
|| !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL
|
||||
|| typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null;
|
||||
const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti);
|
||||
return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function authenticate(req) {
|
||||
const token = cookieValue(req);
|
||||
if (!token) return null;
|
||||
const login = verify(token);
|
||||
if (!login) throw new Error('invalid account cookie');
|
||||
return login;
|
||||
}
|
||||
function setCookie(req, res, login) {
|
||||
const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:')
|
||||
|| (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https');
|
||||
let value = '', maxAge = 0;
|
||||
if (login) {
|
||||
const at = Math.floor(now() / 1000);
|
||||
const head = json64({ alg: 'HS256', typ: 'JWT' });
|
||||
const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id,
|
||||
iat: at, exp: login.expires, role: login.role_key });
|
||||
value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url');
|
||||
maxAge = Math.max(0, login.expires - at);
|
||||
}
|
||||
res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`);
|
||||
}
|
||||
function publish(player) {
|
||||
const row = service._store.get(player);
|
||||
if (!row || row.status !== 'bound') return null;
|
||||
const roles = service.status(player).roles.filter(playable);
|
||||
const selected = roles.find((role) => role.isDefault) || roles[0];
|
||||
const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null,
|
||||
expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 };
|
||||
const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row;
|
||||
service._store.update(ACCOUNT_PREFIX + login.subject, credentials);
|
||||
db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)')
|
||||
.run(login.id, login.subject, login.role_key, login.expires);
|
||||
service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout.
|
||||
return login;
|
||||
}
|
||||
function select(login, selectedKey) {
|
||||
const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey);
|
||||
if (!role) return null;
|
||||
const old = roleOf(login), session = old && registry.byId(accountPlayerId(old));
|
||||
if (session?.roomCode && selectedKey !== login.role_key) return null;
|
||||
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
|
||||
return { ...login, role_key: selectedKey };
|
||||
}
|
||||
function ensureSelection(login) {
|
||||
if (roleOf(login)) return login;
|
||||
const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable);
|
||||
const role = roles.find((item) => item.isDefault) || roles[0];
|
||||
const selectedKey = role ? roleKey(role) : null;
|
||||
if (selectedKey === login.role_key) return login;
|
||||
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
|
||||
return { ...login, role_key: selectedKey };
|
||||
}
|
||||
function state(login) {
|
||||
const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login);
|
||||
return { ...status, loginId: login.id, selectedRoleKey: login.role_key,
|
||||
gamePlayerId: role ? accountPlayerId(role) : null };
|
||||
}
|
||||
function roleSession(role) {
|
||||
const id = accountPlayerId(role);
|
||||
let session = registry.byId(id);
|
||||
if (session && registry.isExpired(session, registry.now())) {
|
||||
registry.remove(session);
|
||||
onExpire?.(session); // Remove the old seat before reusing its stable account playerId.
|
||||
session = null;
|
||||
}
|
||||
if (!session) {
|
||||
session = registry.create(sanitizeName(role.nickName));
|
||||
if (!session) throw new Error('server full');
|
||||
registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
function revoke(login) {
|
||||
db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject);
|
||||
service.unbind(ACCOUNT_PREFIX + login.subject);
|
||||
}
|
||||
const gamePolicy = {
|
||||
connection(req) {
|
||||
let login = null;
|
||||
try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ }
|
||||
return { login, allowed: originAllowed(req) };
|
||||
},
|
||||
hello(conn, message) {
|
||||
if (!conn.account.allowed) return null;
|
||||
const login = conn.account.login;
|
||||
if (login) {
|
||||
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id);
|
||||
const role = active(current) && current.role_key === login.role_key && roleOf(current);
|
||||
const target = conn.session || (message.token && registry.byToken(message.token));
|
||||
// Merely restoring the cookie or viewing the title must not take over another device. A current role token
|
||||
// resumes normally; the authenticated enter/resume POST supplies it after an explicit user action.
|
||||
if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token };
|
||||
}
|
||||
// Anonymous hellos may establish transport for login, but never resume an authenticated game identity.
|
||||
if (conn.session?.playerId.startsWith('a_')) return null;
|
||||
const target = message.token && registry.byToken(message.token);
|
||||
return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token };
|
||||
},
|
||||
attached(conn, { repeat }) {
|
||||
if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session);
|
||||
},
|
||||
authorized(conn) {
|
||||
const captured = conn.account?.login;
|
||||
if (!captured) return false;
|
||||
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id);
|
||||
const role = active(current) && current.role_key === captured.role_key && roleOf(current);
|
||||
return !!role && conn.session?.playerId === accountPlayerId(role);
|
||||
},
|
||||
};
|
||||
return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited,
|
||||
gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; },
|
||||
diagnosticAllowed(req) {
|
||||
const supplied = req.headers['x-sp-diagnostic-key'];
|
||||
return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey));
|
||||
},
|
||||
close() { db.close(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an
|
||||
// (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of
|
||||
// errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the
|
||||
// "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads)
|
||||
// injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here
|
||||
// touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland
|
||||
// endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js.
|
||||
//
|
||||
// The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service:
|
||||
// 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred
|
||||
// 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred
|
||||
// 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list
|
||||
//
|
||||
// A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant
|
||||
// even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification.
|
||||
// Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14).
|
||||
//
|
||||
// SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and
|
||||
// returns its proof to the challenged step; the server never solves the challenge or exposes account tokens.
|
||||
//
|
||||
// Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired →
|
||||
// re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds.
|
||||
|
||||
import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js';
|
||||
|
||||
const AS_BASE = 'https://as.hypergryph.com';
|
||||
const ZONAI_BASE = 'https://zonai.skland.com';
|
||||
const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com';
|
||||
// Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14).
|
||||
const BINDING_APP_CODE = 'be36d44aa36bfb5b';
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */
|
||||
export class SklandError extends Error {
|
||||
/**
|
||||
* @param {string} message @param {number | string} [code] @param {string} [step]
|
||||
*/
|
||||
constructor(message, code, step) {
|
||||
super(message);
|
||||
this.name = 'SklandError';
|
||||
this.code = code;
|
||||
this.step = step;
|
||||
}
|
||||
}
|
||||
|
||||
/** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */
|
||||
export class SklandCaptchaError extends SklandError {
|
||||
/**
|
||||
* @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha
|
||||
*/
|
||||
constructor(captcha, step = 'sendPhoneCode') {
|
||||
super('需要人机验证,请完成验证后重试', 'captcha', step);
|
||||
this.name = 'SklandCaptchaError';
|
||||
this.captcha = captcha;
|
||||
}
|
||||
}
|
||||
|
||||
/** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */
|
||||
export class SklandNeedDeviceError extends SklandError {
|
||||
constructor(message = '当前设备需要额外验证', step = 'getGrantCode') {
|
||||
super(`${message},请使用验证码或扫码完成登录`, 109, step);
|
||||
this.name = 'SklandNeedDeviceError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(res, step) {
|
||||
try { return await res.json(); } catch {
|
||||
throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step);
|
||||
}
|
||||
}
|
||||
|
||||
/** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */
|
||||
function assertAs(json, step, captchaStep = step) {
|
||||
if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep);
|
||||
if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step);
|
||||
if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step);
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
/** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */
|
||||
function assertZonai(json, step) {
|
||||
if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step);
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* fetchImpl?: typeof fetch,
|
||||
* baseHeaders?: Record<string, string>,
|
||||
* pollIntervalMs?: number,
|
||||
* pollTimeoutMs?: number,
|
||||
* sleep?: (ms: number) => Promise<void>,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function createSklandClient(opts = {}) {
|
||||
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
||||
const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
||||
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||
const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) };
|
||||
|
||||
async function post(url, body, extraHeaders) {
|
||||
return fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 扫码登录 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link),
|
||||
* not the scanId — the 森空岛 app's scanner reads the deep link.
|
||||
* @returns {Promise<{ scanId: string, scanUrl: string }>}
|
||||
*/
|
||||
async function genScan() {
|
||||
const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE });
|
||||
const json = await readJson(res, '获取扫码会话');
|
||||
const data = assertAs(json, '获取扫码会话');
|
||||
return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it).
|
||||
* The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation).
|
||||
* Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14).
|
||||
* Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors.
|
||||
* @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>}
|
||||
*/
|
||||
async function checkScan(scanId) {
|
||||
const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders });
|
||||
const json = await readJson(res, '查询扫码状态');
|
||||
if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode };
|
||||
if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 };
|
||||
throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 (looping): poll until the phone confirms, or the timeout passes.
|
||||
* @param {string} scanId
|
||||
* @param {{ onWait?: (state: string) => void }} [o]
|
||||
* @returns {Promise<string>} the scanCode
|
||||
*/
|
||||
async function pollScan(scanId, o = {}) {
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
for (;;) {
|
||||
const { state, scanCode } = await checkScan(scanId);
|
||||
if (state === 'confirmed') return scanCode;
|
||||
if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan');
|
||||
o.onWait?.(state);
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 3: scanCode → { token, hgId?, deviceToken? }. */
|
||||
async function tokenByScanCode(scanCode) {
|
||||
const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`,
|
||||
{ appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders());
|
||||
const json = await readJson(res, '扫码换取 token');
|
||||
const data = assertAs(json, '扫码换取 token');
|
||||
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
||||
}
|
||||
|
||||
// ── 验证码登录 ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center:
|
||||
* https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14).
|
||||
* Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors;
|
||||
* retry the latter with the solved `captcha` fields.
|
||||
* @param {string} phone
|
||||
* @param {Record<string, string>} [captcha] Geetest v3/v4 proof supplied by the browser
|
||||
* @returns {Promise<{ ok: true }>}
|
||||
*/
|
||||
async function sendPhoneCode(phone, captcha) {
|
||||
const body = { phone, type: 2 };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body);
|
||||
const json = await readJson(res, '发送验证码');
|
||||
if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha);
|
||||
if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode');
|
||||
assertAs(json, '发送验证码');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */
|
||||
async function tokenByPhoneCode(phone, code) {
|
||||
const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`,
|
||||
{ appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders());
|
||||
const json = await readJson(res, '验证码换取 token');
|
||||
const data = assertAs(json, '验证码换取 token');
|
||||
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
||||
}
|
||||
|
||||
// ── 密码登录 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码 → { token }; a deviceToken is not required locally to try the following grant. */
|
||||
async function tokenByPhonePassword(phone, password, captcha) {
|
||||
const body = { phone, password };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders());
|
||||
const json = await readJson(res, '密码换取 token');
|
||||
const data = assertAs(json, '密码换取 token', 'password');
|
||||
if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword');
|
||||
return { token: data.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Arknights account bindings without a Skland account or cred.
|
||||
* Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak').
|
||||
* Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14).
|
||||
*/
|
||||
async function getAccountBinding(accessToken, captcha) {
|
||||
const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 };
|
||||
if (captcha) body.captcha = captcha;
|
||||
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`,
|
||||
body, deviceHeaders());
|
||||
const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant');
|
||||
if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding');
|
||||
const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' });
|
||||
const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders });
|
||||
const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定');
|
||||
if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding');
|
||||
return binding;
|
||||
}
|
||||
|
||||
// ── token → 授权码 → cred → 绑定 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* access_token (+ deviceToken when the login path provided one) → grant code.
|
||||
* @param {string} accessToken
|
||||
* @param {string | null} [deviceToken]
|
||||
* @returns {Promise<string>} the grant code
|
||||
*/
|
||||
async function getGrantCode(accessToken, deviceToken = null) {
|
||||
const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 };
|
||||
if (deviceToken) body.deviceToken = deviceToken;
|
||||
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body);
|
||||
const json = await readJson(res, '换取授权码');
|
||||
return assertAs(json, '换取授权码').code;
|
||||
}
|
||||
|
||||
/** grant code → { cred, credToken } (this endpoint takes no signature). */
|
||||
async function getCred(code) {
|
||||
const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 });
|
||||
const json = await readJson(res, '换取 cred');
|
||||
const data = assertZonai(json, '换取 cred');
|
||||
return { cred: data.cred, credToken: data.token };
|
||||
}
|
||||
|
||||
/** A signed GET on zonai (the signature lives in crypto.js). */
|
||||
async function signedGet(path, query, cred, credToken, dId) {
|
||||
const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query });
|
||||
const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } });
|
||||
return readJson(res, path);
|
||||
}
|
||||
|
||||
/** A signed POST on zonai (attendance etc.). */
|
||||
async function signedPost(path, body, cred, credToken, dId) {
|
||||
const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body });
|
||||
const res = await post(`${ZONAI_BASE}${path}`, body, headers);
|
||||
return readJson(res, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's bound games / roles, refreshing the credential once when the upstream says it expired.
|
||||
* @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c
|
||||
* @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials
|
||||
*/
|
||||
async function getBinding(c) {
|
||||
let { cred, credToken } = c;
|
||||
const dId = c.dId;
|
||||
let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
if (json.code === 10000) {
|
||||
const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred });
|
||||
const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token');
|
||||
credToken = data.token;
|
||||
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
}
|
||||
if (json.code === 10002 && (c.accessToken || c.deviceToken)) {
|
||||
const grantCode = await getGrantCode(c.accessToken, c.deviceToken);
|
||||
const fresh = await getCred(grantCode);
|
||||
cred = fresh.cred;
|
||||
credToken = fresh.credToken;
|
||||
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const message = json.message || json.msg || '';
|
||||
if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding');
|
||||
throw new SklandError(message || '查询绑定失败', json.code, 'getBinding');
|
||||
}
|
||||
return { binding: json.data, cred, credToken };
|
||||
}
|
||||
|
||||
// ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ──────────────────────────────────
|
||||
|
||||
/** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */
|
||||
async function loginByScan({ onWait } = {}) {
|
||||
const { scanId, scanUrl } = await genScan();
|
||||
const scanCode = await pollScan(scanId, { onWait });
|
||||
const auth = await tokenByScanCode(scanCode);
|
||||
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await getCred(grantCode);
|
||||
return { scanId, scanUrl, ...auth, cred, credToken };
|
||||
}
|
||||
|
||||
/** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */
|
||||
async function loginByPhoneCode(phone, code) {
|
||||
const auth = await tokenByPhoneCode(phone, code);
|
||||
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await getCred(grantCode);
|
||||
return { ...auth, cred, credToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Password → HG account token → binding-service token (type 1) → Arknights roles.
|
||||
* This flow does not acquire a Skland cred or require a stored deviceToken.
|
||||
*/
|
||||
async function loginByPhonePassword(phone, password) {
|
||||
const { token } = await tokenByPhonePassword(phone, password);
|
||||
const binding = await getAccountBinding(token);
|
||||
return { token, binding };
|
||||
}
|
||||
|
||||
return {
|
||||
genScan, checkScan, pollScan, tokenByScanCode,
|
||||
sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword,
|
||||
getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost,
|
||||
loginByScan, loginByPhoneCode, loginByPhonePassword,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// server/skland/crypto.js — the Skland API request signature (docs/PACKS.md §4 keeps packs data-only; this module is
|
||||
// server code, not a pack). Same algorithm the community clients use, reconstructed from the public descriptions:
|
||||
//
|
||||
// header_ca = { platform: '3', timestamp: '<seconds>', dId: '<device id>', vName: '1.0.0' }
|
||||
// GET: raw = path + query + timestamp + compactJson(header_ca)
|
||||
// POST: raw = path + compactJson(body) + timestamp + compactJson(header_ca)
|
||||
// sign = md5( hmacSha256Hex(credToken, raw) )
|
||||
//
|
||||
// Only Web Crypto / node:crypto are used; no third-party dependency. Pure functions — fully unit-tested in
|
||||
// test/skland/crypto.test.js against pinned vectors.
|
||||
|
||||
import { createHmac, createHash, randomUUID, randomBytes } from 'node:crypto';
|
||||
|
||||
/** The Skland mobile client's appCode (public SDK constant, visible in decompiled clients). */
|
||||
export const SKLAND_APP_CODE = '4ca99fa6b56cc2ba';
|
||||
/** User-Agent the upstream expects (a Skland Android client). */
|
||||
export const SKLAND_UA = 'Skland/1.32.1 (com.hypergryph.skland; build:103201004; Android 33; ) Okhttp/4.11.0';
|
||||
|
||||
/** Compact JSON: no spaces, key order preserved (the upstream signs the exact body bytes). */
|
||||
export function compactJson(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the signed header set for one Skland API request.
|
||||
* @param {{
|
||||
* cred: string, credToken: string, dId: string,
|
||||
* method: 'GET' | 'POST', path: string,
|
||||
* query?: string, body?: object | string,
|
||||
* timestampSeconds?: number,
|
||||
* }} p
|
||||
* @returns {{ headers: Record<string, string>, signInput: string }}
|
||||
*/
|
||||
export function signedHeaders(p) {
|
||||
const timestampSeconds = p.timestampSeconds ?? Math.floor(Date.now() / 1000) - 2;
|
||||
const ts = String(timestampSeconds);
|
||||
const headerCa = { platform: '3', timestamp: ts, dId: p.dId, vName: '1.0.0' };
|
||||
const caJson = compactJson(headerCa);
|
||||
const method = p.method.toUpperCase();
|
||||
const bodyJson = method === 'POST'
|
||||
? compactJson(p.body ?? {})
|
||||
: '';
|
||||
const query = p.query || '';
|
||||
const signInput = method === 'POST'
|
||||
? `${p.path}${bodyJson}${ts}${caJson}`
|
||||
: `${p.path}${query}${ts}${caJson}`;
|
||||
const sign = md5(hmacSha256Hex(p.credToken, signInput));
|
||||
return {
|
||||
signInput,
|
||||
headers: {
|
||||
'User-Agent': SKLAND_UA,
|
||||
'Accept-Encoding': 'gzip',
|
||||
Connection: 'close',
|
||||
'X-Requested-With': 'com.hypergryph.skland',
|
||||
cred: p.cred,
|
||||
dId: p.dId,
|
||||
platform: '3',
|
||||
timestamp: ts,
|
||||
vName: '1.0.0',
|
||||
sign,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** HMAC-SHA256, lowercase hex digest. */
|
||||
export function hmacSha256Hex(key, message) {
|
||||
return createHmac('sha256', key).update(message, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** MD5, lowercase hex digest. */
|
||||
export function md5(text) {
|
||||
return createHash('md5').update(text, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* A device fingerprint (`dId`). The upstream accepts any stable opaque value here; the community clients derive
|
||||
* one from a random UUID, cache it and reuse it for the account's lifetime. `B` + 32 hex mirrors their format.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function makeDeviceId() {
|
||||
return `B${randomBytes(16).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** A fresh scan session id placeholder (not a real upstream value — tests only). */
|
||||
export function randomId() {
|
||||
return randomUUID().replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The account/login device headers (as.hypergryph.com 验证码 / 密码 / 扫码登录都要求):
|
||||
* `X-DeviceId` random ~10 chars, `X-DeviceModel` a device name, `X-DeviceType: 2` (mobile).
|
||||
* The device id is stable per process so the upstream sees one device for the server's lifetime.
|
||||
* @returns {{ 'X-DeviceId': string, 'X-DeviceModel': string, 'X-DeviceType': string }}
|
||||
*/
|
||||
export function deviceHeaders() {
|
||||
if (!deviceHeaders.cache) {
|
||||
deviceHeaders.cache = Object.freeze({
|
||||
'X-DeviceId': randomBytes(5).toString('hex'), // 10 hex chars
|
||||
'X-DeviceModel': 'Stronghold-Protocol Server',
|
||||
'X-DeviceType': '2',
|
||||
});
|
||||
}
|
||||
return deviceHeaders.cache;
|
||||
}
|
||||
deviceHeaders.cache = null;
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Account recovery describes only the selected role's live server-side seat. It never reads browser caches.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function createAccountRecovery({ registry, lobby }) {
|
||||
const roomIds = new WeakMap();
|
||||
return {
|
||||
describe(playerId) {
|
||||
const session = playerId && registry.byId(playerId);
|
||||
if (!session || registry.isExpired(session, registry.now())) return null;
|
||||
const room = lobby.roomOf(session);
|
||||
if (!room || room.disposed) return null;
|
||||
if (!roomIds.has(room)) roomIds.set(room, randomUUID());
|
||||
return {
|
||||
// A selector, not a credential: a replaced room or a later match must not satisfy a stale recovery request.
|
||||
id: roomIds.get(room) + ':' + room.matchCount,
|
||||
name: session.name, code: room.code, mode: room.mode, difficulty: room.difficulty,
|
||||
inMatch: !!room.match, round: room.match?.round ?? 0,
|
||||
connected: session.connected, spectating: !!room.spectatorOf(playerId),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// server/skland/service.js — per-player Arknights account bindings on top of the client (client.js) and the
|
||||
// (i18n-ignore-file: the Chinese strings are error/diagnostic text (an upstream failure's message, a login that did
|
||||
// not finish) returned as `{ ok: false, error }` — docs/I18N.md's "Error messages are not player text"; the panel
|
||||
// supplies the wording a player reads)
|
||||
// store (store.js). One instance per server. The scan sessions live in memory (a QR code is transient); everything
|
||||
// durable lives in the store.
|
||||
//
|
||||
// HTTP uses transport-scoped pending rows, then JWT-owned account rows after provider confirmation.
|
||||
// Diagnostic ids require an administrator key and cannot access either reserved namespace (docs/skland.md §5).
|
||||
|
||||
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from './client.js';
|
||||
import { makeDeviceId } from './crypto.js';
|
||||
|
||||
/** Extracts a readable profile name from the binding answer (an arknights channel first, then any). */
|
||||
function nicknameFromBinding(binding) {
|
||||
const lists = binding?.list || [];
|
||||
const ordered = [...lists.filter((l) => l?.appCode === 'arknights'), ...lists.filter((l) => l?.appCode !== 'arknights')];
|
||||
for (const list of ordered) {
|
||||
for (const b of list?.bindingList || []) {
|
||||
if (b.nickName) return b.nickName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The arknights characters of the binding answer, as a stable shape (every field always present, so JSON keeps it).
|
||||
* `uid` is the in-game account id, `channelName` the channel, `isDefault` its flag, `gameId` the game's own id. */
|
||||
function arknightsRoles(binding) {
|
||||
const roles = [];
|
||||
for (const list of binding?.list || []) {
|
||||
if (list?.appCode !== 'arknights') continue;
|
||||
for (const b of list?.bindingList || []) {
|
||||
roles.push({
|
||||
uid: b.uid == null ? null : String(b.uid),
|
||||
nickName: b.nickName ?? null,
|
||||
channelName: b.channelName ?? null,
|
||||
isDefault: b.is_default === true || b.isDefault === true,
|
||||
gameId: b.gameId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* store: ReturnType<typeof import('./store.js').createSklandStore>,
|
||||
* client?: ReturnType<typeof import('./client.js').createSklandClient>,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* scanTimeoutMs?: number,
|
||||
* }} p
|
||||
*/
|
||||
export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) {
|
||||
const skland = client || createSklandClient({ fetchImpl });
|
||||
/** @type {Map<string, { scanId: string, scanUrl: string, createdAt: number, attempt: object, polling: boolean }>} in-memory scan sessions by player */
|
||||
const scans = new Map();
|
||||
// Resume the same grant after its captcha; never cache passwords or repeat a successful password exchange.
|
||||
const pendingPasswordGrants = new Map();
|
||||
const attempts = new Map();
|
||||
function begin(playerId) {
|
||||
const attempt = {};
|
||||
attempts.set(playerId, attempt); scans.delete(playerId);
|
||||
return attempt;
|
||||
}
|
||||
const current = (playerId, attempt) => attempts.get(playerId) === attempt;
|
||||
function checkCurrent(playerId, attempt) {
|
||||
if (!current(playerId, attempt)) {
|
||||
const error = new Error('登录已取消,请重试');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function retire(playerId, attempt) { if (current(playerId, attempt)) attempts.delete(playerId); }
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
/** The player's row, created when missing. */
|
||||
function row(playerId) {
|
||||
store.ensure(playerId);
|
||||
return store.get(playerId);
|
||||
}
|
||||
|
||||
/** GET /skland/status body. */
|
||||
function status(playerId) {
|
||||
const r = row(playerId);
|
||||
const roles = r.binding ? arknightsRoles(r.binding) : [];
|
||||
return {
|
||||
playerId,
|
||||
status: r.status,
|
||||
bound: r.status === 'bound',
|
||||
nickname: r.nickname,
|
||||
hgId: r.hg_id,
|
||||
roles,
|
||||
error: r.error || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts (or restarts) a QR login session for the player. The QR code encodes `scanUrl`. */
|
||||
async function startScan(playerId) {
|
||||
const attempt = begin(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
try {
|
||||
const { scanId, scanUrl } = await skland.genScan();
|
||||
checkCurrent(playerId, attempt);
|
||||
scans.set(playerId, { scanId, scanUrl, createdAt: now(), attempt, polling: false });
|
||||
store.update(playerId, { status: 'scanning', error: null });
|
||||
return { scanId, scanUrl, expiresInMs: scanTimeoutMs };
|
||||
} catch (error) { retire(playerId, attempt); throw error; }
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll of the player's scan session. When the phone confirmed, the rest of the chain runs and the player is
|
||||
* bound — the same call that reports `confirmed` also completes the login.
|
||||
* @returns {Promise<{ state: 'none' | 'waiting' | 'confirmed' | 'expired', scanUrl?: string, error?: string,
|
||||
* awaitingConfirmation?: boolean, status?: string, nickname?: string | null }>}
|
||||
*/
|
||||
async function pollScan(playerId) {
|
||||
const session = scans.get(playerId);
|
||||
if (!session) return { state: 'none' };
|
||||
if (now() - session.createdAt > scanTimeoutMs) {
|
||||
scans.delete(playerId);
|
||||
retire(playerId, session.attempt);
|
||||
store.update(playerId, { status: 'unbound', error: '扫码超时' });
|
||||
return { state: 'expired', error: '扫码超时,请重新获取二维码' };
|
||||
}
|
||||
if (session.polling) return { state: 'waiting', scanUrl: session.scanUrl };
|
||||
session.polling = true;
|
||||
let confirmed = false;
|
||||
try {
|
||||
const { state, scanCode, awaitingConfirmation } = await skland.checkScan(session.scanId);
|
||||
checkCurrent(playerId, session.attempt);
|
||||
if (state === 'waiting') return { state, scanUrl: session.scanUrl, awaitingConfirmation: !!awaitingConfirmation };
|
||||
confirmed = true;
|
||||
const auth = await skland.tokenByScanCode(scanCode);
|
||||
await finishLogin(playerId, auth, { attempt: session.attempt });
|
||||
const r = row(playerId);
|
||||
scans.delete(playerId); retire(playerId, session.attempt);
|
||||
return { state: 'confirmed', status: r.status, nickname: r.nickname };
|
||||
} catch (e) {
|
||||
if (!current(playerId, session.attempt)) return { state: 'none' };
|
||||
scans.delete(playerId); retire(playerId, session.attempt);
|
||||
store.update(playerId, { status: 'error', error: e.message });
|
||||
if (!confirmed) throw e;
|
||||
return { state: 'confirmed', error: e.message };
|
||||
} finally { session.polling = false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail of every login path: grant → cred → binding, persisted per player.
|
||||
* @param {string} playerId
|
||||
* @param {{ token: string, hgId?: string | null, deviceToken?: string | null }} auth
|
||||
* @param {{ dId?: string, attempt?: object }} [o]
|
||||
*/
|
||||
async function finishLogin(playerId, auth, o = {}) {
|
||||
const existing = row(playerId);
|
||||
const dId = existing.d_id || o.dId || makeDeviceId();
|
||||
const grantCode = await skland.getGrantCode(auth.token, auth.deviceToken);
|
||||
const { cred, credToken } = await skland.getCred(grantCode);
|
||||
const { binding, cred: cred2, credToken: credToken2 } = await skland.getBinding({
|
||||
cred, credToken, dId, accessToken: auth.token, deviceToken: auth.deviceToken,
|
||||
});
|
||||
if (o.attempt) checkCurrent(playerId, o.attempt);
|
||||
store.update(playerId, {
|
||||
access_token: auth.token, device_token: auth.deviceToken || existing.device_token || null,
|
||||
hg_id: auth.hgId || existing.hg_id || null,
|
||||
cred: cred2, cred_token: credToken2, d_id: dId, binding, binding_source: 'skland',
|
||||
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
|
||||
});
|
||||
return row(playerId);
|
||||
}
|
||||
|
||||
/** 发送短信验证码. `captcha` = the solved geetest fields when the first attempt asked for one. */
|
||||
async function sendPhoneCode(playerId, phone, captcha) {
|
||||
try {
|
||||
await skland.sendPhoneCode(phone, captcha);
|
||||
store.update(playerId, { error: null });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
if (e instanceof SklandCaptchaError) return { ok: false, needCaptcha: true, captcha: e.captcha };
|
||||
if (e instanceof SklandError) return { ok: false, error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 验证码登录: phone + code → bind. Uses the step methods so `finishLogin` is the only grant→cred→binding place. */
|
||||
async function loginByPhoneCode(playerId, phone, code) {
|
||||
const attempt = begin(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
try {
|
||||
const auth = await skland.tokenByPhoneCode(phone, code);
|
||||
const r = await finishLogin(playerId, auth, { attempt });
|
||||
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
|
||||
const message = e instanceof SklandError ? e.message : '登录失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
return { ok: false, error: message };
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login queries the official HG account binding service directly, without acquiring a Skland cred.
|
||||
* A previous QR/SMS login and its deviceToken are not needed for this flow.
|
||||
*/
|
||||
async function loginByPhonePassword(playerId, phone, password, captcha, captchaStep = 'password') {
|
||||
const attempt = begin(playerId);
|
||||
let auth;
|
||||
try {
|
||||
if (captchaStep === 'accountGrant' && captcha) {
|
||||
const pending = pendingPasswordGrants.get(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
if (!pending || pending.phone !== phone || now() - pending.createdAt > 120_000) {
|
||||
throw new SklandError('验证已过期,请重新点击登录', 'captcha_expired', 'accountGrant');
|
||||
}
|
||||
auth = { token: pending.token };
|
||||
} else {
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
auth = await skland.tokenByPhonePassword(phone, password, captcha);
|
||||
}
|
||||
const { token } = auth;
|
||||
const binding = await skland.getAccountBinding(token, captchaStep === 'accountGrant' ? captcha : undefined);
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, {
|
||||
access_token: token, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, binding, binding_source: 'hypergryph',
|
||||
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
|
||||
});
|
||||
const r = row(playerId);
|
||||
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
|
||||
if (e instanceof SklandCaptchaError) {
|
||||
if (e.step === 'accountGrant' && auth) pendingPasswordGrants.set(playerId, { token: auth.token, phone, createdAt: now() });
|
||||
return { ok: false, needCaptcha: true, captcha: e.captcha, captchaStep: e.step };
|
||||
}
|
||||
if (e instanceof SklandNeedDeviceError) {
|
||||
store.update(playerId, { status: 'error', error: e.message });
|
||||
return { ok: false, error: e.message, needDevice: true };
|
||||
}
|
||||
const message = e instanceof SklandError ? e.message : '登录失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
return { ok: false, error: message };
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
/** 解绑: forget every credential, including the deviceToken; later logins are checked by the upstream again. */
|
||||
function unbind(playerId) {
|
||||
store.unbind(playerId);
|
||||
scans.delete(playerId);
|
||||
pendingPasswordGrants.delete(playerId);
|
||||
attempts.delete(playerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** The cached arknights roles (a fresh /player/binding round-trip when `refresh`). */
|
||||
async function roles(playerId, refresh = false) {
|
||||
const r = row(playerId);
|
||||
if (!refresh || r.status !== 'bound') return arknightsRoles(r.binding);
|
||||
const attempt = begin(playerId);
|
||||
try {
|
||||
if (r.binding_source === 'hypergryph') {
|
||||
const binding = await skland.getAccountBinding(r.access_token);
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, { binding, nickname: nicknameFromBinding(binding), error: null });
|
||||
return arknightsRoles(binding);
|
||||
}
|
||||
const { binding, cred, credToken } = await skland.getBinding({
|
||||
cred: r.cred, credToken: r.cred_token, dId: r.d_id, accessToken: r.access_token, deviceToken: r.device_token,
|
||||
});
|
||||
checkCurrent(playerId, attempt);
|
||||
store.update(playerId, { binding, cred, credToken, nickname: nicknameFromBinding(binding) });
|
||||
return arknightsRoles(binding);
|
||||
} catch (e) {
|
||||
if (!current(playerId, attempt)) throw e;
|
||||
const message = e instanceof SklandError ? e.message : '查询失败';
|
||||
store.update(playerId, { status: 'error', error: message });
|
||||
throw e;
|
||||
} finally { retire(playerId, attempt); }
|
||||
}
|
||||
|
||||
return {
|
||||
status, startScan, pollScan, sendPhoneCode, loginByPhoneCode, loginByPhonePassword, unbind, roles,
|
||||
/** test hooks */
|
||||
_store: store, _client: skland, _scans: scans,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// The title integration reuses the game's existing reconnect token. No new WebSocket messages or session fields.
|
||||
export const SESSION_BINDING_PREFIX = '@session/';
|
||||
|
||||
export function createSessionPlayerResolver(registry) {
|
||||
return (req) => {
|
||||
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
|
||||
const session = match ? registry.byToken(match[1]) : null;
|
||||
return session ? SESSION_BINDING_PREFIX + session.playerId : null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is
|
||||
// server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in
|
||||
// WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file.
|
||||
//
|
||||
// One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state:
|
||||
// access_token the HG account token (refreshes official bindings or rebuilds a Skland cred)
|
||||
// cred the long-term API credential
|
||||
// cred_token the HMAC signing key (never leaves the server)
|
||||
// user_id the Skland internal user id
|
||||
// d_id the device fingerprint the credentials are pinned to
|
||||
// nickname / binding what the account reports (cache; refreshed on demand)
|
||||
// binding_source selects official HG account lookup or the legacy Skland refresh path
|
||||
//
|
||||
// The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their
|
||||
// own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js.
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS skland_players (
|
||||
player_id TEXT PRIMARY KEY,
|
||||
access_token TEXT,
|
||||
device_token TEXT,
|
||||
hg_id TEXT,
|
||||
cred TEXT,
|
||||
cred_token TEXT,
|
||||
user_id TEXT,
|
||||
d_id TEXT,
|
||||
nickname TEXT,
|
||||
binding TEXT, -- the raw binding list from the selected provider (cache)
|
||||
binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph
|
||||
status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still
|
||||
* boots (the credentials then do not survive a restart — logged once).
|
||||
* @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for
|
||||
* tests). The parent directory is created when missing.
|
||||
*/
|
||||
export function createSklandStore(opts = {}) {
|
||||
const file = opts.file || join(process.cwd(), 'data', 'skland.db');
|
||||
const log = opts.log || { warn: () => {} };
|
||||
if (typeof DatabaseSync !== 'function') {
|
||||
log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)');
|
||||
return createMemoryStore(file);
|
||||
}
|
||||
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA synchronous = NORMAL;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec(SCHEMA);
|
||||
// Preserve existing QR/SMS bindings when opening a database created before official account lookup was added.
|
||||
if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) {
|
||||
db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'");
|
||||
}
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
/** @returns {Record<string, any> | null} the row as an object, or null */
|
||||
function rowToObject(row) {
|
||||
if (!row) return null;
|
||||
return { ...row, binding: row.binding ? JSON.parse(row.binding) : null };
|
||||
}
|
||||
|
||||
return {
|
||||
/** The database file (or ':memory:'). */
|
||||
file,
|
||||
/** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */
|
||||
journalMode() {
|
||||
try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; }
|
||||
},
|
||||
close() { db.close(); },
|
||||
|
||||
/** Ensure a row exists for the player (idempotent). */
|
||||
ensure(playerId) {
|
||||
db.prepare(
|
||||
'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(player_id) DO NOTHING',
|
||||
).run(playerId, 'unbound', now(), now());
|
||||
},
|
||||
|
||||
get(playerId) {
|
||||
const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId);
|
||||
return rowToObject(row);
|
||||
},
|
||||
|
||||
/** All rows (admin/debug views). */
|
||||
list() {
|
||||
return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject);
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON.
|
||||
* @param {string} playerId
|
||||
* @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string,
|
||||
* user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string,
|
||||
* error: string | null}>} fields
|
||||
*/
|
||||
update(playerId, fields) {
|
||||
this.ensure(playerId);
|
||||
const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error'];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); }
|
||||
}
|
||||
if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); }
|
||||
if (!sets.length) return;
|
||||
sets.push('updated_at = ?');
|
||||
values.push(now(), playerId);
|
||||
db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values);
|
||||
},
|
||||
|
||||
/** Remove a player's credentials (解绑). The row stays, marked unbound. */
|
||||
unbind(playerId) {
|
||||
this.update(playerId, {
|
||||
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/** Delete a player's row entirely (e.g. session cleanup). */
|
||||
remove(playerId) {
|
||||
db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId);
|
||||
},
|
||||
|
||||
/** For tests: raw row count. */
|
||||
count() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */
|
||||
function createMemoryStore(file) {
|
||||
/** @type {Map<string, Record<string, any>>} */
|
||||
const rows = new Map();
|
||||
const now = () => Date.now();
|
||||
const blank = () => ({
|
||||
player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
created_at: 0, updated_at: 0,
|
||||
});
|
||||
return {
|
||||
file,
|
||||
journalMode() { return 'memory'; },
|
||||
close() { rows.clear(); },
|
||||
ensure(playerId) {
|
||||
if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() });
|
||||
},
|
||||
get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; },
|
||||
list() { return [...rows.values()].map((r) => ({ ...r })); },
|
||||
update(playerId, fields) {
|
||||
this.ensure(playerId);
|
||||
const row = rows.get(playerId);
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null);
|
||||
}
|
||||
row.updated_at = now();
|
||||
},
|
||||
unbind(playerId) {
|
||||
this.update(playerId, {
|
||||
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
|
||||
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
|
||||
});
|
||||
},
|
||||
remove(playerId) { rows.delete(playerId); },
|
||||
count() { return rows.size; },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user