A 图标表:从 module_36659.js:5135 的 eA.upf(底盘)与 module_57128.js:840 的 Dn.duk(炮塔)解出 case→形参→实参→i(模块id)→resources.json 的映射(严格走 case→形参名→形参声明行→实参行,不假设 case 号等于实参位置,因为该模块实参表约 1000 项而工厂形参只有 714 个、多出的被丢弃)。自验:用同文件 :907 的 Dn.euk 抗性表 21 case 全命中且本地镜像均存在(去重 20 个文件)。规模:底盘 416 case(未解 1)、炮塔 20、抗性 21、7 格道具 7。B 物品信息:名称来自服务端模型 3935107155251354407(DescriptionModelCC)的 name 字段,类别中文用 lobby.garage.<枚举名>(ARMOR=底盘/WEAPON=炮塔/PAINT=迷彩/DRONE=辅助无人机…),15 件仓库物品名称 9/9、类别中文 9/9 命中;但图标 0/9 —— 承载底盘/炮塔型号的 TankInfoCC(5543162332469864498)在全部 68 个 s2c 帧与模板里 0 次出现,属数据缺失而非算法问题,已如实标注。C 素材中继 client/tools/AssetRelay.mjs(8130):/ 服务客户端产物、/play/static/** 映射 3DTank/web/play/static 本地镜像、正确 MIME 与宽 CORS、防目录穿越(%2e%2e 返回 400)。回归:CompareContainerDecode 5 帧/26950/0、selftest 1108/9/90、semantic --demo 全部断言通过、Kotlin/JS 构建 SUCCESSFUL。
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
// AssetRelay.mjs —— 素材中继(只读本地 HTTP 服务,默认端口 8130)
|
||
//
|
||
// 目的:把「客户端产物」与「官方静态素材本地镜像」拼成一个可浏览的站点,给浏览器侧客户端用。
|
||
// 1) `/` 与其它静态文件 → client/build/dist/js/productionExecutable/(Kotlin/JS 产物;不存在就清晰报错)
|
||
// 2) `/play/static/**` → 3DTank/web/play/static/**(**本地镜像优先**,只读)
|
||
// 3) 本地镜像缺文件时(可选)回落到 --upstream(用户的 3DTank/tools/serve.js,默认不启用),并打日志
|
||
//
|
||
// 安全:只读;路径做规范化 + 前缀校验,禁止用 `..`(含 %2e%2e)逃出映射根。
|
||
//
|
||
// 用法:
|
||
// node client/tools/AssetRelay.mjs
|
||
// node client/tools/AssetRelay.mjs --port 8130 --upstream http://localhost:8080
|
||
'use strict';
|
||
import fs from 'node:fs';
|
||
import http from 'node:http';
|
||
import https from 'node:https';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const Here = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const ClientDir = path.resolve(Here, '..');
|
||
const RepoDir = path.resolve(ClientDir, '..');
|
||
const DistDir = path.join(ClientDir, 'build', 'dist', 'js', 'productionExecutable');
|
||
const MirrorRoot = path.join(RepoDir, '3DTank', 'web', 'play', 'static');
|
||
|
||
// ---- 参数
|
||
const Args = process.argv.slice(2);
|
||
function ArgOf(Name, Default) {
|
||
const I = Args.indexOf('--' + Name);
|
||
return I >= 0 && Args[I + 1] ? Args[I + 1] : Default;
|
||
}
|
||
const Port = Number(ArgOf('port', '8130'));
|
||
const Upstream = ArgOf('upstream', '');
|
||
|
||
// ---- MIME(按扩展名;够用即可,未列的按 application/octet-stream)
|
||
const MimeByExt = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.mjs': 'text/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.txt': 'text/plain; charset=utf-8',
|
||
'.xml': 'application/xml; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.webp': 'image/webp',
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.gif': 'image/gif',
|
||
'.ico': 'image/x-icon',
|
||
'.ttf': 'font/ttf',
|
||
'.otf': 'font/otf',
|
||
'.woff': 'font/woff',
|
||
'.woff2': 'font/woff2',
|
||
'.mp3': 'audio/mpeg',
|
||
'.ogg': 'audio/ogg',
|
||
'.wav': 'audio/wav',
|
||
'.mp4': 'video/mp4',
|
||
'.webm': 'video/webm',
|
||
'.3ds': 'application/octet-stream',
|
||
'.wasm': 'application/wasm',
|
||
};
|
||
function MimeOf(P) {
|
||
return MimeByExt[path.extname(P).toLowerCase()] || 'application/octet-stream';
|
||
}
|
||
|
||
// 防目录穿越:把相对路径 join 到根后规范化,必须仍在根之内
|
||
function SafeJoin(Root, Rel) {
|
||
const Normalized = path.normalize(Rel).replace(/^([/\\])+/, '');
|
||
const Full = path.normalize(path.join(Root, Normalized));
|
||
if (Full !== Root && !Full.startsWith(Root + path.sep)) return null;
|
||
return Full;
|
||
}
|
||
|
||
const UrlPathOf = (Req) => decodeURIComponent((Req.url || '/').split('?')[0].split('#')[0]);
|
||
|
||
function Log(Msg) {
|
||
console.log('TankIcAssetRelay | ' + Msg);
|
||
}
|
||
|
||
function SendFile(Res, File, ExtraHeaders) {
|
||
const Headers = Object.assign({ 'Content-Type': MimeOf(File), 'Content-Length': String(fs.statSync(File).size) }, ExtraHeaders || {});
|
||
Res.writeHead(200, Headers);
|
||
fs.createReadStream(File).pipe(Res);
|
||
}
|
||
|
||
function SendText(Res, Code, Text, ExtraHeaders) {
|
||
const Buf = Buffer.from(Text, 'utf8');
|
||
Res.writeHead(Code, Object.assign({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': String(Buf.length) }, ExtraHeaders || {}));
|
||
Res.end(Buf);
|
||
}
|
||
|
||
// 回落:把请求转发给 --upstream(只读代理;命中时打日志)
|
||
function RelayUpstream(Req, Res, RelPath) {
|
||
const Target = new URL(RelPath, Upstream);
|
||
const Lib = Target.protocol === 'https:' ? https : http;
|
||
const P = Lib.request(
|
||
Target,
|
||
{ method: 'GET', headers: { Accept: Req.headers.accept || '*/*' } },
|
||
(Up) => {
|
||
if (Up.statusCode !== 200) {
|
||
Up.resume();
|
||
SendText(Res, 404, '本地镜像没有,且上游返回 ' + Up.statusCode + ':' + RelPath + '\n');
|
||
return;
|
||
}
|
||
const Headers = {};
|
||
for (const K of ['content-type', 'content-length', 'etag', 'last-modified']) if (Up.headers[K]) Headers[K] = Up.headers[K];
|
||
Headers['Access-Control-Allow-Origin'] = '*';
|
||
Res.writeHead(200, Headers);
|
||
Up.pipe(Res);
|
||
},
|
||
);
|
||
P.on('error', (E) => SendText(Res, 502, '回落到上游失败:' + E.message + '\n'));
|
||
P.end();
|
||
}
|
||
|
||
if (!fs.existsSync(DistDir)) {
|
||
console.error('✗ 客户端产物目录不存在:' + DistDir);
|
||
console.error(' 请先构建:cd client && ' + (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') + ' jsProductionExecutableCompileSync');
|
||
process.exit(1);
|
||
}
|
||
if (!fs.existsSync(MirrorRoot)) {
|
||
console.error('✗ 官方素材本地镜像目录不存在:' + MirrorRoot);
|
||
process.exit(1);
|
||
}
|
||
|
||
const Server = http.createServer((Req, Res) => {
|
||
const Pathname = UrlPathOf(Req);
|
||
|
||
// CORS(/play/static/** 加宽;其余也给,方便本地调试)
|
||
if (Req.method === 'OPTIONS') {
|
||
Res.writeHead(204, {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||
'Access-Control-Allow-Headers': '*',
|
||
'Access-Control-Max-Age': '86400',
|
||
});
|
||
Res.end();
|
||
return;
|
||
}
|
||
|
||
// 1) 官方素材:/play/static/** → 3DTank/web/play/static/**
|
||
if (Pathname === '/play/static' || Pathname.startsWith('/play/static/')) {
|
||
const Rel = Pathname.slice('/play/static/'.length);
|
||
const CORS = { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' };
|
||
const Full = SafeJoin(MirrorRoot, Rel);
|
||
if (Full === null) {
|
||
Log('✗ 目录穿越被拒:' + Pathname);
|
||
SendText(Res, 400, '非法路径(试图逃出映射根):' + Pathname + '\n', CORS);
|
||
return;
|
||
}
|
||
if (Rel && fs.existsSync(Full) && fs.statSync(Full).isFile()) {
|
||
Log('200 镜像 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
|
||
SendFile(Res, Full, CORS);
|
||
return;
|
||
}
|
||
if (Upstream) {
|
||
Log('↩ 回落上游 ' + Upstream + Pathname + '(本地镜像缺 ' + Rel + ')');
|
||
RelayUpstream(Req, Res, Pathname);
|
||
return;
|
||
}
|
||
Log('404 镜像缺文件 ' + Pathname);
|
||
SendText(Res, 404, '本地镜像里没有这个素材:' + Rel + '\n(默认不启用上游回落;可用 --upstream http://localhost:8080)\n', CORS);
|
||
return;
|
||
}
|
||
|
||
// 2) 客户端产物:/ 与其它路径 → build/dist/js/productionExecutable/
|
||
const Rel = Pathname === '/' ? 'index.html' : Pathname.slice(1);
|
||
const Full = SafeJoin(DistDir, Rel);
|
||
if (Full === null) {
|
||
Log('✗ 目录穿越被拒:' + Pathname);
|
||
SendText(Res, 400, '非法路径(试图逃出产物根):' + Pathname + '\n');
|
||
return;
|
||
}
|
||
if (fs.existsSync(Full) && fs.statSync(Full).isFile()) {
|
||
Log('200 产物 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
|
||
SendFile(Res, Full);
|
||
return;
|
||
}
|
||
Log('404 产物缺文件 ' + Pathname);
|
||
SendText(Res, 404, '客户端产物里没有:' + Rel + '\n产物目录:' + DistDir + '\n');
|
||
});
|
||
|
||
Server.listen(Port, () => {
|
||
Log('素材中继已启动:http://localhost:' + Port + '/');
|
||
Log(' 产物目录 ' + DistDir);
|
||
Log(' 素材镜像 ' + MirrorRoot + '(只读,/play/static/**)');
|
||
Log(' 上游回落 ' + (Upstream || '未启用(本地镜像优先;缺文件返回 404)'));
|
||
});
|