实现账号绑定、持久登录与跨设备对局恢复
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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user