实现账号绑定、持久登录与跨设备对局恢复
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:
herryyun committed 2026-10-14 23:22:50 +08:00
1 parent 488db49f11
commit 7c25c89992
62 files changed
+6855 -1005

No files matched your search

+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
};
}