From 4a5b177947029830839d17f0463d321f1b08c981 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Tue, 6 Oct 2026 01:48:12 +0800 Subject: [PATCH] fix: preserve task terminal state and file navigation paths --- docs/bug-sheet-review.md | 10 +++ internal/c2/listener_websocket.go | 32 +++++++--- internal/c2/manager.go | 18 +++--- internal/c2/task_terminal_test.go | 61 +++++++++++++++++++ internal/database/c2.go | 5 ++ internal/handler/c2.go | 8 +++ .../handler/c2_profile_validation_test.go | 60 ++++++++++++++++++ web/static/i18n/en-US.json | 4 +- web/static/i18n/ru-RU.json | 4 +- web/static/i18n/zh-CN.json | 4 +- web/static/js/c2-profile-save.test.cjs | 43 +++++++++++++ web/static/js/c2.js | 16 +++-- web/static/js/notification-view-read.test.cjs | 37 +++++++++++ web/static/js/notifications.js | 5 +- web/static/js/webshell-file-path.test.cjs | 53 ++++++++++++++++ web/static/js/webshell.js | 36 ++++++----- web/static/js/workflows.js | 7 +++ 17 files changed, 360 insertions(+), 43 deletions(-) create mode 100644 internal/c2/task_terminal_test.go create mode 100644 internal/handler/c2_profile_validation_test.go create mode 100644 web/static/js/c2-profile-save.test.cjs create mode 100644 web/static/js/notification-view-read.test.cjs create mode 100644 web/static/js/webshell-file-path.test.cjs diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index aa86cd10..8275be55 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -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 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。 diff --git a/internal/c2/listener_websocket.go b/internal/c2/listener_websocket.go index da7f85db..b092c8cd 100644 --- a/internal/c2/listener_websocket.go +++ b/internal/c2/listener_websocket.go @@ -25,8 +25,9 @@ import ( // - 一个 listener 仅处理一个 WS 路径(默认 /ws),但可承载多个并发 implant。 // // 帧协议(皆为加密后 base64 字符串走 TextMessage): -// client → server:{"type":"checkin"|"result", "data": } -// server → client:{"type":"task", "data": } 或 {"type":"sleep","data":{"sleep":N,"jitter":J}} +// +// client → server:{"type":"checkin"|"result", "data": } +// server → client:{"type":"task", "data": } 或 {"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) diff --git a/internal/c2/manager.go b/internal/c2/manager.go index 6b0c2adf..5c41bb2b 100644 --- a/internal/c2/manager.go +++ b/internal/c2/manager.go @@ -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(如截图)落盘 diff --git a/internal/c2/task_terminal_test.go b/internal/c2/task_terminal_test.go new file mode 100644 index 00000000..7497be18 --- /dev/null +++ b/internal/c2/task_terminal_test.go @@ -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) + } +} diff --git a/internal/database/c2.go b/internal/database/c2.go index fee5184f..9226bc34 100644 --- a/internal/database/c2.go +++ b/internal/database/c2.go @@ -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 diff --git a/internal/handler/c2.go b/internal/handler/c2.go index dd1a0011..dd5156ec 100644 --- a/internal/handler/c2.go +++ b/internal/handler/c2.go @@ -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 diff --git a/internal/handler/c2_profile_validation_test.go b/internal/handler/c2_profile_validation_test.go new file mode 100644 index 00000000..4a772b4a --- /dev/null +++ b/internal/handler/c2_profile_validation_test.go @@ -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()) + } + } + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 84bb0530..cb92daff 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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?", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index a81ea311..92c6c862 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -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": "Удалить этот профиль?", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c6bc729c..3ccad46a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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?", diff --git a/web/static/js/c2-profile-save.test.cjs b/web/static/js/c2-profile-save.test.cjs new file mode 100644 index 00000000..29111783 --- /dev/null +++ b/web/static/js/c2-profile-save.test.cjs @@ -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'); + } +}); diff --git a/web/static/js/c2.js b/web/static/js/c2.js index 21113669..0b2f9488 100644 --- a/web/static/js/c2.js +++ b/web/static/js/c2.js @@ -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'); diff --git a/web/static/js/notification-view-read.test.cjs b/web/static/js/notification-view-read.test.cjs new file mode 100644 index 00000000..e1024852 --- /dev/null +++ b/web/static/js/notification-view-read.test.cjs @@ -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}); +}); diff --git a/web/static/js/notifications.js b/web/static/js/notifications.js index 9fc18c66..3001641f 100644 --- a/web/static/js/notifications.js +++ b/web/static/js/notifications.js @@ -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'); diff --git a/web/static/js/webshell-file-path.test.cjs b/web/static/js/webshell-file-path.test.cjs new file mode 100644 index 00000000..b0578307 --- /dev/null +++ b/web/static/js/webshell-file-path.test.cjs @@ -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"/); +}); diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 1e7c8d1d..54118f36 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -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 = '' + (wsT('webshell.breadcrumbHome') || '根') + '' + + var absolute = currentPath.startsWith('/'); + var parts = (currentPath === '.' || currentPath === '/' || currentPath === '') ? [] : currentPath.replace(/^\//, '').split('/'); + breadcrumbEl.innerHTML = '' + (wsT('webshell.breadcrumbHome') || '根') + '' + parts.map(function (p, idx) { - var path = parts.slice(0, idx + 1).join('/'); + var path = (absolute ? '/' : '') + parts.slice(0, idx + 1).join('/'); return ' / ' + escapeHtml(p) + ''; }).join(''); } @@ -4434,7 +4437,7 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { } else { html = ''; if (currentPath !== '.' && currentPath !== '') { - html += ''; + html += ''; } 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 = '
' + '
' + @@ -4630,7 +4634,7 @@ function renderDirectoryTree(currentPath, items, conn) { return nodeHtml; } - treeEl.innerHTML = '
' + renderNode({ path: '.', name: '/', isDir: true }, 0) + '
'; + treeEl.innerHTML = '
' + renderNode({ path: rootPath, name: '/', isDir: true }, 0) + '
'; treeEl.querySelectorAll('.webshell-tree-toggle').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 6cd3ae3e..36f7ef18 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -2481,6 +2481,13 @@ select.innerHTML = '' + workflows.map(wf => ( `` )).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();
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colPerms') || '权限') + '
..
..