diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b2033d3..4dd42b8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,6 +68,13 @@ server.js(入口,10 行) - 全部 `/api`(除 health/auth/login)要求 HttpOnly Cookie 会话;admin 路由再叠 `requireAdmin` - 文件 API 路径沙箱:`path.resolve` 后必须仍在实例目录内;在线编辑仅限文本扩展名 ≤2MB +- 上传是原始流(`POST …/files/upload`,body 即文件本身,不引 multipart 依赖): + 先写 `.mcsp-upload-*` 再 rename,中断不留半截文件;文件名必须单段(禁 `/` `\` `.` `..` 与控制字符), + 超过 `MCSP_MAX_UPLOAD_MB`(默认 2048)立即断流回 413 +- 下载走同一个沙箱:文件用 `res.download` 原样回传,目录现 `tar czf -` 流式打包(不落盘、 + 客户端断开即 SIGKILL 掉 tar);实例根目录不给下载,那是「备份」的活(会先 save-all) +- 备份 id 必须匹配 `^[\w.-]+\.tar\.gz$` —— Express 会解码 `:id`,不校验的话 `..%2F` 能带着 + `path.join` 走出 `backups/`(download/restore/delete 三处共用该校验) - 登录限速(5 次失败锁 1 分钟);隧道配置输入全部白名单化清洗 - 全局错误中间件 + `asyncHandler`:异步路由抛错返回 500 JSON,不打崩进程 diff --git a/README.md b/README.md index b0ccc18..7adb3f1 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ with **5 built-in tunnels**, **authlib-injector external auth**, and multi-tenan | ❯_ 控制台 = 真实 stdout 流(SSE)+ stdin 命令(↑↓ 历史);玩家/封禁/白名单/OP 均为真实数据 | Console = real stdout stream (SSE) + stdin commands; players/bans/whitelist/OP are real server data | | ⇄ 五种内网穿透:**bore / playit.gg / Pinggy / ngrok / frpc**,每实例独立隧道、公网地址自动解析;frpc 支持 **frps-panel** 多用户鉴权(user + metadatas.token) | 5 tunnels: **bore / playit.gg / Pinggy / ngrok / frpc**, one tunnel per instance with auto-parsed public address; frpc supports **frps-panel** auth (user + metadatas.token) | | 📊 指标采样自 `/proc/`:真实 CPU% / RSS 内存实时曲线 | Metrics sampled from `/proc/`: real CPU% / RSS with live charts | -| 🗀 文件管理器(路径沙箱)、✦ 插件启停(`.jar ⇄ .jar.disabled`)、◍ 世界管理、◷ 计划任务 | Sandboxed file manager, plugin toggle (`.jar ⇄ .jar.disabled`), world management, scheduled tasks | -| ⧉ 真实 `tar.gz` 备份/恢复,备份前自动 `save-all` | Real `tar.gz` backup/restore with automatic `save-all` | +| 🗀 文件管理器(路径沙箱):在线编辑 + **拖拽/多选上传**(实时进度条)+ **文件下载 / 目录打包 tar.gz 下载**、✦ 插件启停(`.jar ⇄ .jar.disabled`)、◍ 世界管理、◷ 计划任务 | Sandboxed file manager: online editing + **drag-and-drop / multi-file upload** with live progress + **file download / folder download as tar.gz**, plugin toggle (`.jar ⇄ .jar.disabled`), world management, scheduled tasks | +| ⧉ 真实 `tar.gz` 备份/恢复/**下载**,备份前自动 `save-all` | Real `tar.gz` backup / restore / **download**, with automatic `save-all` | | ◉ 多租户:普通用户实例**隔离**,配额真实生效——实例数 / 内存(-Xmx 之和)/ CPU 核(taskset 绑核) | Multi-tenant: isolated user instances with enforced quotas — instance count / memory (Σ-Xmx) / CPU cores (taskset pinning) | | 🎨 双主题:像素风(Minecraft GUI 质感)/ Apple 液态玻璃;深浅色、6 主题色、密度可调 | Two themes: pixel (Minecraft GUI) / Apple liquid glass; dark/light, 6 accent colors, density options | diff --git a/public/app.js b/public/app.js index 716d493..cfe1635 100644 --- a/public/app.js +++ b/public/app.js @@ -688,12 +688,16 @@ async function loadFiles(p) { const ico = e2.type === 'dir' ? '📁' : e2.binary ? '📦' : '📄'; const clickable = e2.type === 'dir' || !e2.binary; return ` -
+
${ico}
${escapeHtml(e2.name)}
${e2.type === 'dir' ? '—' : fmtSize(e2.size)}
${fmtAgo(e2.mtime)}
- +
+ + +
`; }).join(''); $('#fm-list').innerHTML = rows || '
空目录
'; @@ -705,6 +709,19 @@ $('#fm-crumb').addEventListener('click', (e) => { }); $('#fm-list').addEventListener('click', async (e) => { + const dl = e.target.closest('[data-fdl]'); + if (dl) { + // 和备份下载同理:交给浏览器,不走 fetch 攒 blob + const isDir = dl.dataset.fdir === 'true'; + const name = dl.dataset.fdl.split('/').pop(); + const a = document.createElement('a'); + a.href = `/api/instances/${currentIid}/files/download?path=${encodeURIComponent(dl.dataset.fdl)}`; + a.download = isDir ? name + '.tar.gz' : name; + a.click(); + // 目录要先 tar 完才有数据,大世界能压好一会儿,别让用户以为没反应 + toast(isDir ? '正在打包,稍后开始下载…' : '已开始下载'); + return; + } const del = e.target.closest('[data-fdel]'); if (del) { if (!confirm(`删除 ${del.dataset.fdel} ?`)) return; @@ -747,6 +764,104 @@ $('#fm-newdir').addEventListener('click', async () => { r.ok ? loadFiles(fmPath) : toast(r.error, true); }); +/* ── 上传:XHR(要 upload.progress,fetch 给不了)· body 就是文件本身 ── */ + +function uploadOne(file, dir, overwrite, onProgress) { + return new Promise((resolve) => { + const q = `?path=${encodeURIComponent(dir)}&name=${encodeURIComponent(file.name)}${overwrite ? '&overwrite=1' : ''}`; + const xhr = new XMLHttpRequest(); + xhr.open('POST', `/api/instances/${currentIid}/files/upload${q}`); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total); + }); + xhr.addEventListener('load', () => { + if (xhr.status === 401) { location.href = '/login'; return resolve({ ok: false, error: '会话已过期' }); } + let r = null; + try { r = JSON.parse(xhr.responseText); } catch {} + resolve(r || { ok: false, error: `HTTP ${xhr.status}` }); + }); + xhr.addEventListener('error', () => resolve({ ok: false, error: '网络错误' })); + xhr.addEventListener('abort', () => resolve({ ok: false, error: '已取消' })); + xhr.send(file); + }); +} + +async function uploadFiles(fileList) { + // 必须先拷成数组:FileList 是 input.files 的实时引用, + // 调用方一清空 input.value 它就空了,循环会从第二个文件起全部漏掉 + const files = [...fileList]; + if (!files.length) return; + const dir = fmPath; + // 同名一次问清楚,免得传了几百 MB 才在 409 上卡住 + const existing = new Set($$('#fm-list .file-row').map((r) => r.dataset.name)); + const dupes = files.filter((f) => existing.has(f.name)); + if (dupes.length && !confirm(`以下文件已存在,覆盖?\n${dupes.map((f) => f.name).join('\n')}`)) return; + + const box = $('#fm-uploads'); + box.hidden = false; + box.innerHTML = files.map((f, i) => ` +
+
${escapeHtml(f.name)}
+
+
等待…
+
`).join(''); + + let failed = 0; + for (let i = 0; i < files.length; i++) { + const row = box.querySelector(`[data-up="${i}"]`); + const bar = row.querySelector('.up-bar i'); + const pct = row.querySelector('.up-pct'); + const r = await uploadOne(files[i], dir, existing.has(files[i].name), (p) => { + bar.style.width = `${Math.round(p * 100)}%`; + pct.textContent = `${Math.round(p * 100)}%`; + }); + row.classList.add(r.ok ? 'done' : 'fail'); + bar.style.width = '100%'; + pct.textContent = r.ok ? '完成' : r.error; + pct.title = r.ok ? '' : r.error; // 错误文案比列宽长,截断后靠 tooltip 看全 + if (!r.ok) failed++; + } + + if (dir === fmPath) loadFiles(fmPath); + toast(failed ? `${files.length - failed} 个成功,${failed} 个失败` : `${files.length} 个文件已上传`, !!failed); + setTimeout(() => { box.hidden = true; box.innerHTML = ''; }, failed ? 8000 : 2500); +} + +$('#fm-upload').addEventListener('click', () => $('#fm-file-input').click()); + +$('#fm-file-input').addEventListener('change', (e) => { + uploadFiles(e.target.files); + e.target.value = ''; // 同一个文件再选一次也能触发 change +}); + +/* 拖拽上传:dragenter/leave 会在子元素间乱跳,用计数器判断真正离开卡片 */ +let fmDragDepth = 0; +const fmCard = $('#view-files .files-card'); + +fmCard.addEventListener('dragenter', (e) => { + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + if (++fmDragDepth === 1) $('#fm-dropmask').hidden = false; +}); +fmCard.addEventListener('dragover', (e) => { + if (e.dataTransfer.types.includes('Files')) e.preventDefault(); +}); +fmCard.addEventListener('dragleave', () => { + if (--fmDragDepth <= 0) { fmDragDepth = 0; $('#fm-dropmask').hidden = true; } +}); +fmCard.addEventListener('drop', (e) => { + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + fmDragDepth = 0; + $('#fm-dropmask').hidden = true; + // 拖进来的目录在 .files 里也是一个 File(大小 0),传上去只会得到一个空文件 + const entries = [...e.dataTransfer.items].map((it) => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null)); + const files = [...e.dataTransfer.files].filter((_, i) => !entries[i] || !entries[i].isDirectory); + if (files.length < e.dataTransfer.files.length) toast('已跳过文件夹,暂不支持整目录上传', true); + uploadFiles(files); +}); + /* ───────── scheduled tasks ───────── */ const ACTION_TEXT = { restart: '重启实例', backup: '创建备份', command: '执行命令', start: '启动实例', stop: '停止实例' }; @@ -982,9 +1097,10 @@ async function loadBackups() {
🗄️
${escapeHtml(b.name)}
-
${(b.sizeMB / 1024).toFixed(2)} GB · ${fmtAgo(b.createdAt)}
+
${fmtSize(b.size)} · ${fmtAgo(b.createdAt)}
+
`).join('') : '
暂无备份
'; @@ -998,7 +1114,14 @@ $('#backup-create').addEventListener('click', async () => { $('#backup-list').addEventListener('click', async (e) => { const btn = e.target.closest('[data-bact]'); if (!btn) return; - if (btn.dataset.bact === 'restore') { + if (btn.dataset.bact === 'download') { + // 备份可能有好几 GB,交给浏览器自己下载,不要走 fetch 攒 blob + const a = document.createElement('a'); + a.href = `/api/instances/${currentIid}/backups/${encodeURIComponent(btn.dataset.id)}/download`; + a.download = btn.dataset.id; + a.click(); + toast('已开始下载'); + } else if (btn.dataset.bact === 'restore') { const r = await iapi(`/backups/${btn.dataset.id}/restore`, { method: 'POST' }); r.ok ? toast('备份恢复完成') : toast(r.error, true); } else { diff --git a/public/index.html b/public/index.html index 85c192b..7c84914 100644 --- a/public/index.html +++ b/public/index.html @@ -232,10 +232,14 @@

总览

+ +
+
+