fix: preserve task terminal state and file navigation paths

This commit is contained in:
Ed1s0nZ committed 2026-10-06 01:48:12 +08:00
1 parent 079ba0d2b3
commit 4a5b177947
17 files changed
+360 -43

No files matched your search

+10
View File
@@ -19,6 +19,11 @@
| 30、31 角色名路径字符/长度 | 名称未校验,文件名会转换,存在碰撞 | 拒绝路径和控制字符、首尾空格、超过 64 字符及文件名碰撞 |
| 工作流悬空引用 | 角色保存与工作流删除均未检查引用 | 保存时验证存在;删除引用中的工作流返回冲突 |
| 4 登录用户名为空 | 前端无 required,后端空用户名默认 admin | 登录接口及页面拒绝空白/超过 64 字符用户名 |
| Profile UA/响应头/Jitter 保存丢失 | 前端 snake_case 与后端 camelCase 不一致 | 对齐 JSON 字段;保留 0;拒绝负值/倒置范围 |
| 22 取消后显示成功 | 回传结果无条件更新任务终态 | 终态保持不变;取消和结果写入使用状态条件检查 |
| 同 UUID 旧连接断开误判离线 | WS defer 无条件删当前连接及标记 dead | 仅当前登记连接可以删除并退役会话,结束心跳 goroutine |
| 23 WebShell 目录路径及假条目 | normalizeWebshellPath 删除根斜杠;权限兜底正则接受 ls 错误行 | 保留 Unix 绝对路径;树/面包屑/父级保持绝对路径;只接受权限格式目录项 |
| 6 查看通知未清计数 | 查看回调只跳转 | 查看普通事件后标记已读并刷新;待审批通知仍保留待处理状态 |
## 不适用
@@ -30,3 +35,8 @@
- 新增 C2 跨用户禁止等待/取消、所有者正常操作、机器人纯文本交付、角色名称边界、工作流引用、登录用户名测试。
- 前端已有测试存在 HITL 断言失败,正在核实基线;未修改 HITL 脚本。
- 当前尚未完成全部表内条目核对,也尚未合入 main。
- `go test ./internal/c2 ./internal/database` 通过;新终态保护及连接替换测试通过。
- Profile API 持久化/无效 Jitter 测试和前端实际提交字段测试通过。
- 前端 HITL 断言问题在原始提交 82b0af1 的隔离导出目录也可复现,属于基线问题。
- `go test ./...` 全仓通过。
- WebShell 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。
+22 -10
View File
@@ -25,8 +25,9 @@ import (
// - 一个 listener 仅处理一个 WS 路径(默认 /ws),但可承载多个并发 implant。
//
// 帧协议(皆为加密后 base64 字符串走 TextMessage):
// client → server:{"type":"checkin"|"result", "data": <ImplantCheckInRequest|TaskResultReport>}
// server → client:{"type":"task", "data": <TaskEnvelope>} 或 {"type":"sleep","data":{"sleep":N,"jitter":J}}
//
// client → server:{"type":"checkin"|"result", "data": <ImplantCheckInRequest|TaskResultReport>}
// server → client:{"type":"task", "data": <TaskEnvelope>} 或 {"type":"sleep","data":{"sleep":N,"jitter":J}}
type WebSocketListener struct {
rec *database.C2Listener
cfg *ListenerConfig
@@ -36,10 +37,10 @@ type WebSocketListener struct {
srv *http.Server
upgrader websocket.Upgrader
mu sync.Mutex
conns map[string]*wsConn // session_id → 连接
stopped bool
stopCh chan struct{}
mu sync.Mutex
conns map[string]*wsConn // session_id → 连接
stopped bool
stopCh chan struct{}
}
// wsConn 单个 WS implant 的内存状态
@@ -179,21 +180,22 @@ func (l *WebSocketListener) handleConn(ws *websocket.Conn) {
l.conns[session.ID] = conn
l.mu.Unlock()
defer func() {
l.mu.Lock()
delete(l.conns, session.ID)
l.mu.Unlock()
l.detachConnection(conn)
_ = ws.Close()
_ = l.manager.MarkSessionDead(session.ID)
}()
// 心跳 goroutine
pingTicker := time.NewTicker(20 * time.Second)
defer pingTicker.Stop()
connDone := make(chan struct{})
defer close(connDone)
go func() {
for {
select {
case <-l.stopCh:
return
case <-connDone:
return
case <-pingTicker.C:
conn.writeMu.Lock()
_ = ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
@@ -224,6 +226,16 @@ func (l *WebSocketListener) handleConn(ws *websocket.Conn) {
}
}
// detachConnection only retires the connection that still owns this session.
func (l *WebSocketListener) detachConnection(conn *wsConn) {
l.mu.Lock()
defer l.mu.Unlock()
if l.conns[conn.sessionID] == conn {
delete(l.conns, conn.sessionID)
_ = l.manager.MarkSessionDead(conn.sessionID)
}
}
// taskDispatcherLoop 周期扫描所有活动 WS 会话,下发任务
func (l *WebSocketListener) taskDispatcherLoop() {
t := time.NewTicker(500 * time.Millisecond)
+11 -7
View File
@@ -637,7 +637,7 @@ func (m *Manager) CancelTask(taskID string) error {
}
cancelled := string(TaskCancelled)
now := time.Now()
if err := m.db.UpdateC2Task(taskID, database.C2TaskUpdate{Status: &cancelled, CompletedAt: &now}); err != nil {
if err := m.db.UpdateC2Task(taskID, database.C2TaskUpdate{ExpectedStatus: &t.Status, Status: &cancelled, CompletedAt: &now}); err != nil {
return err
}
m.publishEvent("info", "task", t.SessionID, taskID, "任务已取消", nil)
@@ -670,6 +670,9 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
if t == nil {
return ErrTaskNotFound
}
if t.Status == string(TaskCancelled) || t.Status == string(TaskSuccess) || t.Status == string(TaskFailed) {
return nil
}
startedAt := time.Unix(0, report.StartedAt*int64(time.Millisecond))
endedAt := time.Unix(0, report.EndedAt*int64(time.Millisecond))
@@ -694,12 +697,13 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
errText := ResolveTaskResultText(report.Error, report.ErrorB64, sessionOS)
upd := database.C2TaskUpdate{
Status: &status,
ResultText: &resultText,
Error: &errText,
StartedAt: &startedAt,
CompletedAt: &endedAt,
DurationMS: &duration,
ExpectedStatus: &t.Status,
Status: &status,
ResultText: &resultText,
Error: &errText,
StartedAt: &startedAt,
CompletedAt: &endedAt,
DurationMS: &duration,
}
// blob(如截图)落盘
+61
View File
@@ -0,0 +1,61 @@
package c2
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func terminalTestManager(t *testing.T) (*Manager, *database.DB) {
t.Helper()
db, err := database.NewDB(filepath.Join(t.TempDir(), "terminal.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
now := time.Now()
if err := db.CreateC2Listener(&database.C2Listener{ID: "listener", Name: "test", Type: "websocket", BindHost: "127.0.0.1", BindPort: 12345, CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&database.C2Session{ID: "session", ListenerID: "listener", ImplantUUID: "test-uuid", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
t.Fatal(err)
}
return NewManager(db, zap.NewNop(), t.TempDir()), db
}
func TestLateC2ResultDoesNotOverwriteTerminalState(t *testing.T) {
m, db := terminalTestManager(t)
for _, status := range []string{"cancelled", "success", "failed"} {
task := &database.C2Task{ID: "t_" + status, SessionID: "session", TaskType: "exec", Status: status, ResultText: "original", CreatedAt: time.Now()}
if err := db.CreateC2Task(task); err != nil {
t.Fatal(err)
}
if err := m.IngestTaskResult(TaskResultReport{TaskID: task.ID, Success: true, Output: "late"}); err != nil {
t.Fatal(err)
}
saved, err := db.GetC2Task(task.ID)
if err != nil || saved.Status != status || saved.ResultText != "original" {
t.Fatalf("overwrote terminal task: %#v %v", saved, err)
}
}
}
func TestWebSocketOldConnectionCannotRetireReplacement(t *testing.T) {
m, db := terminalTestManager(t)
old := &wsConn{sessionID: "session"}
current := &wsConn{sessionID: "session"}
l := &WebSocketListener{manager: m, conns: map[string]*wsConn{"session": current}}
l.detachConnection(old)
session, err := db.GetC2Session("session")
if err != nil || session.Status != "active" || l.conns["session"] != current {
t.Fatalf("old connection retired replacement: %#v %v", session, err)
}
l.detachConnection(current)
session, err = db.GetC2Session("session")
if err != nil || session.Status != "dead" || len(l.conns) != 0 {
t.Fatalf("current connection failed to retire: %#v %v", session, err)
}
}
+5
View File
@@ -865,6 +865,7 @@ func (db *DB) CreateC2Task(t *C2Task) error {
// SetC2TaskStatus 更新任务的状态/结果/错误/时间戳
type C2TaskUpdate struct {
ExpectedStatus *string // Optional compare-and-swap guard for terminal transitions.
Status *string
ResultText *string
ResultBlobPath *string
@@ -921,6 +922,10 @@ func (db *DB) UpdateC2Task(id string, u C2TaskUpdate) error {
}
query := "UPDATE c2_tasks SET " + strings.Join(sets, ", ") + " WHERE id = ?"
args = append(args, id)
if u.ExpectedStatus != nil {
query += " AND status = ?"
args = append(args, *u.ExpectedStatus)
}
res, err := db.Exec(query, args...)
if err != nil {
return err
+8
View File
@@ -1021,6 +1021,10 @@ func (h *C2Handler) CreateProfile(c *gin.Context) {
return
}
if req.JitterMinMS < 0 || req.JitterMaxMS < req.JitterMinMS {
c.JSON(http.StatusBadRequest, gin.H{"error": "Jitter 必须满足 0 <= min <= max"})
return
}
req.ID = "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
req.CreatedAt = time.Now()
@@ -1050,6 +1054,10 @@ func (h *C2Handler) UpdateProfile(c *gin.Context) {
return
}
if req.JitterMinMS < 0 || req.JitterMaxMS < req.JitterMinMS {
c.JSON(http.StatusBadRequest, gin.H{"error": "Jitter 必须满足 0 <= min <= max"})
return
}
profile.Name = req.Name
profile.UserAgent = req.UserAgent
profile.URIs = req.URIs
@@ -0,0 +1,60 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestC2ProfilePreservesFieldsAndRejectsInvalidJitter(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "profile.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
h := NewC2Handler(c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop())
r := gin.New()
r.POST("/profiles", h.CreateProfile)
r.PUT("/profiles/:id", h.UpdateProfile)
request := func(method, path, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w
}
w := request(http.MethodPost, "/profiles", `{"name":"test","userAgent":"test-only-agent","uris":["/test"],"jitterMinMs":0,"jitterMaxMs":100,"responseHeaders":{"X-Test":"test"}}`)
if w.Code != http.StatusOK {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
var data struct {
Profile database.C2Profile `json:"profile"`
}
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatal(err)
}
saved, err := db.GetC2Profile(data.Profile.ID)
if err != nil || saved == nil || saved.UserAgent != "test-only-agent" || saved.ResponseHeaders["X-Test"] != "test" || saved.JitterMinMS != 0 || saved.JitterMaxMS != 100 {
t.Fatalf("fields lost: %#v %v", saved, err)
}
for _, body := range []string{`{"name":"bad","jitterMinMs":500,"jitterMaxMs":100}`, `{"name":"bad","jitterMinMs":-1,"jitterMaxMs":100}`} {
for _, method := range []string{http.MethodPost, http.MethodPut} {
path := "/profiles"
if method == http.MethodPut {
path += "/" + saved.ID
}
w := request(method, path, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s invalid jitter: %d %s", method, w.Code, w.Body.String())
}
}
}
}
+3 -1
View File
@@ -3766,7 +3766,8 @@
"workflowPolicyAuto": "Auto trigger",
"workflowPolicyOff": "Off",
"noWorkflowBind": "No workflow",
"workflowDisabledSuffix": " (disabled)"
"workflowDisabledSuffix": " (disabled)",
"workflowMissing": "missing or unavailable"
},
"workflows": {
"title": "Workflows",
@@ -4485,6 +4486,7 @@
"hintHeaders": "HTTP response headers to mimic a legitimate server",
"toastNameRequired": "Profile name is required",
"toastInvalidHeadersJson": "Invalid JSON in response headers",
"toastInvalidJitter": "Jitter must use integers with 0 <= min <= max",
"toastCreated": "Profile created",
"toastDeleted": "Profile deleted",
"confirmDelete": "Delete this profile?",
+3 -1
View File
@@ -3778,7 +3778,8 @@
"workflowPolicyAuto": "Автозапуск",
"workflowPolicyOff": "Выкл.",
"noWorkflowBind": "Без сценария",
"workflowDisabledSuffix": " (отключён)"
"workflowDisabledSuffix": " (отключён)",
"workflowMissing": "отсутствует или недоступен"
},
"workflows": {
"title": "Сценарии",
@@ -4497,6 +4498,7 @@
"hintHeaders": "Заголовки ответа HTTP, имитирующие обычный сервер",
"toastNameRequired": "Укажите имя профиля",
"toastInvalidHeadersJson": "Некорректный JSON в заголовках ответа",
"toastInvalidJitter": "Jitter должен быть целым: 0 <= минимум <= максимум",
"toastCreated": "Профиль создан",
"toastDeleted": "Профиль удалён",
"confirmDelete": "Удалить этот профиль?",
+3 -1
View File
@@ -3754,7 +3754,8 @@
"workflowPolicyAuto": "自动触发",
"workflowPolicyOff": "关闭",
"noWorkflowBind": "不绑定工作流",
"workflowDisabledSuffix": "(已禁用)"
"workflowDisabledSuffix": "(已禁用)",
"workflowMissing": "不存在或不可用"
},
"workflows": {
"title": "工作流",
@@ -4473,6 +4474,7 @@
"hintHeaders": "用于伪装为合法服务的 HTTP 响应头",
"toastNameRequired": "请填写 Profile 名称",
"toastInvalidHeadersJson": "响应头 JSON 无效",
"toastInvalidJitter": "Jitter 必须为整数,且满足 0 <= 最小值 <= 最大值",
"toastCreated": "Profile 已创建",
"toastDeleted": "Profile 已删除",
"confirmDelete": "确定删除此 Profile?",
+43
View File
@@ -0,0 +1,43 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/c2.js', 'utf8');
const start = source.indexOf('C2.createProfile = function()');
const end = source.indexOf('C2.deleteProfile = function', start);
function harness(min, max) {
let sent;
let error;
const values = {
'c2-profile-name': 'test', 'c2-profile-ua': 'test-only-agent',
'c2-profile-uris': '/test', 'c2-profile-jmin': min,
'c2-profile-jmax': max, 'c2-profile-headers': '{"X-Test":"value"}',
};
const ctx = {
C2: { closeModal() {}, loadProfiles() {} }, API_BASE: '/api/c2',
c2t: x => x, showToast: (text, type) => { if (type === 'error') error = text; },
document: { getElementById: id => ({value: values[id]}) },
apiRequest: (method, url, data) => { sent = data; return Promise.resolve({}); },
};
vm.runInNewContext(source.slice(start, end), ctx);
ctx.C2.createProfile();
return {sent, error};
}
test('profile save uses API field names and preserves zero jitter', () => {
const {sent, error} = harness('0', '100');
assert.equal(error, undefined);
assert.equal(sent.userAgent, 'test-only-agent');
assert.equal(sent.jitterMinMs, 0);
assert.equal(sent.jitterMaxMs, 100);
assert.equal(sent.responseHeaders['X-Test'], 'value');
});
test('invalid jitter is rejected before sending profile', () => {
for (const [min, max] of [['500', '100'], ['-1', '100'], ['1.5', '100'], ['x', '100']]) {
const {sent, error} = harness(min, max);
assert.equal(sent, undefined);
assert.equal(error, 'c2.profiles.toastInvalidJitter');
}
});
+10 -6
View File
@@ -4514,8 +4514,12 @@
const userAgent = document.getElementById('c2-profile-ua')?.value.trim() || '';
const urisRaw = document.getElementById('c2-profile-uris')?.value.trim() || '';
const uris = urisRaw.split('\n').map(u => u.trim()).filter(u => u);
const jitterMinMs = parseInt(document.getElementById('c2-profile-jmin')?.value) || 100;
const jitterMaxMs = parseInt(document.getElementById('c2-profile-jmax')?.value) || 500;
const jitterMinMs = Number(document.getElementById('c2-profile-jmin')?.value);
const jitterMaxMs = Number(document.getElementById('c2-profile-jmax')?.value);
if (!Number.isSafeInteger(jitterMinMs) || !Number.isSafeInteger(jitterMaxMs) || jitterMinMs < 0 || jitterMaxMs < jitterMinMs) {
showToast(c2t('c2.profiles.toastInvalidJitter'), 'error');
return;
}
let responseHeaders = {};
const headersRaw = document.getElementById('c2-profile-headers')?.value.trim();
@@ -4526,11 +4530,11 @@
apiRequest('POST', `${API_BASE}/profiles`, {
name,
user_agent: userAgent,
userAgent,
uris,
jitter_min_ms: jitterMinMs,
jitter_max_ms: jitterMaxMs,
response_headers: responseHeaders
jitterMinMs,
jitterMaxMs,
responseHeaders
}).then(data => {
if (data.error) {
showToast(data.error, 'error');
@@ -0,0 +1,37 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/notifications.js', 'utf8');
const start = source.indexOf('function renderNotificationList(');
const end = source.indexOf('function closeDropdown(', start);
async function clickView(actionable) {
let click;
let marked = 0;
let refreshed = 0;
let opened = 0;
const item = {id: 'event-test', actionable};
const list = {innerHTML: '', querySelectorAll: selector => selector.includes('view-btn') ? [{
getAttribute: () => item.id,
addEventListener: (_, callback) => { click = callback; },
}] : []};
const ctx = {
document: {getElementById: () => list}, state: {items: [item]},
MAX_RENDER_ITEMS: 20, htmlEscape: String, t: (_, fallback) => fallback,
hasAction: () => true, formatTime: () => '', closeDropdown() {},
openNotificationTarget: () => { opened++; },
markItemsRead: async ids => { assert.equal(ids[0], item.id); marked++; return true; },
refreshNotifications: async () => { refreshed++; },
};
vm.runInNewContext(source.slice(start, end), ctx);
ctx.renderNotificationList([item]);
await click({preventDefault() {}, stopPropagation() {}});
return {marked, refreshed, opened};
}
test('viewing an informational event clears unread count through the server', async () => {
assert.deepEqual(await clickView(false), {marked: 1, refreshed: 1, opened: 1});
});
test('viewing pending actionable notifications leaves approval unread state intact', async () => {
assert.deepEqual(await clickView(true), {marked: 0, refreshed: 0, opened: 1});
});
+4 -1
View File
@@ -224,7 +224,7 @@
list.innerHTML = html;
const viewButtons = list.querySelectorAll('.notification-item-view-btn');
viewButtons.forEach(btn => {
btn.addEventListener('click', function (event) {
btn.addEventListener('click', async function (event) {
event.preventDefault();
event.stopPropagation();
const eventID = btn.getAttribute('data-action-id') || '';
@@ -233,6 +233,9 @@
if (!item) return;
openNotificationTarget(item);
closeDropdown();
if (item.actionable !== true && await markItemsRead([eventID])) {
await refreshNotifications();
}
});
});
const readButtons = list.querySelectorAll('.notification-item-read-btn');
+53
View File
@@ -0,0 +1,53 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/webshell.js', 'utf8');
function declaration(name) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf('\nfunction ', start + 1);
assert.ok(start >= 0 && end > start);
return source.slice(start, end);
}
function harness() {
const tree = { tree: {}, expanded: {}, loaded: {} };
const treeEl = {innerHTML: '', querySelectorAll: () => []};
const ctx = {
Date, getWebshellTreeState: () => tree,
getWebshellSelectedFile: () => '', webshellCurrentConn: {id: 'test'},
document: { getElementById: id => id === 'webshell-dir-tree' ? treeEl : null },
escapeHtml: value => String(value),
};
vm.createContext(ctx);
for (const name of ['normalizeWebshellPath', 'getWebshellParentPath', 'normalizeLsMtime', 'modeToType', 'parseWindowsDirEntry', 'parseWebshellListItems', 'renderDirectoryTree']) {
vm.runInContext(declaration(name), ctx);
}
return {ctx, tree, treeEl};
}
test('absolute Unix paths and roots survive normalization and parent navigation', () => {
const {ctx} = harness();
for (const [input, expected] of [['/tmp/', '/tmp'], ['/', '/'], ['/tmp/demo', '/tmp/demo'], ['tmp', 'tmp'], ['C:\\Temp', 'C:/Temp'], ['C:\\', 'C:/']]) {
assert.equal(ctx.normalizeWebshellPath(input), expected);
}
assert.equal(ctx.getWebshellParentPath('/tmp'), '/');
assert.equal(ctx.getWebshellParentPath('/'), '/');
assert.equal(ctx.getWebshellParentPath('C:/Temp'), 'C:/');
});
test('ls failures never become clickable fake files', () => {
const {ctx} = harness();
for (const text of ["ls: 无法访问'tmp': 没有那个文件或目录", "ls: cannot access 'tmp': No such file or directory"]) {
assert.equal(ctx.parseWebshellListItems(text).length, 0);
}
const items = ctx.parseWebshellListItems('drwxr-xr-x 2 root root 4096 Sep 26 12:00 demo\n-rw-r--r-- 1 root root 2 Sep 26 12:00 file');
assert.equal(items.length, 2);
assert.equal(items[0].name, 'demo');
assert.equal(items[0].isDir, true);
});
test('directory tree preserves absolute ancestry and child paths', () => {
const {ctx, tree, treeEl} = harness();
ctx.renderDirectoryTree('/tmp/demo', [{name: 'file', isDir: false}], {id:'test'});
assert.equal(tree.tree['/'][0].path, '/tmp');
assert.equal(tree.tree['/tmp'][0].path, '/tmp/demo');
assert.equal(tree.tree['/tmp/demo'][0].path, '/tmp/demo/file');
assert.match(treeEl.innerHTML, /data-path="\/tmp\/demo\/file"/);
});
+20 -16
View File
@@ -1399,8 +1399,9 @@ function safeConnIdForStorage(conn) {
function normalizeWebshellPath(path) {
var p = path == null ? '.' : String(path).trim();
if (!p || p === '/') return '.';
p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
if (!p) return '.';
p = p.replace(/\\/g, '/').replace(/\/+/g, '/');
if (p === '/') return '/';
// Windows 盘符根目录保持为 "C:/",避免被裁成 "C:" 后父级计算异常
if (/^[A-Za-z]:\/?$/.test(p)) {
return p.slice(0, 2) + '/';
@@ -1428,6 +1429,7 @@ function setWebshellSelectedFile(conn, path) {
function getWebshellParentPath(path) {
var p = normalizeWebshellPath(path);
if (p === '/') return '/';
// Windows 盘符根目录不可再上探
if (/^[A-Za-z]:\/$/.test(p)) return p;
// 允许从当前目录持续上探:. -> .. -> ../.. -> ../../..
@@ -1436,7 +1438,7 @@ function getWebshellParentPath(path) {
// 已经是相对上探时,先维持链路;后续 list 成功后会用远端真实路径回填
var idx = p.lastIndexOf('/');
if (idx < 0) return '.';
var parent = p.slice(0, idx) || '.';
var parent = p.slice(0, idx) || (p.startsWith('/') ? '/' : '.');
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
return parent;
}
@@ -4363,7 +4365,7 @@ function parseWebshellListItems(rawOutput) {
continue;
}
// 仅兜底解析 Unix 权限格式,避免把 `dir` 统计行误识别为文件。
if (/^[-dlcbsp]/.test(line)) {
if (/^[bcdlps-][rwxStTs-]{9}[+.@]?\s/.test(line)) {
var parts = line.trim().split(/\s+/);
if (parts.length >= 9) {
name = parts.slice(8).join(' ').trim();
@@ -4413,10 +4415,11 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
// 面包屑
var breadcrumbEl = document.getElementById('webshell-file-breadcrumb');
if (breadcrumbEl) {
var parts = (currentPath === '.' || currentPath === '') ? [] : currentPath.replace(/^\//, '').split('/');
breadcrumbEl.innerHTML = '<a href="#" class="webshell-breadcrumb-item" data-path=".">' + (wsT('webshell.breadcrumbHome') || '根') + '</a>' +
var absolute = currentPath.startsWith('/');
var parts = (currentPath === '.' || currentPath === '/' || currentPath === '') ? [] : currentPath.replace(/^\//, '').split('/');
breadcrumbEl.innerHTML = '<a href="#" class="webshell-breadcrumb-item" data-path="' + (absolute ? '/' : '.') + '">' + (wsT('webshell.breadcrumbHome') || '根') + '</a>' +
parts.map(function (p, idx) {
var path = parts.slice(0, idx + 1).join('/');
var path = (absolute ? '/' : '') + parts.slice(0, idx + 1).join('/');
return ' / <a href="#" class="webshell-breadcrumb-item" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
}).join('');
}
@@ -4434,7 +4437,7 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
} else {
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
if (currentPath !== '.' && currentPath !== '') {
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td></tr>';
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(getWebshellParentPath(currentPath)) + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td></tr>';
}
items.forEach(function (item) {
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
@@ -4554,8 +4557,9 @@ function renderDirectoryTree(currentPath, items, conn) {
var expanded = state.expanded;
var loaded = state.loaded;
var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn);
if (!tree['.']) tree['.'] = [];
if (expanded['.'] !== false) expanded['.'] = true;
var rootPath = curr.startsWith('/') ? '/' : '.';
if (!tree[rootPath]) tree[rootPath] = [];
if (expanded[rootPath] !== false) expanded[rootPath] = true;
// 把当前目录的子项(目录+文件)同步到树缓存
var childNodes = (items || []).map(function (item) {
@@ -4579,11 +4583,11 @@ function renderDirectoryTree(currentPath, items, conn) {
// 仅对“真实路径”补祖先链;相对上探链(../..)不构建,避免出现假层级。
var isRelativeUpChain = /^(?:\.\.\/)*\.\.$/.test(curr);
var parts = curr === '.' ? [] : curr.split('/');
var parentPath = '.';
var parts = (curr === '.' || curr === '/') ? [] : curr.replace(/^\//, '').split('/');
var parentPath = rootPath;
if (!isRelativeUpChain) {
for (var i = 0; i < parts.length; i++) {
var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i];
var nextPath = parentPath === '.' ? parts[i] : normalizeWebshellPath(parentPath + '/' + parts[i]);
if (!tree[parentPath]) tree[parentPath] = [];
var parentChildren = tree[parentPath];
var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; });
@@ -4606,13 +4610,13 @@ function renderDirectoryTree(currentPath, items, conn) {
var isDir = !!node.isDir;
var children = isDir ? (tree[path] || []).slice() : [];
var hasLoadedChildren = isDir ? (loaded[path] === true) : true;
var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0);
var canExpand = isDir && (path === rootPath || !hasLoadedChildren || children.length > 0);
var hasChildren = children.length > 0;
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 icon = isDir ? (path === rootPath ? '🗂' : '📁') : '📄';
var nodeHtml =
'<div class="webshell-tree-node" data-depth="' + depth + '">' +
'<div class="webshell-tree-row' + (isActive ? ' active' : '') + (isSelectedFile ? ' selected-file' : '') + '">' +
@@ -4630,7 +4634,7 @@ function renderDirectoryTree(currentPath, items, conn) {
return nodeHtml;
}
treeEl.innerHTML = '<div class="webshell-tree-root">' + renderNode({ path: '.', name: '/', isDir: true }, 0) + '</div>';
treeEl.innerHTML = '<div class="webshell-tree-root">' + renderNode({ path: rootPath, name: '/', isDir: true }, 0) + '</div>';
treeEl.querySelectorAll('.webshell-tree-toggle').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
+7
View File
@@ -2481,6 +2481,13 @@
select.innerHTML = '<option value="">' + esc(_t('roleModal.noWorkflowBind')) + '</option>' + workflows.map(wf => (
`<option value="${esc(wf.id)}">${esc(wf.name || wf.id)}${wf.enabled ? '' : esc(_t('roleModal.workflowDisabledSuffix'))}</option>`
)).join('');
if (current && !workflows.some(wf => wf.id === current)) {
// Keep an invalid binding visible instead of silently clearing it.
const option = document.createElement('option');
option.value = current;
option.textContent = `${current} (${_t('roleModal.workflowMissing')})`;
select.appendChild(option);
}
select.value = current || '';
if (typeof window.refreshRoleModalSelects === 'function') {
window.refreshRoleModalSelects();