From 5cc53b1076e68c73a638c7ff7401421482e7762c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 27 May 2026 21:14:37 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 38 +++++++ web/static/js/webshell.js | 231 ++++++++++++++++++++++++++++++-------- 2 files changed, 222 insertions(+), 47 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index e4cefcee..2ca1c495 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -12970,6 +12970,7 @@ header { align-items: center; border-radius: 8px; margin: 2px 0; + min-width: 0; } .webshell-tree-row.active { @@ -13041,6 +13042,12 @@ header { font-weight: 600; } +.webshell-tree-row.selected-file .webshell-dir-item { + background: rgba(0, 102, 255, 0.08); + color: var(--accent-color); + font-weight: 600; +} + .webshell-file-main { display: flex; flex-direction: column; @@ -13237,6 +13244,11 @@ header { text-align: left; border-bottom: 1px solid var(--border-color); transition: background 0.15s ease; + min-width: 0; +} + +.webshell-col-name { + min-width: 0; } .webshell-file-empty-state { @@ -13256,6 +13268,15 @@ header { background: var(--bg-secondary); } +.webshell-file-table tbody tr.webshell-file-row-selected { + background: rgba(0, 102, 255, 0.1); +} + +.webshell-file-table tbody tr.webshell-file-row-selected a.webshell-file-link { + color: var(--accent-hover); + font-weight: 600; +} + .webshell-file-table tbody tr:last-child td { border-bottom: none; } @@ -13267,6 +13288,12 @@ header { padding: 2px 4px; border-radius: 4px; transition: background 0.15s ease, color 0.15s ease; + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; } .webshell-file-table a.webshell-file-link:hover { @@ -13430,6 +13457,17 @@ header { padding: 12px 0; } +.webshell-file-content-path { + font-size: 0.85rem; + color: var(--text-secondary); + padding: 8px 12px; + font-family: ui-monospace, monospace; + word-break: break-all; + background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); + border-radius: 8px; + border: 1px solid var(--border-color); +} + .webshell-file-content .btn-ghost { margin-top: 12px; padding: 8px 16px; diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index a54ba187..a0e5d96e 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -34,6 +34,7 @@ let webshellDbConfigByConn = {}; let webshellDirTreeByConn = {}; let webshellDirExpandedByConn = {}; let webshellDirLoadedByConn = {}; +let webshellSelectedFileByConn = {}; // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 let webshellStreamingTypingId = 0; let webshellProbeStatusById = {}; @@ -70,6 +71,23 @@ function webshellConnOS(conn) { return normalizeWebshellOS(conn && conn.os); } +/** 生成一次性探活 token,避免固定回显值被包装时误判 */ +function buildWebshellProbeToken() { + return '__CSAI_PROBE_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36) + '__'; +} + +/** 构造跨 Windows/Linux 都可执行的探活命令 */ +function buildWebshellProbeCommand(token) { + return 'echo ' + token; +} + +/** 探活成功判定:HTTP 成功且输出中包含本次 token */ +function isWebshellProbeOutputMatched(output, token) { + if (!token) return false; + var text = (output == null) ? '' : String(output); + return text.indexOf(token) !== -1; +} + /** * 组装 /api/webshell/file 的公共请求体。 * 所有文件管理调用点都应走此函数,避免遗漏字段(如 connection_id)。 @@ -816,6 +834,7 @@ function probeWebshellConnection(conn) { if (!conn || typeof apiFetch === 'undefined') { return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' }); } + var probeToken = buildWebshellProbeToken(); return apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -827,13 +846,13 @@ function probeWebshellConnection(conn) { cmd_param: conn.cmdParam || '', encoding: webshellConnEncoding(conn), os: webshellConnOS(conn), - command: 'echo 1' + command: buildWebshellProbeCommand(probeToken) }) }) .then(function (r) { return r.json(); }) .then(function (data) { - var output = (data && data.output != null) ? String(data.output).trim() : ''; - var ok = !!(data && data.ok && output === '1'); + var output = (data && data.output != null) ? String(data.output) : ''; + var ok = !!(data && data.ok && isWebshellProbeOutputMatched(output, probeToken)); if (ok) return { ok: true, message: wsT('webshell.testSuccess') || '连通性正常,Shell 可访问' }; var msg = (data && data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); return { ok: false, message: msg }; @@ -931,11 +950,61 @@ function normalizeWebshellPath(path) { var p = path == null ? '.' : String(path).trim(); if (!p || p === '/') return '.'; p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/'); + // Windows 盘符根目录保持为 "C:/",避免被裁成 "C:" 后父级计算异常 + if (/^[A-Za-z]:\/?$/.test(p)) { + return p.slice(0, 2) + '/'; + } if (!p || p === '.') return '.'; if (p.endsWith('/')) p = p.slice(0, -1); return p || '.'; } +function getWebshellSelectedFile(conn) { + if (!conn || !conn.id) return ''; + var p = webshellSelectedFileByConn[conn.id]; + if (!p) return ''; + return normalizeWebshellPath(p); +} + +function setWebshellSelectedFile(conn, path) { + if (!conn || !conn.id) return; + if (!path) { + delete webshellSelectedFileByConn[conn.id]; + return; + } + webshellSelectedFileByConn[conn.id] = normalizeWebshellPath(path); +} + +function getWebshellParentPath(path) { + var p = normalizeWebshellPath(path); + // Windows 盘符根目录不可再上探 + if (/^[A-Za-z]:\/$/.test(p)) return p; + // 允许从当前目录持续上探:. -> .. -> ../.. -> ../../.. + if (p === '.') return '..'; + if (/^(?:\.\.\/)*\.\.$/.test(p)) return p + '/..'; + // 已经是相对上探时,先维持链路;后续 list 成功后会用远端真实路径回填 + var idx = p.lastIndexOf('/'); + if (idx < 0) return '.'; + var parent = p.slice(0, idx) || '.'; + if (/^[A-Za-z]:$/.test(parent)) return parent + '/'; + return parent; +} + +function inferPathFromWindowsDirOutput(rawOutput) { + var text = String(rawOutput || '').replace(/\r/g, ''); + var lines = text.split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = String(lines[i] || '').trim(); + // 中文: C:\xxx 的目录 + var zh = line.match(/^([A-Za-z]:\\.*)\s+的目录$/); + if (zh && zh[1]) return normalizeWebshellPath(zh[1]); + // 英文: Directory of C:\xxx + var en = line.match(/^Directory of\s+([A-Za-z]:\\.*)$/i); + if (en && en[1]) return normalizeWebshellPath(en[1]); + } + return ''; +} + function getWebshellTerminalSessionKey(connId, sessionId) { if (!connId || !sessionId) return ''; return String(connId) + '::' + String(sessionId); @@ -2047,11 +2116,7 @@ function selectWebshell(id, stateReady) { }); document.getElementById('webshell-parent-dir').addEventListener('click', function () { const p = (pathInput && pathInput.value.trim()) || '.'; - if (p === '.' || p === '/') { - pathInput.value = '..'; - } else { - pathInput.value = p.replace(/\/[^/]+$/, '') || '.'; - } + pathInput.value = getWebshellParentPath(p); webshellFileListDir(webshellCurrentConn, pathInput.value || '.'); }); @@ -3578,9 +3643,14 @@ function webshellFileListDir(conn, path) { listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; return; } - listEl.dataset.currentPath = path; + var normalizedPath = normalizeWebshellPath(path); + var inferredPath = inferPathFromWindowsDirOutput(data.output || ''); + var displayPath = inferredPath || normalizedPath; + listEl.dataset.currentPath = displayPath; listEl.dataset.rawOutput = data.output || ''; - renderFileList(listEl, path, data.output || '', conn); + var pathInput = document.getElementById('webshell-file-path'); + if (pathInput) pathInput.value = displayPath; + renderFileList(listEl, displayPath, data.output || '', conn); }) .catch(function (err) { listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '
'; @@ -3619,6 +3689,27 @@ function modeToType(mode) { return c; } +function parseWindowsDirEntry(line) { + var m = String(line || '').match(/^(\d{4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2})(?:\s*(AM|PM))?\s+(<[^>]+>|[\d,]+)\s+(.+?)\s*$/i); + if (!m) return null; + var kind = (m[4] || '').trim(); + var name = (m[5] || '').trim(); + if (!name || name === '.' || name === '..') return null; + var isDir = /^<(dir|junction|symlinkd)>$/i.test(kind); + var size = isDir ? '' : kind.replace(/,/g, ''); + var mtime = (m[1] + ' ' + m[2] + (m[3] ? (' ' + m[3].toUpperCase()) : '')).trim(); + return { + name: name, + isDir: isDir, + size: size, + mtime: mtime, + mode: isDir ? 'd' : '-', + owner: '', + group: '', + type: isDir ? 'dir' : 'file' + }; +} + function parseWebshellListItems(rawOutput) { var lines = (rawOutput || '').split(/\n/).filter(function (l) { return l.trim(); }); var items = []; @@ -3627,6 +3718,12 @@ function parseWebshellListItems(rawOutput) { var trimmedLine = String(line || '').trim(); // `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。 if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue; + // `dir` 头尾信息(中英文)与 shell 提示符,不是目录项。 + if (/^(驱动器|卷的序列号是|volume in drive|volume serial number is|directory of)/i.test(trimmedLine)) continue; + if (/^[A-Za-z]:\\.*\s+的目录$/i.test(trimmedLine)) continue; + if (/^\d+\s+(个文件|file\(s\))\s+[\d,]+\s+(字节|bytes?)$/i.test(trimmedLine)) continue; + if (/^\d+\s+(个目录|dir\(s\))\s+[\d,]+\s+(可用字节|bytes free)$/i.test(trimmedLine)) continue; + if (/^[^>\n]*>\s*dir(?:\s|$)/i.test(trimmedLine)) continue; var name = ''; var isDir = false; var size = ''; @@ -3646,16 +3743,38 @@ function parseWebshellListItems(rawOutput) { isDir = mode && mode.startsWith('d'); type = modeToType(mode); } else { - var mName = line.match(/\s*(\S+)\s*$/); - name = mName ? mName[1].trim() : line.trim(); - if (name === '.' || name === '..') continue; - isDir = line.startsWith('d') || line.toLowerCase().indexOf('') !== -1; - if (line.startsWith('-') || line.startsWith('d')) { - var parts = line.split(/\s+/); + var winItem = parseWindowsDirEntry(line); + if (winItem) { + items.push({ + name: winItem.name, + isDir: winItem.isDir, + line: line, + size: winItem.size, + mode: winItem.mode, + mtime: winItem.mtime, + owner: winItem.owner, + group: winItem.group, + type: winItem.type + }); + continue; + } + // 仅兜底解析 Unix 权限格式,避免把 `dir` 统计行误识别为文件。 + if (/^[-dlcbsp]/.test(line)) { + var parts = line.trim().split(/\s+/); + if (parts.length >= 9) { + name = parts.slice(8).join(' ').trim(); + } else { + name = parts.length ? parts[parts.length - 1].trim() : line.trim(); + } + if (name === '.' || name === '..') continue; + isDir = line.startsWith('d'); + parts = line.split(/\s+/); if (parts.length >= 5) { mode = parts[0]; size = parts[4]; } if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; } if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) mtime = normalizeLsMtime(parts[5], parts[6], parts[7]); type = modeToType(mode); + } else { + continue; } } if (name === '.' || name === '..') continue; @@ -3680,7 +3799,9 @@ function fetchWebshellDirectoryItems(conn, path) { } function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { + currentPath = normalizeWebshellPath(currentPath); var items = parseWebshellListItems(rawOutput); + var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn); if (nameFilter && nameFilter.trim()) { var f = nameFilter.trim().toLowerCase(); items = items.filter(function (item) { return item.name.toLowerCase().indexOf(f) !== -1; }); @@ -3713,10 +3834,11 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { } items.forEach(function (item) { var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; + var pathNextNorm = normalizeWebshellPath(pathNext); var nameClass = item.isDir ? 'is-dir' : 'is-file'; - html += ''; + html += ''; if (!item.isDir) html += ''; - html += '' + escapeHtml(item.name) + (item.isDir ? '/' : '') + ''; + html += '' + escapeHtml(item.name) + (item.isDir ? '/' : '') + ''; html += '' + escapeHtml(item.size) + ''; html += '' + escapeHtml(item.mtime || '') + ''; html += '' + escapeHtml(item.owner || '') + ''; @@ -3748,10 +3870,13 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { const isDir = a.getAttribute('data-isdir') === '1'; const pathInput = document.getElementById('webshell-file-path'); if (isDir) { + setWebshellSelectedFile(webshellCurrentConn, ''); if (pathInput) pathInput.value = path; webshellFileListDir(webshellCurrentConn, path); } else { // 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图 + setWebshellSelectedFile(webshellCurrentConn, path); + renderDirectoryTree(currentPath, items, conn || webshellCurrentConn); webshellFileRead(webshellCurrentConn, path, listEl, currentPath); } }); @@ -3759,7 +3884,10 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); - webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl, currentPath); + var filePath = btn.getAttribute('data-path'); + setWebshellSelectedFile(webshellCurrentConn, filePath); + renderDirectoryTree(currentPath, items, conn || webshellCurrentConn); + webshellFileRead(webshellCurrentConn, filePath, listEl, currentPath); }); }); listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) { @@ -3821,6 +3949,7 @@ function renderDirectoryTree(currentPath, items, conn) { var tree = state.tree; var expanded = state.expanded; var loaded = state.loaded; + var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn); if (!tree['.']) tree['.'] = []; if (expanded['.'] !== false) expanded['.'] = true; @@ -3844,26 +3973,29 @@ function renderDirectoryTree(currentPath, items, conn) { if (node.isDir && !tree[node.path]) tree[node.path] = []; }); - // 确保当前路径祖先链存在并展开 + // 仅对“真实路径”补祖先链;相对上探链(../..)不构建,避免出现假层级。 + var isRelativeUpChain = /^(?:\.\.\/)*\.\.$/.test(curr); var parts = curr === '.' ? [] : curr.split('/'); var parentPath = '.'; - for (var i = 0; i < parts.length; i++) { - var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i]; - if (!tree[parentPath]) tree[parentPath] = []; - var parentChildren = tree[parentPath]; - var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; }); - if (!hasAncestorNode) { - parentChildren.push({ path: nextPath, name: parts[i], isDir: true }); - parentChildren.sort(function (a, b) { - if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1; - return (a.name || '').localeCompare(b.name || ''); - }); + if (!isRelativeUpChain) { + for (var i = 0; i < parts.length; i++) { + var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i]; + if (!tree[parentPath]) tree[parentPath] = []; + var parentChildren = tree[parentPath]; + var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; }); + if (!hasAncestorNode) { + parentChildren.push({ path: nextPath, name: parts[i], isDir: true }); + parentChildren.sort(function (a, b) { + if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + } + if (!tree[nextPath]) tree[nextPath] = []; + expanded[parentPath] = true; + parentPath = nextPath; } - if (!tree[nextPath]) tree[nextPath] = []; - expanded[parentPath] = true; - parentPath = nextPath; } - expanded[curr] = true; + if (expanded[curr] == null) expanded[curr] = true; function renderNode(node, depth) { var path = node.path; @@ -3872,15 +4004,16 @@ function renderDirectoryTree(currentPath, items, conn) { var hasLoadedChildren = isDir ? (loaded[path] === true) : true; var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0); var hasChildren = children.length > 0; - var isExpanded = isDir ? (expanded[path] !== false) : false; + var isExpanded = isDir ? (expanded[path] === true) : false; var isActive = path === curr; + var isSelectedFile = !isDir && path === selectedPath; var name = node.name; var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄'; var nodeHtml = '
' + - '
' + + '
' + '' + - '' + + '' + '
'; if (isDir && hasChildren && isExpanded) { nodeHtml += '
'; @@ -3899,7 +4032,7 @@ function renderDirectoryTree(currentPath, items, conn) { e.preventDefault(); e.stopPropagation(); var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); - if (expanded[p] !== false) { + if (expanded[p] === true) { expanded[p] = false; renderDirectoryTree(curr, items, conn || webshellCurrentConn); return; @@ -3939,12 +4072,15 @@ function renderDirectoryTree(currentPath, items, conn) { var isDir = btn.getAttribute('data-isdir') === '1'; var pathInput = document.getElementById('webshell-file-path'); if (isDir) { + setWebshellSelectedFile(webshellCurrentConn, ''); if (pathInput) pathInput.value = p; webshellFileListDir(webshellCurrentConn, p); return; } var listEl = document.getElementById('webshell-file-list'); var browsePath = p.replace(/\/[^/]+$/, '') || '.'; + setWebshellSelectedFile(webshellCurrentConn, p); + renderDirectoryTree(curr, items, conn || webshellCurrentConn); if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath); }); }); @@ -4101,7 +4237,7 @@ function webshellFileRead(conn, path, listEl, browsePath) { // 兜底:若路径被污染成文件路径,回退到父目录 backPath = path.replace(/\/[^/]+$/, '') || '.'; } - listEl.innerHTML = '
' + escapeHtml(out) + '
'; + listEl.innerHTML = '
' + escapeHtml(path) + '
' + escapeHtml(out) + '
'; var backBtn = document.getElementById('webshell-file-back-btn'); if (backBtn) { backBtn.addEventListener('click', function () { @@ -4467,7 +4603,7 @@ document.addEventListener('conversation-deleted', function (e) { } }); -// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1) +// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行一次性探活命令) function testWebshellConnection() { var url = (document.getElementById('webshell-url') || {}).value; if (url && typeof url.trim === 'function') url = url.trim(); @@ -4484,13 +4620,14 @@ function testWebshellConnection() { var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value); var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value); var btn = document.getElementById('webshell-test-btn'); + var probeToken = buildWebshellProbeToken(); if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } if (typeof apiFetch === 'undefined') { if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } alert(wsT('webshell.testFailed') || '连通性测试失败'); return; } - // 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价) + // 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo token 在 cmd 和 sh 下行为等价) apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -4502,7 +4639,7 @@ function testWebshellConnection() { cmd_param: cmdParam || '', encoding: encoding, os: osTag, - command: 'echo 1' + command: buildWebshellProbeCommand(probeToken) }) }) .then(function (r) { return r.json(); }) @@ -4512,14 +4649,14 @@ function testWebshellConnection() { alert(wsT('webshell.testFailed') || '连通性测试失败'); return; } - // 仅 HTTP 200 不算通过,需校验是否真的执行了 echo 1(响应体 trim 后应为 "1") - var output = (data.output != null) ? String(data.output).trim() : ''; - var reallyOk = data.ok && output === '1'; + // 仅 HTTP 200 不算通过,需校验响应中是否包含本次一次性探活 token + var output = (data.output != null) ? String(data.output) : ''; + var reallyOk = data.ok && isWebshellProbeOutputMatched(output, probeToken); if (reallyOk) { alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问'); } else { var msg; - if (data.ok && output !== '1') + if (data.ok && !isWebshellProbeOutputMatched(output, probeToken)) msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名'; else msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败');