// 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)')); });