fix: 修正令牌鉴权拦下静态资源、全量地图数据每 20 秒重传两个问题
- 静态资源改到鉴权中间件之前:浏览器加载 index.html 时带的 ?token= 不会附加到 style.css / app.js 的子请求上,原先一并拦截会导致页面缺样式缺脚本 - 全量地图数据(实测 11.5 万区块约 1MB JSON)改为按需拉取: 勾选「显示全部邦国」、手动刷新地图或超过 5 分钟时才拉全量, 定时刷新只拉己方领土(约 4KB)并复用上一次的全量数据 - 配置热更新时直接替换控制器配置对象,去掉多余的一层浅合并 测试:新增静态资源可加载的回归用例,154 个用例全绿。
这个提交包含在:
@@ -165,7 +165,7 @@ npm start
|
|||||||
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
|
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
|
||||||
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
|
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
|
||||||
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
|
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
|
||||||
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置 |
|
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
|
||||||
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
|
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
|
||||||
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
|
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ async function main() {
|
|||||||
config = normalizeConfig(next, config);
|
config = normalizeConfig(next, config);
|
||||||
configStore.save(config);
|
configStore.save(config);
|
||||||
botManager.applyConfig(config);
|
botManager.applyConfig(config);
|
||||||
controller.config = controller.config ? Object.assign({}, controller.config, config) : config;
|
controller.config = config;
|
||||||
dataService.config = config;
|
dataService.config = config;
|
||||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||||
dataService.reschedule(config.refreshIntervalSeconds);
|
dataService.reschedule(config.refreshIntervalSeconds);
|
||||||
|
|||||||
+34
-10
@@ -643,16 +643,39 @@ function hexToRgba(hex, alpha) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mapLoading = false;
|
let mapLoading = false;
|
||||||
async function loadMap() {
|
/** 上次拉取「全部邦国」地图数据的时间戳(全量数据体积大,需限频) */
|
||||||
|
let lastFullFetchAt = 0;
|
||||||
|
/** 全量地图数据的最短拉取间隔:默认 5 分钟 */
|
||||||
|
const FULL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取地图数据。
|
||||||
|
*
|
||||||
|
* `full=1` 会带上全部邦国的区块(实测 11.5 万区块约 1.5MB JSON),
|
||||||
|
* 因此只在「用户勾选全部邦国」「手动刷新」「超过 5 分钟」时才拉全量,
|
||||||
|
* 其余定时刷新只拉己方领土,并复用上一次的全量数据,避免每 20 秒传 1.5MB。
|
||||||
|
*
|
||||||
|
* @param {boolean} [forceFull] 强制拉取全量数据
|
||||||
|
*/
|
||||||
|
async function loadMap(forceFull) {
|
||||||
if (mapLoading) return;
|
if (mapLoading) return;
|
||||||
mapLoading = true;
|
mapLoading = true;
|
||||||
try {
|
try {
|
||||||
const full = $('chkFull').checked ? '1' : '0';
|
const cached = mapView.data;
|
||||||
const res = await api('/api/map?full=' + full);
|
const needFull = $('chkFull').checked
|
||||||
mapView.data = res.data;
|
&& (forceFull === true || !cached || !cached.otherChunks
|
||||||
if (!mapView.centered && res.data.playerChunk) {
|
|| Date.now() - lastFullFetchAt > FULL_REFRESH_INTERVAL_MS);
|
||||||
mapView.centerX = res.data.playerChunk.x;
|
const res = await api('/api/map?full=' + (needFull ? '1' : '0'));
|
||||||
mapView.centerZ = res.data.playerChunk.z;
|
const next = res.data;
|
||||||
|
if (needFull) {
|
||||||
|
lastFullFetchAt = Date.now();
|
||||||
|
} else if (cached && cached.otherChunks) {
|
||||||
|
next.otherChunks = cached.otherChunks;
|
||||||
|
}
|
||||||
|
mapView.data = next;
|
||||||
|
if (!mapView.centered && next.playerChunk) {
|
||||||
|
mapView.centerX = next.playerChunk.x;
|
||||||
|
mapView.centerZ = next.playerChunk.z;
|
||||||
mapView.centered = true;
|
mapView.centered = true;
|
||||||
}
|
}
|
||||||
drawMap();
|
drawMap();
|
||||||
@@ -728,6 +751,7 @@ function bindEvents() {
|
|||||||
try {
|
try {
|
||||||
await api('/api/map/refresh', { method: 'POST' });
|
await api('/api/map/refresh', { method: 'POST' });
|
||||||
toast('已触发地图刷新');
|
toast('已触发地图刷新');
|
||||||
|
loadMap(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
@@ -738,7 +762,7 @@ function bindEvents() {
|
|||||||
try {
|
try {
|
||||||
const res = await api('/api/api/fetch-all', { method: 'POST' });
|
const res = await api('/api/api/fetch-all', { method: 'POST' });
|
||||||
toast(res.message);
|
toast(res.message);
|
||||||
loadMap();
|
loadMap(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('拉取失败:' + err.message, true);
|
toast('拉取失败:' + err.message, true);
|
||||||
}
|
}
|
||||||
@@ -757,7 +781,7 @@ function bindEvents() {
|
|||||||
renderConfig();
|
renderConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('chkFull').addEventListener('change', loadMap);
|
$('chkFull').addEventListener('change', () => loadMap(true));
|
||||||
$('chkFlying').addEventListener('change', drawMap);
|
$('chkFlying').addEventListener('change', drawMap);
|
||||||
$('chkGrid').addEventListener('change', drawMap);
|
$('chkGrid').addEventListener('change', drawMap);
|
||||||
|
|
||||||
@@ -845,7 +869,7 @@ async function main() {
|
|||||||
await loadMap();
|
await loadMap();
|
||||||
connectWs();
|
connectWs();
|
||||||
// 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒)
|
// 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒)
|
||||||
setInterval(loadMap, 20000);
|
setInterval(() => loadMap(), 20000);
|
||||||
// 相对时间("x 秒前")需要定期重绘
|
// 相对时间("x 秒前")需要定期重绘
|
||||||
setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000);
|
setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -135,6 +135,10 @@ class WebConsole {
|
|||||||
const app = this.app;
|
const app = this.app;
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
// 静态资源(控制台页面本身)不需要令牌:浏览器加载 index.html 时带的 ?token= 不会
|
||||||
|
// 附加到后续 style.css / app.js 的子请求上,若一并拦截会导致页面样式与脚本 404/401。
|
||||||
|
// 页面本身不含任何敏感信息,真正需要保护的是下面的 /api 接口。
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (!this._authorized(req)) {
|
if (!this._authorized(req)) {
|
||||||
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
|
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
|
||||||
@@ -142,7 +146,6 @@ class WebConsole {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', (req, res) => {
|
||||||
res.json({ ok: true, data: this.statusPayload() });
|
res.json({ ok: true, data: this.statusPayload() });
|
||||||
|
|||||||
+9
-1
@@ -142,7 +142,7 @@ test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => {
|
test('HTTP 接口:配置令牌后拒绝无令牌的 API 请求,但静态资源仍可加载', async () => {
|
||||||
const { webConsole } = setupConsole();
|
const { webConsole } = setupConsole();
|
||||||
webConsole.token = 'secret-token';
|
webConsole.token = 'secret-token';
|
||||||
await webConsole.start();
|
await webConsole.start();
|
||||||
@@ -156,6 +156,14 @@ test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => {
|
|||||||
|
|
||||||
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
|
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
|
||||||
assert.strictEqual(viaHeader.status, 200);
|
assert.strictEqual(viaHeader.status, 200);
|
||||||
|
|
||||||
|
// 静态资源不带令牌也必须能加载,否则浏览器里控制台页面会缺样式 / 缺脚本
|
||||||
|
const html = await fetch(base + '/');
|
||||||
|
assert.strictEqual(html.status, 200);
|
||||||
|
const css = await fetch(base + '/style.css');
|
||||||
|
assert.strictEqual(css.status, 200);
|
||||||
|
const js = await fetch(base + '/app.js');
|
||||||
|
assert.strictEqual(js.status, 200);
|
||||||
} finally {
|
} finally {
|
||||||
await webConsole.stop();
|
await webConsole.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户