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

199 lines
13 KiB
JavaScript

// server/http/static.js — the static file server (routes.js hands it every GET / HEAD that is not an API route):
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
//
// / → public/ (index.html for directories)
// /data/ → data/ (generated game data)
// /shared/ → shared/ (ESM shared with the browser)
// /sim/ → server/sim/ (the battle simulation, read-only, `.js` only — client-side
// combat, DESIGN §14; the Node-only loader nodeData.js is not served)
// /data.js → a generated browser stand-in of server/data.js (the sim's content modules
// import `../../../data.js`; in the browser it serves the data injected with
// /sim/simdata.js setSimData). No other server file is ever served.
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a
// "下载文件信息" dialog for every BGM track (media.js, shared/media.js, public/js/media.js)
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
// /assets/~<hash>/…, /fonts/~<hash>/…, /media/~<hash>/… → the same files: the content-hash segment
// (shared/assetPaths.js) is stripped. A hash that names the file's current bytes (assetIndex.js
// urlHashOk: its own hash, or its Spine model's) answers `immutable` for a year; any other hash —
// an old page, a URL derived from another file's — the current file with `no-cache`
// /data/assets.json, /data/local-assets.json → the served manifests (assetViews.js: every art URL with its
// content-hash segment; no-cache, the ETag of the view) once the asset index is ready, the files as
// they are before that or without an index
//
// Files under public/assets/ and public/fonts/ get their content hash as ETag when the index has it (a file whose
// size or mtime changed since asks for a rescan), others size + mtime. Traversal & dotfile protection, a directory
// without its trailing slash → 301, 404 page; an absent data/local-assets.json is answered with an empty manifest.
// 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';
import { ROOT, noopLog } from './config.js';
import { sendError, sendJson } from './common.js';
import { MIME, CompressCache, isNotModified, serveFile, serveBuffer } from './files.js';
import { ASSET_INDEX_ROOTS, hashedFileOpts } from './assetIndex.js';
import { serveMedia } from './media.js';
import { createPackRegistry } from '../packs.js';
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
/** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
// The simulation's content modules (/sim/content/support/index.js) import getData() from here; it returns the game data
// the page injected with /sim/simdata.js setSimData(data).
import { getSimData } from './sim/simdata.js';
export function getData() { return getSimData() || {}; }
export function resetData() {}
`;
/** Files under server/sim that are never served (Node-only). */
const SIM_PRIVATE = new Set(['nodedata.js']); // lower-case (compared case-insensitively)
// ---------------------------------------------------------------------------------------------------
// Static file handler
// ---------------------------------------------------------------------------------------------------
/** Optional per-machine art manifest (tools/local-extract) and the empty stand-in served when it is absent. */
const LOCAL_ART_MANIFEST = 'local-assets.json';
const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', count: 0, groups: {} }));
/**
* Create the static request handler.
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
* 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);
* assetIndex / views: the content hashes and the served manifests (none: plain URLs, size-mtime ETags)
* @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, 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) },
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
// the simulation: ES modules only (no directory listings, no other file types, no Node-only loader)
{ prefix: '/sim/', name: 'sim', dir: path.resolve(simDir), only: new Set(['.js']), deny: SIM_PRIVATE },
{ prefix: '/', name: 'public', dir: path.resolve(publicDir) },
];
const shimBody = Buffer.from(DATA_SHIM_JS);
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
const gzipCache = new CompressCache(); // brotli and gzip bodies (files.js)
return async function serveStatic(req, res, rawPath, query) {
let decoded;
try { decoded = decodeURIComponent(rawPath); } catch { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
if (!decoded.startsWith('/') || decoded.includes('\0') || decoded.includes('\\')) {
sendError(req, res, 400, '请求地址无效 · Bad request');
return;
}
// /assets/~<hash>/x → /assets/x (the hash decides the cache policy below)
const hashed = splitHashedPath(decoded);
const urlHash = hashed.hash;
decoded = hashed.path;
if (decoded === '/data.js') {
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : shimBody);
return;
}
// Content packs (server/packs.js): the live index, and only the files a pack's manifest names
if (decoded.startsWith(PACKS_URL)) {
const parts = decoded.slice(PACKS_URL.length).split('/');
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() || 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, isPrivate });
return;
}
// The served manifests: every art URL with its content hash (assetViews.js), once the index is ready
if (views && (decoded === '/data/assets.json' || decoded === '/data/local-assets.json')) {
const view = await views.get(decoded.slice('/data/'.length));
if (view) {
await serveBuffer(req, res, { body: view.body, hash: view.hash, type: MIME['.json'], cacheControl: 'no-cache', key: `view:${decoded}`, cache: gzipCache });
return;
}
}
// Bare mount paths (e.g. "/data") → treat as the mount directory.
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
const segments = rest.split('/').filter((s) => s.length > 0);
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
if (segments.some((s) => s.startsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
if (mount.only && (!segments.length || !mount.only.has(path.extname(segments[segments.length - 1]).toLowerCase())
// (case-insensitive: the host may be Windows / macOS, where NODEDATA.JS opens nodeData.js)
|| (mount.deny && mount.deny.has(segments[segments.length - 1].toLowerCase())))) {
sendError(req, res, 404, '页面不存在 · Not found');
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;
let viaDirectory = false;
try {
stat = await fsp.stat(absPath);
if (stat.isDirectory()) {
if (!decoded.endsWith('/')) {
// Built from normalized segments (never from the raw path) so "//host" can't become an open redirect.
const loc = (mount.prefix + segments.map(encodeURIComponent).join('/') + '/').replace(/\/{2,}/g, '/');
res.writeHead(301, { Location: loc + (query ? `?${query}` : ''), 'Cache-Control': 'no-cache', 'Content-Length': 0 });
res.end();
return;
}
absPath = path.join(absPath, 'index.html');
segments.push('index.html');
viaDirectory = true;
stat = await fsp.stat(absPath);
}
// Not a regular file, or a file addressed like a directory ("/app.js/") → 404.
if (!stat.isFile() || (decoded.endsWith('/') && !viaDirectory)) {
throw Object.assign(new Error('not a file'), { code: 'ENOENT' });
}
if (mount.deny) {
// the on-disk name decides (case-insensitive file systems, Windows 8.3 short names like NODEDA~1.JS)
const real = await fsp.realpath(absPath);
if (mount.deny.has(path.basename(real).toLowerCase())) throw Object.assign(new Error('private'), { code: 'ENOENT' });
}
} catch (e) {
if (e && e.code === 'ENOENT' && mount.name === 'data' && segments.length === 1 && segments[0] === LOCAL_ART_MANIFEST) {
// Optional local-client art (DESIGN §13): an install without it gets an empty manifest instead of a 404,
// so browsers don't log an error on every page load. Clients treat empty groups as "no local art".
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-cache', 'Content-Length': EMPTY_LOCAL_ART.length });
res.end(req.method === 'HEAD' ? undefined : EMPTY_LOCAL_ART);
} else if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e.code === 'EISDIR' || e.code === 'ENAMETOOLONG')) {
sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : '');
} else if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
sendError(req, res, 403, '禁止访问 · Forbidden');
} else {
log.error('[http] stat failed', e);
sendError(req, res, 500, '服务器内部错误 · Internal error');
}
return;
}
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);
};
}