mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
fix: preserve task terminal state and file navigation paths
This commit is contained in:
1 parent
079ba0d2b3
commit
4a5b177947
17 files changed
+360
-43
No files matched your search
@@ -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 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。
|
||||
@@ -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
@@ -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(如截图)落盘
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "Удалить этот профиль?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user