From 86f182cd2a2f2d35dbc45e471037c8bef7591c98 Mon Sep 17 00:00:00 2001 From: SMNETSTUDIO Date: Fri, 28 Aug 2026 12:30:53 +0000 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E6=8A=8A=E5=B7=B2=E7=AE=97=E5=A5=BD?= =?UTF-8?q?=E4=BD=86=E6=B2=A1=E5=8F=91=E5=87=BA=E5=8E=BB=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=8E=A5=E5=88=B0=E7=95=8C=E9=9D=A2=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三批。共同点是后端早就算好了,只差最后一段接线 —— 用户看不到的功能等于没有。 ## 1. csrf 错误码没人接(上个 PR 我自己留下的) api() 只处理 401 和 2fa_required,而我上轮加的 code:'csrf' 没有分支,很多调用点 又不判 ok —— 表现是"点了没反应"。正常同源访问走不到这里,走到了基本只有一种 情况:面板在反代后面而 Host 头没透传对,而那恰恰是最需要把原因说清楚的时候。 顺带把 MCSP_TRUSTED_ORIGINS 这个逃生阀写进 README 和 docker-compose —— 之前它只存在于源码里,遇到问题的人不可能知道有这个东西。 ## 2. 权限分档在 UI 上等于不存在 后端一直算着 req.perm(viewer/operator/manager/owner),但从没发给前端: snapshot() 里没有,前端对 .perm 的引用数是 0。于是 viewer 看到的界面和主人 一模一样,启动、保存配置、清空统计按钮全在,点下去才 403。 "能看见但一按就报错"比"看不见"更糟 —— 用户分不清是自己没权限还是面板坏了。 - perm 加在**路由层**(实例列表 + /status),不进 snapshot():同一份 snapshot 还会经 SSE 广播给权限不同的多个用户,塞进去就会发错人。 - 前端用独立的 permMap 存,不放 instMap —— 否则会被 SSE 的 state 事件覆盖掉。 - 做法是声明式的:HTML 标 data-need="operator|manager|owner",一个函数统一 禁用并给出"需要 X 档,你是 Y 档"的 title。以后加按钮只需加个属性。 - 总览卡片的启停按钮按**卡片所属实例**判权限(不是当前选中实例),只读档直接 不渲染 —— 列表里摆一排点了就报错的按钮比没有按钮更让人困惑。 - .perm-denied 的透明度对齐已有的 .btn:disabled(0.35)。那条规则特指度 (0,2,0) 本来就压过 (0,1,0),按钮走的一直是它;这里主要给非 .btn 控件兜底,数值写一样 免得输入框和按钮灰得深浅不同,也不加 filter(否则只在输入框上生效,更不一致)。 ## 3. 五处"数据已在手边,差一行渲染" - **计划任务执行历史**:后端存最近 5 次并落盘、API 原样下发,前端只画了 lastResult。而"偶尔抽风"和"一直坏"是两种处置,一次结果分不出来。 画成结果点,失败用方块+琥珀色(形状+颜色双编码,不只靠颜色)。 - **实例 createdAt**:三处写入、一直落盘,却从没进过 snapshot。用户/邀请/备份 都有"创建于",唯独实例没有。 - **playtime firstSeen**:后端三层都传到前端了,渲染时跳过了。 - **审计按用户精确筛选**:read() 早就支持 user 参数,还为它写了条免 JSON.parse 的快路径(直接在原始行里找 "user":"xxx"),但前端只发 q —— 那条优化没有调用方 能触发。改成点用户名即筛他一个人;全文搜索会把路径里含同名子串的行也带进来, 精确匹配不会。 - **导入探测置信度**:detect.js 算了 high/low/none 三级(三条线索都对上才 high), 前端只用了 d.type,于是"三样都对上"和"只猜出个类型"反馈一模一样,而后者 需要用户去核对。notes 也从只显示第一条改成全部显示 —— 里面有"未找到已同意的 eula.txt"这类直接影响能否启动的提示,漏掉后面几条等于让用户自己去撞。 ## 测试 85 → 111 项。新增 perm 下发的 7 条(三档 + 管理员,列表与 /status 各一)。 **并修好了一个测试覆盖缺口**:collabRoleSuite 原先只在"面板上恰好已有实例"时才跑, 空面板(CI 就是空的)会整段跳过 —— 也就是说这套权限用例在 CI 里从来没执行过, 而它覆盖的正是最容易写错的部分。现在没有现成实例就自建一个空壳走完 finalize, 跑完删掉。上个 PR 里靠手工验证的那批权限断言,现在 CI 每次都会跑。 前端部分另在真实浏览器里核对:viewer 身份下 9 个控件全部 disabled 且 title 正确、 协作者卡片隐藏、createdAt 正常渲染、三类控件透明度一致(0.35)。 Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- docker-compose.yml | 5 ++ public/app.js | 140 ++++++++++++++++++++++++++++++++++++---- public/index.html | 24 +++---- public/style.css | 23 +++++++ scripts/smoke.js | 33 +++++++++- src/instance.js | 1 + src/routes/instances.js | 7 +- 8 files changed, 211 insertions(+), 27 deletions(-) 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() {
${i.state === 'installing' ? `安装中 ${i.installProgress || 0}%` : txt}
- ${i.state === 'stopped' - ? `` - : (i.state === 'installing' || i.state === 'importing') ? '' - : ``} + ${(() => { + /* 总览卡片上的启停是**按卡片所属实例**判权限的,不能用 canDo() + (那个看的是当前选中实例)。只读档直接不渲染按钮 —— 列表里摆一排 + 点了就报错的按钮,比没有按钮更让人困惑 */ + const p = permMap.get(i.id); + if (p && (PERM_LEVEL[p] || 0) < PERM_LEVEL.operator) return ''; + if (i.state === 'stopped') return ``; + if (i.state === 'installing' || i.state === 'importing') return ''; + return ``; + })()}
`; @@ -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 @@

实时动态

/ - - + +
@@ -283,7 +283,7 @@

在线玩家 0
在线时长 按进出服记录累计 - +
@@ -402,7 +402,7 @@

新建计划任务

- +

@@ -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=关键词 大小写不敏感的子串匹配