diff --git a/web/static/css/style.css b/web/static/css/style.css index 3b254183..a8403ddf 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -9646,6 +9646,31 @@ header { background: var(--bg-secondary); border: 1px solid var(--border-color); } +.webshell-ai-msg.assistant.webshell-ai-msg-error { + max-width: 72%; + border-color: rgba(220, 53, 69, 0.35); + background: rgba(220, 53, 69, 0.06); +} +.webshell-ai-error-head { + color: var(--error-color); + font-weight: 600; + line-height: 1.45; +} +.webshell-ai-error-detail { + margin-top: 6px; + font-size: 0.82rem; +} +.webshell-ai-error-detail summary { + cursor: pointer; + color: var(--text-secondary); +} +.webshell-ai-error-detail pre { + margin-top: 6px; + max-height: 140px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} /* AI 助手 markdown 渲染优化:避免行间距离过大和内容横向溢出 */ .webshell-ai-msg.assistant { /* markdown 里已经有块级元素,不需要再整体 pre-wrap,否则容易在块之间产生“空行”感 */ @@ -9757,6 +9782,152 @@ header { justify-content: center; } +/* WebShell 数据库管理 Tab */ +.webshell-pane-db { + flex: 1; + min-height: 0; + flex-direction: column; + gap: 12px; + padding: 14px; + overflow: hidden; + background: linear-gradient(180deg, rgba(2, 6, 23, 0.015) 0%, rgba(2, 6, 23, 0.03) 100%); + border-radius: 10px; +} +.webshell-db-toolbar { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 12px; + padding: 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%); + box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.9); +} +.webshell-db-toolbar label { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(15, 23, 42, 0.08); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} +.webshell-db-toolbar label:focus-within { + border-color: rgba(0, 102, 255, 0.38); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12); + transform: translateY(-1px); +} +.webshell-db-toolbar label span { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.webshell-db-toolbar .form-control { + height: 36px; + border-radius: 8px; + border: 1px solid rgba(15, 23, 42, 0.16); + background: #fff; + font-size: 0.9rem; + padding-left: 10px; + padding-right: 10px; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} +.webshell-db-toolbar .form-control:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); + background: #fff; +} +#webshell-db-sqlite-row { + grid-column: 1 / -1; +} +.webshell-db-sql { + width: 100%; + min-height: 140px; + resize: vertical; + font-family: var(--font-mono, Menlo, Monaco, Consolas, "Courier New", monospace); + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + line-height: 1.45; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); +} +.webshell-db-sql:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12); +} +.webshell-db-actions { + display: flex; + gap: 8px; + align-items: center; +} +.webshell-db-actions .btn-primary, +.webshell-db-actions .btn-ghost { + min-width: 96px; + height: 34px; + border-radius: 8px; +} +.webshell-db-output-wrap { + flex: 1; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); + display: flex; + flex-direction: column; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.webshell-db-output-title { + padding: 9px 12px; + border-bottom: 1px solid var(--border-color); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--text-secondary); + background: linear-gradient(180deg, rgba(2, 6, 23, 0.015) 0%, transparent 100%); +} +.webshell-db-output { + flex: 1; + margin: 0; + padding: 12px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono, Menlo, Monaco, Consolas, "Courier New", monospace); + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-primary); +} +.webshell-db-output.error { + color: var(--error-color); +} +.webshell-db-hint { + border-top: 1px solid var(--border-color); + font-size: 0.76rem; + color: var(--text-secondary); + padding: 8px 12px; + background: rgba(2, 6, 23, 0.02); +} +@media (max-width: 1280px) { + .webshell-db-toolbar { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + } +} +@media (max-width: 980px) { + .webshell-db-toolbar { + grid-template-columns: repeat(2, minmax(140px, 1fr)); + } +} +@media (max-width: 700px) { + .webshell-db-toolbar { + grid-template-columns: 1fr; + } +} + /* 仪表盘页面样式(最佳实践布局 + 视觉增强) */ .dashboard-page { height: 100%; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 1d9fd0fc..b0ddd1cb 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -373,6 +373,23 @@ "tabTerminal": "Virtual terminal", "tabFileManager": "File manager", "tabAiAssistant": "AI Assistant", + "tabDbManager": "Database Manager", + "dbType": "Database type", + "dbHost": "Host", + "dbPort": "Port", + "dbUsername": "Username", + "dbPassword": "Password", + "dbName": "Database name", + "dbSqlitePath": "SQLite file path", + "dbSqlPlaceholder": "Enter SQL, e.g. SELECT version();", + "dbRunSql": "Run SQL", + "dbTest": "Test connection", + "dbOutput": "Output", + "dbNoConn": "Please select a WebShell connection first", + "dbSqlRequired": "Please enter SQL", + "dbRunning": "Database command is running, please wait", + "dbCliHint": "If command not found appears, install mysql/psql/sqlite3/sqlcmd on the target host first", + "dbExecFailed": "Database execution failed", "aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.", "aiNewConversation": "New conversation", "aiPreviousConversation": "Previous conversation", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 3395c1ce..289c77e4 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -373,6 +373,23 @@ "tabTerminal": "虚拟终端", "tabFileManager": "文件管理", "tabAiAssistant": "AI 助手", + "tabDbManager": "数据库管理", + "dbType": "数据库类型", + "dbHost": "主机", + "dbPort": "端口", + "dbUsername": "用户名", + "dbPassword": "密码", + "dbName": "数据库名", + "dbSqlitePath": "SQLite 文件路径", + "dbSqlPlaceholder": "输入 SQL,例如:SELECT version();", + "dbRunSql": "执行 SQL", + "dbTest": "测试连接", + "dbOutput": "执行输出", + "dbNoConn": "请先选择 WebShell 连接", + "dbSqlRequired": "请输入 SQL", + "dbRunning": "数据库命令执行中,请稍候", + "dbCliHint": "如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)", + "dbExecFailed": "数据库执行失败", "aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。", "aiNewConversation": "新对话", "aiPreviousConversation": "之前的对话", diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index c487c3b0..78185c0b 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -23,6 +23,7 @@ let webshellClearInProgress = false; // AI 助手:按连接 ID 保存对话 ID,便于多轮对话 let webshellAiConvMap = {}; let webshellAiSending = false; +let webshellDbConfigByConn = {}; // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 let webshellStreamingTypingId = 0; let webshellProbeStatusById = {}; @@ -89,6 +90,23 @@ function wsT(key) { 'webshell.tabTerminal': '虚拟终端', 'webshell.tabFileManager': '文件管理', 'webshell.tabAiAssistant': 'AI 助手', + 'webshell.tabDbManager': '数据库管理', + 'webshell.dbType': '数据库类型', + 'webshell.dbHost': '主机', + 'webshell.dbPort': '端口', + 'webshell.dbUsername': '用户名', + 'webshell.dbPassword': '密码', + 'webshell.dbName': '数据库名', + 'webshell.dbSqlitePath': 'SQLite 文件路径', + 'webshell.dbSqlPlaceholder': '输入 SQL,例如:SELECT version();', + 'webshell.dbRunSql': '执行 SQL', + 'webshell.dbTest': '测试连接', + 'webshell.dbOutput': '执行输出', + 'webshell.dbNoConn': '请先选择 WebShell 连接', + 'webshell.dbSqlRequired': '请输入 SQL', + 'webshell.dbRunning': '数据库命令执行中,请稍候', + 'webshell.dbCliHint': '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)', + 'webshell.dbExecFailed': '数据库执行失败', 'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。', 'webshell.aiPlaceholder': '例如:列出当前目录下的文件', 'webshell.aiSend': '发送', @@ -480,6 +498,187 @@ function escapeHtml(s) { return div.innerHTML; } +function escapeSingleQuotedShellArg(value) { + var s = value == null ? '' : String(value); + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function safeConnIdForStorage(conn) { + if (!conn || !conn.id) return ''; + return String(conn.id).replace(/[^\w.-]/g, '_'); +} + +function getWebshellDbConfig(conn) { + var key = 'webshell_db_cfg_' + safeConnIdForStorage(conn); + if (!key) return { + type: 'mysql', host: '127.0.0.1', port: '3306', username: 'root', password: '', database: '', sqlitePath: '/tmp/test.db', sql: 'SELECT 1;' + }; + if (webshellDbConfigByConn[key]) return webshellDbConfigByConn[key]; + var def = { + type: 'mysql', + host: '127.0.0.1', + port: '3306', + username: 'root', + password: '', + database: '', + sqlitePath: '/tmp/test.db', + sql: 'SELECT 1;' + }; + try { + var raw = localStorage.getItem(key); + if (raw) { + var parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + def = Object.assign(def, parsed); + } + } + } catch (e) {} + webshellDbConfigByConn[key] = def; + return def; +} + +function saveWebshellDbConfig(conn, cfg) { + var key = 'webshell_db_cfg_' + safeConnIdForStorage(conn); + if (!key || !cfg) return; + webshellDbConfigByConn[key] = cfg; + try { localStorage.setItem(key, JSON.stringify(cfg)); } catch (e) {} +} + +function webshellDbGetFieldValue(id) { + var el = document.getElementById(id); + return el && typeof el.value === 'string' ? el.value.trim() : ''; +} + +function webshellDbCollectConfig(conn) { + var cfg = { + type: webshellDbGetFieldValue('webshell-db-type') || 'mysql', + host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1', + port: webshellDbGetFieldValue('webshell-db-port') || '', + username: webshellDbGetFieldValue('webshell-db-user') || '', + password: (document.getElementById('webshell-db-pass') || {}).value || '', + database: webshellDbGetFieldValue('webshell-db-name') || '', + sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db', + sql: (document.getElementById('webshell-db-sql') || {}).value || '' + }; + saveWebshellDbConfig(conn, cfg); + return cfg; +} + +function webshellDbUpdateFieldVisibility() { + var type = webshellDbGetFieldValue('webshell-db-type') || 'mysql'; + var isSqlite = type === 'sqlite'; + var blocks = document.querySelectorAll('.webshell-db-common-field'); + blocks.forEach(function (el) { el.style.display = isSqlite ? 'none' : ''; }); + var sqliteBlock = document.getElementById('webshell-db-sqlite-row'); + if (sqliteBlock) sqliteBlock.style.display = isSqlite ? '' : 'none'; + var portEl = document.getElementById('webshell-db-port'); + if (portEl && !String(portEl.value || '').trim()) { + if (type === 'mysql') portEl.value = '3306'; + else if (type === 'pgsql') portEl.value = '5432'; + else if (type === 'mssql') portEl.value = '1433'; + } +} + +function webshellDbSetOutput(text, isError) { + var outputEl = document.getElementById('webshell-db-output'); + if (!outputEl) return; + outputEl.textContent = text || ''; + outputEl.classList.toggle('error', !!isError); +} + +function buildWebshellDbCommand(cfg, isTestOnly) { + var type = cfg.type || 'mysql'; + var sql = String(isTestOnly ? 'SELECT 1;' : (cfg.sql || '')).trim(); + if (!sql) return { error: wsT('webshell.dbSqlRequired') || '请输入 SQL' }; + + var sqlB64 = btoa(unescape(encodeURIComponent(sql))); + var sqlB64Arg = escapeSingleQuotedShellArg(sqlB64); + var tmpFile = '/tmp/.csai_sql_$$.sql'; + var decodeToFile = 'printf %s ' + sqlB64Arg + " | base64 -d > " + tmpFile; + var cleanup = '; rc=$?; rm -f ' + tmpFile + '; echo "__CSAI_DB_RC__:$rc"; exit $rc'; + var command = ''; + + if (type === 'mysql') { + var host = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1'); + var port = escapeSingleQuotedShellArg(cfg.port || '3306'); + var user = escapeSingleQuotedShellArg(cfg.username || 'root'); + var pass = escapeSingleQuotedShellArg(cfg.password || ''); + var db = cfg.database ? (' -D ' + escapeSingleQuotedShellArg(cfg.database)) : ''; + command = decodeToFile + '; MYSQL_PWD=' + pass + ' mysql -h ' + host + ' -P ' + port + ' -u ' + user + db + ' --batch --raw < ' + tmpFile + cleanup; + } else if (type === 'pgsql') { + var pHost = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1'); + var pPort = escapeSingleQuotedShellArg(cfg.port || '5432'); + var pUser = escapeSingleQuotedShellArg(cfg.username || 'postgres'); + var pPass = escapeSingleQuotedShellArg(cfg.password || ''); + var pDb = escapeSingleQuotedShellArg(cfg.database || 'postgres'); + command = decodeToFile + '; PGPASSWORD=' + pPass + ' psql -h ' + pHost + ' -p ' + pPort + ' -U ' + pUser + ' -d ' + pDb + ' -f ' + tmpFile + cleanup; + } else if (type === 'sqlite') { + var sqlitePath = escapeSingleQuotedShellArg(cfg.sqlitePath || '/tmp/test.db'); + command = decodeToFile + '; sqlite3 -header -column ' + sqlitePath + ' < ' + tmpFile + cleanup; + } else if (type === 'mssql') { + var sHost = cfg.host || '127.0.0.1'; + var sPort = cfg.port || '1433'; + var sUser = escapeSingleQuotedShellArg(cfg.username || 'sa'); + var sPass = escapeSingleQuotedShellArg(cfg.password || ''); + var sDb = escapeSingleQuotedShellArg(cfg.database || 'master'); + var server = escapeSingleQuotedShellArg(sHost + ',' + sPort); + command = decodeToFile + '; sqlcmd -S ' + server + ' -U ' + sUser + ' -P ' + sPass + ' -d ' + sDb + ' -i ' + tmpFile + cleanup; + } else { + return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; + } + + return { command: command }; +} + +function parseWebshellDbExecOutput(rawOutput) { + var raw = String(rawOutput || ''); + var rc = null; + var cleaned = raw.replace(/__CSAI_DB_RC__:(\d+)\s*$/m, function (_, code) { + rc = parseInt(code, 10); + return ''; + }).trim(); + return { rc: rc, output: cleaned }; +} + +function simplifyWebshellAiError(rawMessage) { + var msg = String(rawMessage || '').trim(); + var lower = msg.toLowerCase(); + if ((lower.indexOf('401') !== -1 || lower.indexOf('unauthorized') !== -1) && + (lower.indexOf('api key') !== -1 || lower.indexOf('apikey') !== -1)) { + return '鉴权失败:API Key 未配置或无效(401)'; + } + if (lower.indexOf('timeout') !== -1 || lower.indexOf('timed out') !== -1) { + return '请求超时,请稍后重试'; + } + if (lower.indexOf('network') !== -1 || lower.indexOf('failed to fetch') !== -1) { + return '网络异常,请检查服务连通性'; + } + return msg || '请求失败'; +} + +function renderWebshellAiErrorMessage(targetEl, rawMessage) { + if (!targetEl) return; + var full = String(rawMessage || '').trim(); + var shortMsg = simplifyWebshellAiError(full); + targetEl.classList.add('webshell-ai-msg-error'); + targetEl.innerHTML = ''; + var head = document.createElement('div'); + head.className = 'webshell-ai-error-head'; + head.textContent = shortMsg; + targetEl.appendChild(head); + if (full && full !== shortMsg) { + var detail = document.createElement('details'); + detail.className = 'webshell-ai-error-detail'; + var summary = document.createElement('summary'); + summary.textContent = '查看详细错误'; + var pre = document.createElement('pre'); + pre.textContent = full; + detail.appendChild(summary); + detail.appendChild(pre); + targetEl.appendChild(detail); + } +} + function formatWebshellAiConvDate(updatedAt) { if (!updatedAt) return ''; var d = typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt; @@ -698,6 +897,7 @@ function selectWebshell(id) { '' + '' + '' + + '' + '' + '