Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,不打崩进程

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>`:真实 CPU% / RSS 内存实时曲线 | Metrics sampled from `/proc/<pid>`: 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 |

Expand Down
131 changes: 127 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -688,12 +688,16 @@ async function loadFiles(p) {
const ico = e2.type === 'dir' ? '📁' : e2.binary ? '📦' : '📄';
const clickable = e2.type === 'dir' || !e2.binary;
return `
<div class="file-row ${clickable ? 'clickable' : ''}" data-type="${e2.type}" data-binary="${e2.binary}" data-path="${escapeHtml(full)}">
<div class="file-row ${clickable ? 'clickable' : ''}" data-type="${e2.type}" data-binary="${e2.binary}" data-name="${escapeHtml(e2.name)}" data-path="${escapeHtml(full)}">
<div class="f-ico">${ico}</div>
<div class="f-name">${escapeHtml(e2.name)}</div>
<div class="f-size">${e2.type === 'dir' ? '—' : fmtSize(e2.size)}</div>
<div class="f-time">${fmtAgo(e2.mtime)}</div>
<button class="icon-btn danger f-del" data-fdel="${escapeHtml(full)}">删除</button>
<div class="f-actions">
<button class="icon-btn" data-fdl="${escapeHtml(full)}" data-fdir="${e2.type === 'dir'}"
${e2.type === 'dir' ? 'title="打包成 tar.gz 下载"' : ''}>下载</button>
<button class="icon-btn danger" data-fdel="${escapeHtml(full)}">删除</button>
</div>
</div>`;
}).join('');
$('#fm-list').innerHTML = rows || '<div class="empty">空目录</div>';
Expand All @@ -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;
Expand Down Expand Up @@ -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) => `
<div class="up-row" data-up="${i}">
<div class="up-name">${escapeHtml(f.name)}</div>
<div class="up-bar"><i></i></div>
<div class="up-pct">等待…</div>
</div>`).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: '停止实例' };
Expand Down Expand Up @@ -982,9 +1097,10 @@ async function loadBackups() {
<div class="backup-ico">🗄️</div>
<div>
<div class="backup-name">${escapeHtml(b.name)}</div>
<div class="backup-meta">${(b.sizeMB / 1024).toFixed(2)} GB · ${fmtAgo(b.createdAt)}</div>
<div class="backup-meta">${fmtSize(b.size)} · ${fmtAgo(b.createdAt)}</div>
</div>
<div class="spacer"></div>
<button class="icon-btn" data-bact="download" data-id="${b.id}">下载</button>
<button class="icon-btn" data-bact="restore" data-id="${b.id}">恢复</button>
<button class="icon-btn danger" data-bact="delete" data-id="${b.id}">删除</button>
</div>`).join('') : '<div class="empty">暂无备份</div>';
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,14 @@ <h1 id="view-title">总览</h1>
<div class="files-toolbar">
<div class="breadcrumb" id="fm-crumb"></div>
<div class="spacer"></div>
<button class="icon-btn" id="fm-upload">⬆ 上传</button>
<button class="icon-btn" id="fm-newfile">+ 文件</button>
<button class="icon-btn" id="fm-newdir">+ 文件夹</button>
<input type="file" id="fm-file-input" multiple hidden />
</div>
<div class="upload-list" id="fm-uploads" hidden></div>
<div class="file-table" id="fm-list"></div>
<div class="fm-dropmask" id="fm-dropmask" hidden>松开即可上传到当前目录</div>
</div>
<div class="card glass editor-card" id="fm-editor" hidden>
<div class="files-toolbar">
Expand Down
45 changes: 42 additions & 3 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,7 @@ body {

/* ── file manager ── */

.files-card { display: flex; flex-direction: column; }
.files-card { display: flex; flex-direction: column; position: relative; }
.files-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.files-toolbar .spacer { flex: 1; }
.breadcrumb { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 13px; }
Expand All @@ -769,7 +769,7 @@ body {

.file-table { display: flex; flex-direction: column; }
.file-row {
display: grid; grid-template-columns: 26px 1fr 110px 130px 70px;
display: grid; grid-template-columns: 26px 1fr 110px 130px auto;
align-items: center; gap: 10px;
padding: 9px 12px; border-radius: 11px;
font-size: 13px; cursor: default;
Expand All @@ -780,7 +780,44 @@ body {
.file-row .f-ico { font-size: 15px; text-align: center; }
.file-row .f-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.file-row .f-size, .file-row .f-time { color: var(--text-dim); font-size: 12px; }
.file-row .f-del { justify-self: end; }
.file-row .f-actions { display: flex; gap: 6px; justify-self: end; }

/* ── upload(进度条 + 拖拽遮罩)── */

.upload-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.up-row {
display: grid; grid-template-columns: 1fr 160px 108px;
align-items: center; gap: 12px;
padding: 8px 12px; border-radius: 11px; font-size: 13px;
background: rgba(255, 255, 255, 0.05);
}
.up-row .up-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.up-row .up-bar {
height: 5px; border-radius: 3px; overflow: hidden;
background: rgba(255, 255, 255, 0.12);
}
.up-row .up-bar i {
display: block; height: 100%; width: 0;
background: var(--blue); transition: width 0.2s;
}
.up-row .up-pct {
font-size: 12px; color: var(--text-dim); text-align: right;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.up-row.done .up-bar i { background: var(--green); }
.up-row.done .up-pct { color: var(--green); }
.up-row.fail .up-bar i { background: var(--red); }
.up-row.fail .up-pct { color: var(--red); }

.fm-dropmask {
position: absolute; inset: 0; z-index: 5;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 600; color: var(--blue);
background: rgba(var(--acc-rgb), 0.12);
border: 2px dashed rgba(var(--acc-rgb), 0.55);
border-radius: var(--radius);
pointer-events: none; /* 否则遮罩自己会吃掉 dragleave/drop */
}

.editor-card { margin-top: 18px; }
#fm-content {
Expand Down Expand Up @@ -911,6 +948,8 @@ body {
border-color: rgba(0, 0, 0, 0.06);
}
[data-theme="light"] .file-row:hover { background: rgba(0, 0, 0, 0.045); }
[data-theme="light"] .up-row { background: rgba(0, 0, 0, 0.045); }
[data-theme="light"] .up-row .up-bar { background: rgba(0, 0, 0, 0.1); }
[data-theme="light"] .bar { background: rgba(0, 0, 0, 0.1); }
[data-theme="light"] .pill-gray { background: rgba(120, 120, 128, 0.16); color: #6e6e73; }
[data-theme="light"] .pill-green { color: #1d8a43; box-shadow: 0 0 14px rgba(48, 209, 88, 0.2); }
Expand Down
15 changes: 15 additions & 0 deletions scripts/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,27 @@ function check(name, cond, detail = '') {
r = await req('GET', `/api/instances/${iid}/files?path=/../../etc`);
check('path sandbox', r.status === 400);

r = await req('POST', `/api/instances/${iid}/files/upload?path=/&name=${encodeURIComponent('../pwn.txt')}`);
check('upload name sandbox', r.status === 400);

r = await req('POST', `/api/instances/${iid}/files/upload?path=/../../&name=pwn.txt`);
check('upload path sandbox', r.status === 400);

r = await req('GET', `/api/instances/${iid}/files/download?path=/../../etc/passwd`);
check('download path sandbox', r.status === 400);

r = await req('GET', `/api/instances/${iid}/files/download?path=/`);
check('download instance root rejected', r.status === 400);

r = await req('GET', `/api/instances/${iid}/properties`);
check('properties', r.status === 200 && typeof r.json === 'object');

r = await req('GET', `/api/instances/${iid}/backups`);
check('backups list', r.status === 200 && Array.isArray(r.json));

r = await req('GET', `/api/instances/${iid}/backups/${encodeURIComponent('../../../etc/passwd.tar.gz')}/download`);
check('backup id sandbox', r.status === 404);

r = await req('GET', `/api/instances/${iid}/tasks`);
check('tasks list', r.status === 200 && Array.isArray(r.json));

Expand Down
2 changes: 1 addition & 1 deletion src/backups.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function listBackups(inst) {
.filter((f) => f.endsWith('.tar.gz'))
.map((f) => {
const st = fs.statSync(path.join(backupDir(inst), f));
return { id: f, name: f.replace(/\.tar\.gz$/, ''), sizeMB: +(st.size / 1048576).toFixed(1), createdAt: st.mtimeMs };
return { id: f, name: f.replace(/\.tar\.gz$/, ''), size: st.size, sizeMB: +(st.size / 1048576).toFixed(1), createdAt: st.mtimeMs };
})
.sort((a, b) => b.createdAt - a.createdAt);
}
Expand Down
2 changes: 2 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const config = {
BACKUPS_DIR: path.join(ROOT, 'backups'),
BIN_DIR: path.join(ROOT, 'bin'),
JAVA_BIN: process.env.JAVA_BIN || 'java',
// 单个上传文件的大小上限(整合包/世界压缩包可能很大,默认 2 GB)
MAX_UPLOAD_MB: Math.max(1, parseInt(process.env.MCSP_MAX_UPLOAD_MB, 10) || 2048),
TUNNEL_ARCH: os.arch() === 'arm64' ? 'arm64' : 'amd64',
PANEL_STARTED: Date.now(),
SESSION_TTL_MS: 7 * 86400_000,
Expand Down
Loading
Loading