diff --git a/README.md b/README.md
index 12fac24..8b62499 100644
--- a/README.md
+++ b/README.md
@@ -240,7 +240,10 @@ CI 在每次 push 时启动面板并跑完整冒烟;Docker 镜像由 Actions 构
- 安装服务端时写入 `eula=true`,代表**你**同意 [Minecraft EULA](https://aka.ms/MinecraftEULA)(创建实例时需勾选确认)。
Installing a server writes `eula=true`, meaning **you** accept the Minecraft EULA (confirmed at instance creation).
- 面板**未内置 HTTPS/反代**,公网部署请置于 Nginx/Caddy 之后并修改默认密码。
- No built-in HTTPS/reverse proxy — put it behind Nginx/Caddy and change the default password before going public.
+ 写操作带跨站(CSRF)校验,**反代请透传 `Host` 头**;用多个域名访问同一面板时把它们加进
+ `MCSP_TRUSTED_ORIGINS`(逗号分隔、含协议),否则写操作会被拒并提示「跨站请求被拒绝」。
+ No built-in HTTPS/reverse proxy — put it behind Nginx/Caddy and change the default password before going public.
+ Writes are CSRF-checked: forward the `Host` header, and list any additional hostnames in `MCSP_TRUSTED_ORIGINS`.
- 内网穿透与外置登录组件(bore/frpc/ngrok/playit、authlib-injector)均从官方源下载,遵守各自服务条款。
Tunnel & auth components are downloaded from official sources; comply with their respective terms.
diff --git a/docker-compose.yml b/docker-compose.yml
index af010bf..b6c4b30 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,6 +16,11 @@ services:
# # 隧道/Worker 反代常见的 ~10MB 请求体上限,
# # 前面没有这类限制时可以调大
# - MCSP_UPLOAD_CONCURRENCY=3 # 浏览器同时在途的上传请求数
+ # - MCSP_TRUSTED_ORIGINS=https://mc.example.com,https://panel.example.com
+ # # 跨站校验的额外放行来源(逗号分隔,含协议)。
+ # # 一般不用配:同源访问自动通过。只有当反代
+ # # 没透传 Host 头、或你用多个域名访问同一面板时
+ # # 才需要 —— 否则写操作会被拒并提示"跨站请求被拒绝"
volumes:
- mcsp-data:/app/data # 账户 / 会话 / 注册表 / 计划任务
- mcsp-instances:/app/instances # 服务端实例(世界存档)
diff --git a/public/app.js b/public/app.js
index cf9a384..83c33a9 100644
--- a/public/app.js
+++ b/public/app.js
@@ -58,6 +58,48 @@ let fmOpenFile = null;
let fmClip = null; // { op:'cut'|'copy', iid, dir, names[] }
let fmLastIndex = null; // Shift 范围选的锚点,每次换目录重置
+/* ───────── 权限档在 UI 上的体现 ─────────
+ *
+ * 后端一直算着 req.perm(viewer/operator/manager/owner),但从没发给前端 ——
+ * 于是 viewer 看到的界面和主人一模一样:启动、保存配置、删文件按钮全在,
+ * 点下去才 403。「能看见但一按就报错」比「看不见」更糟:用户不知道是自己没权限
+ * 还是面板坏了,而报错文案是通用的 403。
+ *
+ * 做法是声明式的:HTML 上标 data-need="operator|manager|owner",
+ * 这里统一按当前实例的档位禁用 + 给出原因。以后加按钮只需加一个属性,
+ * 不用再往这里塞 if。
+ */
+const PERM_LEVEL = { viewer: 1, operator: 2, manager: 3, owner: 4 };
+const PERM_LABEL = { viewer: '只读', operator: '运维', manager: '管理', owner: '主人' };
+const permMap = new Map(); // iid → perm。单独存,免得被 SSE 的 state 事件覆盖掉
+
+function permOf(iid) { return permMap.get(iid) || null; }
+
+/** 当前实例下,调用者是否够 need 这一档 */
+function canDo(need) {
+ const p = permOf(currentIid);
+ if (!p) return true; // 拿不到就不拦(老后端/还没加载完),后端才是权威
+ return (PERM_LEVEL[p] || 0) >= (PERM_LEVEL[need] || 4);
+}
+
+/** 按当前实例的档位刷新所有标了 data-need 的控件 */
+function applyPermUI() {
+ const p = permOf(currentIid);
+ for (const el of document.querySelectorAll('[data-need]')) {
+ const need = el.dataset.need;
+ const ok = canDo(need);
+ el.disabled = !ok;
+ el.classList.toggle('perm-denied', !ok);
+ if (!ok) {
+ el.title = `需要「${PERM_LABEL[need] || need}」及以上权限,你在这个实例上是「${PERM_LABEL[p] || p}」`;
+ } else if (el.dataset.permTitle !== undefined) {
+ el.title = el.dataset.permTitle;
+ }
+ }
+ // 协作者卡片不在这里管:renderCollab() 已经按 admin||owner 显隐了,
+ // 再写一遍就是同一件事的第二个真相来源,迟早会两边不一致
+}
+
/* ───────── helpers ───────── */
async function api(path, opts = {}) {
@@ -74,6 +116,15 @@ async function api(path, opts = {}) {
force2FA(data.error);
throw new Error('2fa_required');
}
+ /* 跨站校验拦下来的请求。正常同源访问永远走不到这里 —— 走到了基本只有一种情况:
+ 面板在反代后面,而反代没把 Host 头透传对(或者用了另一个域名访问)。
+ 不显式处理的话表现是"点了没反应":很多调用点不判 ok,拿到 {ok:false} 就静默结束,
+ 而这恰恰是最需要把原因说清楚的时候。 */
+ if (res.status === 403 && data && data.code === 'csrf') {
+ toast('跨站请求被拒绝。若面板在 Nginx/Caddy 后面,请确认反代透传了 Host 头;'
+ + '用其它域名访问时把该域名加进 MCSP_TRUSTED_ORIGINS 环境变量。', true);
+ throw new Error('csrf');
+ }
return data;
}
@@ -242,6 +293,7 @@ $('#inst-select').addEventListener('change', async (e) => {
});
async function refreshInstanceContext() {
+ applyPermUI(); // 切实例先按新档位刷一遍,别让上一个实例的状态留在按钮上
metricsHistory = await iapi('/metrics/history');
metricsDay = [];
chartRange = 'live';
@@ -377,6 +429,9 @@ function javaCellHtml(host) {
async function loadOverview() {
const [host, list] = await Promise.all([api('/host'), api('/instances')]);
instMap = new Map(list.map((i) => [i.id, i]));
+ // perm 只在列表和 /status 里下发(SSE 广播不带,那份要发给权限不同的多个人)
+ for (const i of list) if (i.perm) permMap.set(i.id, i.perm);
+ applyPermUI();
renderInstSelect();
// 宿主机一栏只有管理员能看:普通用户拿到的 /host 压根没有这些字段
@@ -461,10 +516,16 @@ function renderInstGrid() {
`;
@@ -675,10 +736,20 @@ $('#imp-ok').addEventListener('click', async () => {
setStep('完成', 100);
const d = fin.detected || {};
- toast(d.type
- ? `已导入:${typeLabel(d.type)} ${d.version || '(版本未知)'}`
- : '已导入,但未能识别服务端类型,请到设置页确认');
- if (d.notes && d.notes.length) toast(d.notes[0], true);
+ /* 把探测置信度说出来。detect.js 算了 high/low/none 三级(三条线索都对上才是 high),
+ 但之前只用了 d.type —— 于是"三样都对上"和"只猜出个类型、版本和 jar 都没认出来"
+ 给用户的反馈一模一样。而后者恰恰需要他去设置页核对一遍。
+ notes 也从只显示第一条改成全部显示:里面有"未找到已同意的 eula.txt"这类
+ 直接影响能不能启动的提示,漏掉后面几条等于让用户自己去撞。 */
+ if (!d.type) {
+ toast('已导入,但**未能识别**服务端类型,请到设置页手动指定', true);
+ } else if (d.confidence === 'high') {
+ toast(`已导入:${typeLabel(d.type)} ${d.version}`);
+ } else {
+ toast(`已导入:${typeLabel(d.type)} ${d.version || '(版本未知)'} —— 识别置信度低,`
+ + '请到设置页核对类型与版本再启动', true);
+ }
+ for (const note of (d.notes || [])) toast(note, true);
$('#imp-modal').hidden = true;
$('#imp-name').value = ''; $('#imp-file').value = ''; $('#imp-eula').checked = false;
@@ -2059,6 +2130,25 @@ $('#task-sched-type').addEventListener('change', (e) => {
$('#task-time').hidden = e.target.value !== 'daily';
});
+/**
+ * 最近几次执行的结果条。后端一直存着最近 5 次(tasks.js 的 task.history)并原样下发,
+ * 前端却只画了 lastResult —— 而「偶尔抽风」和「一直坏」是两种完全不同的处置,
+ * 一次结果分不出来,五次一眼就看出来。
+ * 最新的在右边,鼠标悬停看具体时间和原因。
+ */
+function taskHistoryHtml(t) {
+ const h = (t.history || []).slice(-5);
+ if (h.length < 2) return ''; // 只有一次的话 lastResult 已经说完了,不重复占地方
+ const dots = h.map((r) => {
+ const when = r.at ? fmtAgo(r.at) : '';
+ const tip = `${r.ok ? '成功' : '失败'}${when ? ' · ' + when : ''}${r.ms ? ` · 耗时 ${r.ms}ms` : ''}${r.msg ? '\n' + r.msg : ''}`;
+ return ``;
+ }).join('');
+ const bad = h.filter((r) => !r.ok).length;
+ return `最近 ${h.length} 次 ${dots}`
+ + ` ${bad ? `${bad} 次失败` : '全部成功'}
`;
+}
+
async function loadTasks() {
const list = await iapi('/tasks');
$('#task-list').innerHTML = list.length ? list.map((t) => `
@@ -2071,6 +2161,7 @@ async function loadTasks() {
${t.lastResult.ok ? '✔' : '✘'} ${escapeHtml(t.lastResult.msg || '')}
${t.failStreak > 1 ? `· 已连续 ${t.failStreak} 次未成功` : ''}
` : ''}
+ ${taskHistoryHtml(t)}
@@ -2403,7 +2494,7 @@ async function loadPlaytime() {
${p.online ? '● ' : ''}${escapeHtml(p.name)}
${fmtDuration(p.totalMs)}
- ${p.sessions} 次 · 最后 ${p.lastSeen ? fmtAgo(p.lastSeen) : '—'}
+ ${p.sessions} 次 · ${p.firstSeen ? `初见 ${fmtAgo(p.firstSeen)} · ` : ''}最后 ${p.lastSeen ? fmtAgo(p.lastSeen) : '—'}
`).join('');
}
@@ -2541,8 +2632,13 @@ const BOOL_PROPS = new Set(['pvp', 'online-mode', 'white-list', 'allow-nether',
async function loadProperties() {
const [props, status] = await Promise.all([iapi('/properties'), iapi('/status')]);
instMap.set(status.id, status);
+ if (status.perm) { permMap.set(status.id, status.perm); applyPermUI(); }
$('#cfg-name').value = status.name || '';
$('#cfg-icon').value = status.icon || '🌳';
+ // createdAt 一直在存也一直在落盘,只是从没进过 snapshot。用户/邀请/备份都有"创建于",
+ // 唯独实例没有 —— 而"这个服开了多久了"恰恰是最常被问起的
+ $('#cfg-created').textContent = status.createdAt
+ ? `创建于 ${new Date(status.createdAt).toLocaleString('zh-CN')} · ${fmtAgo(status.createdAt)}` : '';
$('#cfg-xmx').value = status.xmx || 2048;
$('#cfg-jvm').value = status.jvmArgs || '';
loadReinstall(status);
@@ -2881,17 +2977,26 @@ $('#nt-test').addEventListener('click', async () => {
|| '没有配置任何推送目标';
});
+/* 精确到人的筛选。后端 read() 早就支持 user 参数,而且为它写了一条免 JSON.parse 的
+ 快路径(直接在原始行里找 "user":"xxx"),但前端从来只发 q —— 那条优化没有调用方
+ 能触发。点用户名即筛他一个人:排查"这是谁干的"时,全文搜索会把路径里含同名
+ 子串的行也带进来,而精确匹配不会。 */
+let auditUser = '';
+
async function loadAudit() {
const q = $('#au-q').value.trim();
- const d = await api(`/audit?limit=100${q ? '&q=' + encodeURIComponent(q) : ''}`);
+ const d = await api(`/audit?limit=100${q ? '&q=' + encodeURIComponent(q) : ''}`
+ + (auditUser ? '&user=' + encodeURIComponent(auditUser) : ''));
const rows = (d && d.rows) || [];
+ $('#au-user-chip').innerHTML = auditUser
+ ? `仅看 ${escapeHtml(auditUser)} ✕` : '';
$('#audit-list').innerHTML = rows.length ? rows.map((r) => {
const bad = r.status >= 400;
const params = r.params && Object.keys(r.params).length
? escapeHtml(JSON.stringify(r.params)).slice(0, 160) : '';
return `
${new Date(r.at).toLocaleString('zh-CN')}
-
${escapeHtml(r.user)}
+
${escapeHtml(r.action)}
${r.status}
${escapeHtml(r.path)}${params ? ' · ' + params : ''}
@@ -2905,6 +3010,19 @@ async function loadAudit() {
}
}
$('#au-refresh').addEventListener('click', loadAudit);
+// 点用户名 → 只看他;点顶部的筛选条 → 取消
+$('#audit-list').addEventListener('click', (e) => {
+ const a = e.target.closest('[data-au-user]');
+ if (!a) return;
+ e.preventDefault();
+ auditUser = a.dataset.auUser === '-' ? '' : a.dataset.auUser;
+ loadAudit();
+});
+$('#au-user-chip').addEventListener('click', (e) => {
+ if (!e.target.closest('#au-user-clear')) return;
+ auditUser = '';
+ loadAudit();
+});
$('#au-q').addEventListener('input', (() => {
let t = null;
return () => { clearTimeout(t); t = setTimeout(loadAudit, 300); };
diff --git a/public/index.html b/public/index.html
index cd4ff11..a46cece 100644
--- a/public/index.html
+++ b/public/index.html
@@ -146,10 +146,10 @@
总览
已停止
-
-
-
-
+
+
+
+
@@ -258,8 +258,8 @@
实时动态
/
-
-
+
+
@@ -558,8 +558,8 @@
隧道状态
- 实例配置
-
+ 实例配置
+
@@ -914,9 +914,9 @@
用户列表
- 操作审计 所有写操作,含失败尝试
+ 操作审计 所有写操作,含失败尝试
-
+
diff --git a/public/style.css b/public/style.css
index df1bea5..019a387 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1563,6 +1563,29 @@ body {
.task-result.ok { color: var(--green); }
.task-result.bad { color: var(--amber); }
+/* 计划任务最近几次执行的结果点。用形状+颜色双编码,不只靠颜色 —— 色觉障碍下
+ 纯绿/黄两个圆点是分不开的,失败的画成方块 */
+/* 权限不足的控件。保留在界面上而不是藏掉,是因为"有这个功能但你没权限"比
+ "什么都没有"更好解释 —— hover 的 title 会说清楚需要哪一档。
+ 真正藏掉的只有协作者卡片(那是主人专属)。
+
+ 透明度对齐上面已有的 `.btn:disabled`(0.35):那条规则特指度 (0,2,0) 本来就
+ 压过这里的 (0,1,0),按钮走的一直是它。这里主要是给非 .btn 的控件(如命令输入框)
+ 兜底,数值写成一样,免得输入框和按钮灰得深浅不同。
+ 不加 filter —— .btn:disabled 明确写了 filter:none,加了也只在输入框上生效,
+ 反而制造不一致。 */
+.perm-denied { opacity: 0.35; cursor: not-allowed !important; }
+.perm-denied:hover { transform: none; filter: none; }
+
+.run-history { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
+.run-dot {
+ display: inline-block; width: 9px; height: 9px;
+ border-radius: 50%; cursor: help;
+}
+.run-dot.ok { background: var(--green); }
+.run-dot.bad { background: var(--amber); border-radius: 1px; }
+[data-style="pixel"] .run-dot { border-radius: 0; border: 1px solid #000; }
+
/* ── 操作审计 ── */
.audit-row {
display: grid; grid-template-columns: 150px 90px 150px 46px 1fr;
diff --git a/scripts/smoke.js b/scripts/smoke.js
index 3a3387b..08c5157 100644
--- a/scripts/smoke.js
+++ b/scripts/smoke.js
@@ -252,6 +252,23 @@ async function collabRoleSuite(iid) {
check('collab operator: 可启停(路由是 /server/:action,别再写成 /start)',
(await as(users.operator, 'POST', '/server/stop')) !== 403);
+ /* perm 要下发给前端,否则界面没法按档位禁用按钮 —— viewer 会看到完整界面、
+ 点了才 403。后端一直算着 req.perm,只是从没发出去过。 */
+ for (const [who, want] of [[users.viewer, 'viewer'], [users.operator, 'operator'], [users.manager, 'manager']]) {
+ cookie = '';
+ await req('POST', '/api/auth/login', { username: who, password: pass });
+ const st = await req('GET', `/api/instances/${iid}/status`);
+ check(`perm: /status 下发 ${want}`, st.json && st.json.perm === want, JSON.stringify(st.json && st.json.perm));
+ const ls = await req('GET', '/api/instances');
+ const mine = (ls.json || []).find((x) => x.id === iid);
+ check(`perm: 实例列表下发 ${want}`, mine && mine.perm === want, JSON.stringify(mine && mine.perm));
+ }
+ cookie = adminCookie;
+ {
+ const st = await req('GET', `/api/instances/${iid}/status`);
+ check('perm: 管理员是 owner 档', st.json && st.json.perm === 'owner', JSON.stringify(st.json && st.json.perm));
+ }
+
// viewer 不该读到凭据:这几条 GET 会吐出 rcon 密码 / 整个 server.properties / 任意文件
check('collab viewer: 禁止读 rcon 密码', (await as(users.viewer, 'GET', '/rcon')) === 403);
check('collab viewer: 禁止读 server.properties', (await as(users.viewer, 'GET', '/properties')) === 403);
@@ -983,7 +1000,21 @@ async function uniqueNameRoundtrip() {
if (inst) await incrementalBackupSuite(inst.id);
if (inst) await chunkedUploadSuite(inst.id);
if (inst) await rconTunnelSuite(inst.id);
- if (inst && isAdmin) await collabRoleSuite(inst.id);
+ /* 协作者权限档:原先只在"面板上恰好已有实例"时才跑,空面板(CI 就是空的)会整段跳过 ——
+ 也就是说这套权限用例在 CI 里从来没执行过,而它覆盖的正是最容易写错的部分。
+ 没有现成实例就自己造一个 import 空壳走完 finalize,跑完删掉。 */
+ if (isAdmin) {
+ let cid = inst && inst.id;
+ let owned = false;
+ if (!cid) {
+ const c = await req('POST', '/api/instances/import', { name: 'smoke-collab', xmx: 512 });
+ cid = c.json && c.json.instance && c.json.instance.id;
+ if (cid) { await finalizeImportShell(cid); owned = true; }
+ check('collab: 自建测试实例(空面板也能跑权限用例)', !!cid, JSON.stringify(c.json));
+ }
+ if (cid) await collabRoleSuite(cid);
+ if (owned && cid) await req('DELETE', `/api/instances/${cid}`);
+ }
/* ── 多租户与新功能的集成用例(功能 15)──
这一段专门覆盖"跨用户"和"权限边界",单用户的 happy path 测不到这些。
diff --git a/src/instance.js b/src/instance.js
index d0d172d..3b8c9b2 100644
--- a/src/instance.js
+++ b/src/instance.js
@@ -266,6 +266,7 @@ class Instance {
type: this.type,
owner: this.owner,
collaborators: this.collaboratorList(), // 统一成 [{name,role}],前端不用再判两种形态
+ createdAt: this.createdAt, // 一直在存也一直在落盘,只是从没往外发过
state: this.state,
installProgress: this.installProgress,
version: this.version,
diff --git a/src/routes/instances.js b/src/routes/instances.js
index e0d6d4f..3244c27 100644
--- a/src/routes/instances.js
+++ b/src/routes/instances.js
@@ -325,7 +325,9 @@ function quotaError(req, extraMB, newInstance, excludeInst) {
/* ── CRUD ── */
router.get('/', (req, res) => {
- res.json(visibleInstances(req).map((i) => i.snapshot()));
+ /* perm 加在路由层而不是 snapshot() 里:同一份 snapshot 还会经 SSE 广播给
+ 权限不同的多个用户,把 perm 塞进去就会发错人。这里按**请求者**逐个算。 */
+ res.json(visibleInstances(req).map((i) => ({ ...i.snapshot(), perm: i.permOf(req.user) })));
});
/* 创建实例:普通用户也可以,但受资源配额约束(实例数/内存);实例归创建者所有 */
@@ -643,7 +645,8 @@ router.delete('/:iid', asyncHandler(async (req, res) => {
/* ── 状态 / 日志 / 指标 / 进程控制 / 命令 ── */
-router.get('/:iid/status', (req, res) => res.json(req.inst.snapshot()));
+// perm 同上:按请求者算,不进 snapshot()
+router.get('/:iid/status', (req, res) => res.json({ ...req.inst.snapshot(), perm: req.perm }));
/**
* 控制台日志。默认回最后 300 行(前端首屏),支持:
* ?q=关键词 大小写不敏感的子串匹配