file browser improvements and hardening.
This commit is contained in:
@@ -33,15 +33,16 @@ import (
|
||||
var ErrReconnect = errors.New("reconnect requested")
|
||||
|
||||
var (
|
||||
activeCommands = make(map[string]context.CancelFunc)
|
||||
activeCommandsMu sync.Mutex
|
||||
voiceSessionMu sync.Mutex
|
||||
voiceSession *voiceRuntime
|
||||
desktopAudioMu sync.Mutex
|
||||
desktopAudioSession *voiceRuntime
|
||||
backstageInputOnce sync.Once
|
||||
backstageInputQueue chan backstageInputEvent
|
||||
backstageInputDropped atomic.Uint64
|
||||
activeCommands = make(map[string]context.CancelFunc)
|
||||
activeCommandsMu sync.Mutex
|
||||
voiceSessionMu sync.Mutex
|
||||
voiceSession *voiceRuntime
|
||||
desktopAudioMu sync.Mutex
|
||||
desktopAudioSession *voiceRuntime
|
||||
backstageInputOnce sync.Once
|
||||
backstageInputQueue chan backstageInputEvent
|
||||
backstageInputDropped atomic.Uint64
|
||||
fileHashSlots = make(chan struct{}, 2)
|
||||
)
|
||||
|
||||
type backstageInputKind int
|
||||
@@ -2697,8 +2698,17 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
payload, _ := envelope["payload"].(map[string]interface{})
|
||||
path, _ := payload["path"].(string)
|
||||
algorithm, _ := payload["algorithm"].(string)
|
||||
hashCtx, cancel := context.WithCancel(ctx)
|
||||
registerCancellableCommand(cmdID, cancel)
|
||||
goSafe("file_hash", env.Cancel, func() {
|
||||
if err := HandleFileHash(ctx, env, cmdID, path, algorithm); err != nil && err != context.Canceled {
|
||||
defer unregisterCommand(cmdID)
|
||||
select {
|
||||
case fileHashSlots <- struct{}{}:
|
||||
defer func() { <-fileHashSlots }()
|
||||
case <-hashCtx.Done():
|
||||
return
|
||||
}
|
||||
if err := HandleFileHash(hashCtx, env, cmdID, path, algorithm); err != nil && err != context.Canceled {
|
||||
log.Printf("file_hash error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -359,16 +359,30 @@ func HandleFileHash(ctx context.Context, env *agentRuntime.Env, cmdID string, pa
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
|
||||
Type: "file_hash_result",
|
||||
CommandID: cmdID,
|
||||
Path: path,
|
||||
Algorithm: algorithm,
|
||||
Error: err.Error(),
|
||||
})
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
n, readErr := f.Read(buf)
|
||||
if n > 0 {
|
||||
if _, err := h.Write(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
|
||||
Type: "file_hash_result", CommandID: cmdID, Path: path,
|
||||
Algorithm: algorithm, Error: readErr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
|
||||
Type: "file_hash_result",
|
||||
CommandID: cmdID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, formatBytes } from "./filebrowser-utils.js";
|
||||
import { escapeHtml, formatBytes, getHighlightLanguage } from "./filebrowser-utils.js";
|
||||
|
||||
const KNOWN_HASHES_SHA256 = {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)",
|
||||
@@ -39,7 +39,23 @@ export function createFileHexHashManager({
|
||||
|
||||
function resetPreviewTextHead() {
|
||||
previewTextHeadHost?.classList.add("hidden");
|
||||
if (previewTextHead) previewTextHead.textContent = "";
|
||||
if (previewTextHead) {
|
||||
previewTextHead.textContent = "";
|
||||
previewTextHead.className = "";
|
||||
delete previewTextHead.dataset.highlighted;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreviewTextHead(text, fileName) {
|
||||
if (!previewTextHead) return;
|
||||
const requestedLanguage = getHighlightLanguage(fileName);
|
||||
const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext";
|
||||
previewTextHead.className = language === "plaintext" ? "hljs" : `language-${language}`;
|
||||
previewTextHead.textContent = text;
|
||||
delete previewTextHead.dataset.highlighted;
|
||||
if (window.hljs && language !== "plaintext") {
|
||||
window.hljs.highlightElement(previewTextHead);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPreviewHash() {
|
||||
@@ -157,7 +173,7 @@ export function createFileHexHashManager({
|
||||
return;
|
||||
}
|
||||
const text = decodeTextHead(data);
|
||||
if (previewTextHead) previewTextHead.textContent = text;
|
||||
renderPreviewTextHead(text, req.fileName);
|
||||
previewTextHeadHost?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ export function createFilePreviewModal({
|
||||
|
||||
let currentPreviewBlobUrl = null;
|
||||
let currentPreviewPath = null;
|
||||
let currentPreviewAbortController = null;
|
||||
|
||||
function closePreview() {
|
||||
currentPreviewAbortController?.abort();
|
||||
currentPreviewAbortController = null;
|
||||
if (filePreviewModal) filePreviewModal.classList.remove("show");
|
||||
if (currentPreviewBlobUrl) {
|
||||
URL.revokeObjectURL(currentPreviewBlobUrl);
|
||||
@@ -48,6 +51,9 @@ export function createFilePreviewModal({
|
||||
return;
|
||||
}
|
||||
|
||||
currentPreviewAbortController?.abort();
|
||||
const abortController = new AbortController();
|
||||
currentPreviewAbortController = abortController;
|
||||
currentPreviewPath = path;
|
||||
if (previewFileNameEl) previewFileNameEl.textContent = fileName;
|
||||
if (previewContent) previewContent.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
|
||||
@@ -59,7 +65,8 @@ export function createFilePreviewModal({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ clientId, path }),
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({ clientId, path, preview: true }),
|
||||
});
|
||||
|
||||
if (!requestRes.ok) {
|
||||
@@ -87,6 +94,7 @@ export function createFilePreviewModal({
|
||||
const res = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -99,22 +107,37 @@ export function createFilePreviewModal({
|
||||
|
||||
const contentLength = Number(res.headers.get("Content-Length") || 0);
|
||||
if (contentLength > PREVIEW_MAX_BYTES) {
|
||||
await res.body?.cancel("preview size limit exceeded");
|
||||
if (currentPreviewPath !== path) return;
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(contentLength))})</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
if (!res.body) throw new Error("Preview response did not include a body");
|
||||
const reader = res.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
received += value.byteLength;
|
||||
if (received > PREVIEW_MAX_BYTES) {
|
||||
await reader.cancel("preview size limit exceeded");
|
||||
throw new Error(`File too large to preview (${formatBytes(received)})`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const arrayBuffer = new Uint8Array(received);
|
||||
let chunkOffset = 0;
|
||||
for (const chunk of chunks) {
|
||||
arrayBuffer.set(chunk, chunkOffset);
|
||||
chunkOffset += chunk.byteLength;
|
||||
}
|
||||
|
||||
if (currentPreviewPath !== path) return;
|
||||
|
||||
if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPreviewBlobUrl) {
|
||||
URL.revokeObjectURL(currentPreviewBlobUrl);
|
||||
currentPreviewBlobUrl = null;
|
||||
@@ -140,9 +163,12 @@ export function createFilePreviewModal({
|
||||
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError" || abortController.signal.aborted) return;
|
||||
if (currentPreviewPath !== path) return;
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(err.message || "Failed to load preview")}</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
} finally {
|
||||
if (currentPreviewAbortController === abortController) currentPreviewAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,40 @@ export const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp",
|
||||
export const PREVIEW_PDF_EXTS = new Set(["pdf"]);
|
||||
export const PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const HIGHLIGHT_FILENAME_MAP = {
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
gnumakefile: "makefile",
|
||||
"nginx.conf": "nginx",
|
||||
".env": "bash",
|
||||
};
|
||||
|
||||
const HIGHLIGHT_EXTENSION_MAP = {
|
||||
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
||||
ts: "typescript", mts: "typescript", cts: "typescript", tsx: "typescript",
|
||||
py: "python", pyw: "python", rb: "ruby", java: "java",
|
||||
cpp: "cpp", cxx: "cpp", cc: "cpp", hpp: "cpp", hxx: "cpp",
|
||||
c: "c", h: "c", cs: "csharp", php: "php", phtml: "php",
|
||||
go: "go", rs: "rust",
|
||||
sh: "bash", bash: "bash", zsh: "bash", ksh: "bash",
|
||||
bat: "powershell", cmd: "powershell", ps1: "powershell", psm1: "powershell", psd1: "powershell",
|
||||
json: "json", jsonc: "json", json5: "json",
|
||||
xml: "xml", svg: "xml", xsl: "xml", xslt: "xml", plist: "xml",
|
||||
html: "xml", htm: "xml", xhtml: "xml", vue: "xml", svelte: "xml",
|
||||
css: "css", scss: "scss", sass: "scss", sql: "sql",
|
||||
yaml: "yaml", yml: "yaml", md: "markdown", markdown: "markdown",
|
||||
ini: "ini", cfg: "ini", properties: "ini", toml: "ini",
|
||||
diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile", nginx: "nginx",
|
||||
};
|
||||
|
||||
export function getHighlightLanguage(name = "") {
|
||||
const fileName = name.split(/[\\/]/).pop()?.toLowerCase() || "";
|
||||
if (HIGHLIGHT_FILENAME_MAP[fileName]) return HIGHLIGHT_FILENAME_MAP[fileName];
|
||||
if (fileName.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (fileName.startsWith(".env.")) return "bash";
|
||||
return HIGHLIGHT_EXTENSION_MAP[getFileExt(fileName)] || "plaintext";
|
||||
}
|
||||
|
||||
export const KNOWN_BINARY_EXTS = new Set([
|
||||
"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v",
|
||||
"mp3", "wav", "flac", "ogg", "aac", "wma", "m4a",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
escapeHtml,
|
||||
formatBytes,
|
||||
getFileExt,
|
||||
getHighlightLanguage,
|
||||
getParentPath,
|
||||
isPreviewable,
|
||||
shouldShowParentDirectory,
|
||||
@@ -140,7 +141,8 @@ function updateStatus(state, text) {
|
||||
disconnected: '<i class="fa-solid fa-circle text-slate-500"></i>',
|
||||
};
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
|
||||
statusEl.innerHTML = icons[state] || icons.disconnected;
|
||||
statusEl.appendChild(document.createTextNode(` ${String(text ?? "")}`));
|
||||
const stateClasses = {
|
||||
connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30",
|
||||
error: "bg-red-500/10 text-red-300 border-red-500/30",
|
||||
@@ -662,6 +664,28 @@ const THUMBNAIL_EXTS = new Set([
|
||||
// key → { blobUrl?: string, failed?: boolean, pending?: boolean }
|
||||
const iconCache = new Map();
|
||||
const thumbCache = new Map();
|
||||
const MAX_ICON_CACHE_ENTRIES = 256;
|
||||
const MAX_THUMB_CACHE_ENTRIES = 128;
|
||||
|
||||
function trimBlobCache(cache, maxEntries) {
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
const entry = cache.get(oldestKey);
|
||||
if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function clearBlobCaches() {
|
||||
for (const cache of [iconCache, thumbCache]) {
|
||||
for (const entry of cache.values()) {
|
||||
if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
}
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", clearBlobCaches, { once: true });
|
||||
|
||||
// Queues + batching
|
||||
const iconQueue = []; // {key, path?, ext?}
|
||||
@@ -743,8 +767,14 @@ function flushThumbQueue() {
|
||||
function requestIconFor(entry) {
|
||||
const key = iconCacheKey(entry);
|
||||
if (!key) return null;
|
||||
if (iconCache.has(key)) return key;
|
||||
if (iconCache.has(key)) {
|
||||
const cached = iconCache.get(key);
|
||||
iconCache.delete(key);
|
||||
iconCache.set(key, cached);
|
||||
return key;
|
||||
}
|
||||
iconCache.set(key, { pending: true });
|
||||
trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES);
|
||||
const item = { key };
|
||||
if (key.startsWith("path:")) {
|
||||
item.path = entry.path;
|
||||
@@ -761,8 +791,14 @@ function requestIconFor(entry) {
|
||||
function requestThumbFor(entry) {
|
||||
const key = thumbCacheKey(entry);
|
||||
if (!key) return null;
|
||||
if (thumbCache.has(key)) return key;
|
||||
if (thumbCache.has(key)) {
|
||||
const cached = thumbCache.get(key);
|
||||
thumbCache.delete(key);
|
||||
thumbCache.set(key, cached);
|
||||
return key;
|
||||
}
|
||||
thumbCache.set(key, { pending: true });
|
||||
trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES);
|
||||
thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE });
|
||||
scheduleThumbFlush();
|
||||
return key;
|
||||
@@ -776,12 +812,15 @@ function handleFileIconResult(msg) {
|
||||
const entry = iconCache.get(item.key) || {};
|
||||
entry.pending = false;
|
||||
if (item.png && item.png.length > 0) {
|
||||
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
const blob = new Blob([item.png], { type: "image/png" });
|
||||
entry.blobUrl = URL.createObjectURL(blob);
|
||||
} else {
|
||||
entry.failed = true;
|
||||
}
|
||||
iconCache.delete(item.key);
|
||||
iconCache.set(item.key, entry);
|
||||
trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES);
|
||||
applyIconToDom(item.key, entry);
|
||||
}
|
||||
}
|
||||
@@ -796,6 +835,7 @@ function handleFileThumbResult(msg) {
|
||||
const entry = thumbCache.get(item.key) || {};
|
||||
entry.pending = false;
|
||||
if (item.jpeg && item.jpeg.length > 0) {
|
||||
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
const blob = new Blob([item.jpeg], { type: "image/jpeg" });
|
||||
entry.blobUrl = URL.createObjectURL(blob);
|
||||
entry.w = item.w || 0;
|
||||
@@ -803,7 +843,9 @@ function handleFileThumbResult(msg) {
|
||||
} else {
|
||||
entry.failed = true;
|
||||
}
|
||||
thumbCache.delete(item.key);
|
||||
thumbCache.set(item.key, entry);
|
||||
trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES);
|
||||
applyThumbToDom(item.key, entry);
|
||||
}
|
||||
}
|
||||
@@ -2120,7 +2162,7 @@ function showProgressNotification(commandId, message, path) {
|
||||
<i class="fa-solid fa-file-zipper text-blue-400"></i>
|
||||
<span class="font-semibold text-slate-200">Zipping Directory</span>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-red-400 transition-colors" data-command-id="${escapeHtml(commandId)}" onclick="cancelZipOperation(this.dataset.commandId)">
|
||||
<button class="cancel-zip-operation text-slate-400 hover:text-red-400 transition-colors" data-command-id="${escapeHtml(commandId)}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2128,6 +2170,11 @@ function showProgressNotification(commandId, message, path) {
|
||||
<div class="text-xs text-slate-500 truncate" title="${escapeHtml(path)}">${escapeHtml(path)}</div>
|
||||
`;
|
||||
|
||||
notification.querySelector(".cancel-zip-operation")?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
cancelZipOperation(commandId);
|
||||
});
|
||||
|
||||
document.body.appendChild(notification);
|
||||
activeProgressNotifications.set(commandId, notification);
|
||||
}
|
||||
@@ -3375,7 +3422,8 @@ function handleFileSearchResult(msg) {
|
||||
"file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2";
|
||||
|
||||
const fileName = result.path.split(/[\/\\]/).pop();
|
||||
const lineInfo = result.line ? ` (line ${result.line})` : "";
|
||||
const lineNumber = Number(result.line);
|
||||
const lineInfo = Number.isSafeInteger(lineNumber) && lineNumber > 0 ? ` (line ${lineNumber})` : "";
|
||||
const matchPreview = result.match
|
||||
? `<div class="text-xs text-slate-500 mt-1 font-mono">${escapeHtml(result.match.substring(0, 100))}</div>`
|
||||
: "";
|
||||
@@ -3390,12 +3438,17 @@ function handleFileSearchResult(msg) {
|
||||
<div class="text-xs text-slate-400">${escapeHtml(result.path)}</div>
|
||||
${matchPreview}
|
||||
</div>
|
||||
<button class="px-2 py-1 rounded hover:bg-slate-700" onclick="event.stopPropagation(); downloadFile('${escapeHtml(result.path)}')">
|
||||
<button class="search-result-download px-2 py-1 rounded hover:bg-slate-700">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.querySelector(".search-result-download")?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
downloadFile(String(result.path || ""));
|
||||
});
|
||||
|
||||
row.onclick = async () => {
|
||||
const resultName = result.path.split(/[\/\\]/).pop() || "";
|
||||
if (isPreviewable(resultName)) {
|
||||
@@ -3507,48 +3560,23 @@ function closeEditor() {
|
||||
|
||||
function applySyntaxHighlighting() {
|
||||
const code = editorTextarea.value;
|
||||
const MAX_HIGHLIGHT_CHARS = 512 * 1024;
|
||||
const displayedCode = code.length > MAX_HIGHLIGHT_CHARS
|
||||
? `${code.slice(0, MAX_HIGHLIGHT_CHARS)}\n\n[Syntax preview truncated for performance]`
|
||||
: code;
|
||||
const codeElement = document.getElementById("editor-code");
|
||||
const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || "";
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const languageMap = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
bat: "powershell",
|
||||
cmd: "powershell",
|
||||
ps1: "powershell",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sql: "sql",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
md: "markdown",
|
||||
txt: "plaintext",
|
||||
};
|
||||
|
||||
const language = languageMap[ext] || "plaintext";
|
||||
codeElement.className = `language-${language}`;
|
||||
codeElement.textContent = code;
|
||||
const requestedLanguage = getHighlightLanguage(fileName);
|
||||
const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext";
|
||||
codeElement.className = language === "plaintext" ? "hljs" : `language-${language}`;
|
||||
codeElement.textContent = displayedCode;
|
||||
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||
editorStatus.textContent = `Ready — syntax preview limited to ${Math.round(MAX_HIGHLIGHT_CHARS / 1024)} KB`;
|
||||
}
|
||||
|
||||
delete codeElement.dataset.highlighted;
|
||||
|
||||
if (window.hljs) {
|
||||
if (window.hljs && language !== "plaintext") {
|
||||
hljs.highlightElement(codeElement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button id="preview-hex-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||
<i class="fa-solid fa-magnifying-glass-chart"></i> Hex peek
|
||||
<i class="fa-solid fa-table-cells"></i> Hex preview (4 KB)
|
||||
</button>
|
||||
<button id="preview-hash-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||
<i class="fa-solid fa-fingerprint"></i> SHA-256
|
||||
@@ -821,7 +821,7 @@
|
||||
<i class="fa-solid fa-star-half-stroke mr-2"></i> Unpin
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="hex_peek">
|
||||
<i class="fa-solid fa-magnifying-glass-chart mr-2"></i> Hex peek (4 KB)
|
||||
<i class="fa-solid fa-table-cells mr-2"></i> Hex preview (4 KB)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="hash">
|
||||
<i class="fa-solid fa-fingerprint mr-2"></i> SHA-256
|
||||
@@ -837,8 +837,8 @@
|
||||
<div class="hex-viewer-content">
|
||||
<div class="px-4 py-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i class="fa-solid fa-magnifying-glass-chart text-blue-400 flex-shrink-0"></i>
|
||||
<span class="font-semibold truncate" id="hex-viewer-file">--</span>
|
||||
<i class="fa-solid fa-table-cells text-blue-400 flex-shrink-0"></i>
|
||||
<div class="min-w-0"><div class="text-[10px] uppercase tracking-wider text-slate-500">Hex preview</div><div class="font-semibold truncate" id="hex-viewer-file">--</div></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span id="hex-viewer-info" class="text-xs text-slate-400"></span>
|
||||
|
||||
@@ -248,11 +248,51 @@ import powershell from '${path.join(NM, "highlight.js", "lib", "languages", "pow
|
||||
import python from '${path.join(NM, "highlight.js", "lib", "languages", "python.js").replace(/\\/g, "/")}';
|
||||
import go from '${path.join(NM, "highlight.js", "lib", "languages", "go.js").replace(/\\/g, "/")}';
|
||||
import rust from '${path.join(NM, "highlight.js", "lib", "languages", "rust.js").replace(/\\/g, "/")}';
|
||||
import javascript from '${path.join(NM, "highlight.js", "lib", "languages", "javascript.js").replace(/\\/g, "/")}';
|
||||
import typescript from '${path.join(NM, "highlight.js", "lib", "languages", "typescript.js").replace(/\\/g, "/")}';
|
||||
import json from '${path.join(NM, "highlight.js", "lib", "languages", "json.js").replace(/\\/g, "/")}';
|
||||
import xml from '${path.join(NM, "highlight.js", "lib", "languages", "xml.js").replace(/\\/g, "/")}';
|
||||
import css from '${path.join(NM, "highlight.js", "lib", "languages", "css.js").replace(/\\/g, "/")}';
|
||||
import scss from '${path.join(NM, "highlight.js", "lib", "languages", "scss.js").replace(/\\/g, "/")}';
|
||||
import yaml from '${path.join(NM, "highlight.js", "lib", "languages", "yaml.js").replace(/\\/g, "/")}';
|
||||
import markdown from '${path.join(NM, "highlight.js", "lib", "languages", "markdown.js").replace(/\\/g, "/")}';
|
||||
import sql from '${path.join(NM, "highlight.js", "lib", "languages", "sql.js").replace(/\\/g, "/")}';
|
||||
import ini from '${path.join(NM, "highlight.js", "lib", "languages", "ini.js").replace(/\\/g, "/")}';
|
||||
import dockerfile from '${path.join(NM, "highlight.js", "lib", "languages", "dockerfile.js").replace(/\\/g, "/")}';
|
||||
import makefile from '${path.join(NM, "highlight.js", "lib", "languages", "makefile.js").replace(/\\/g, "/")}';
|
||||
import diff from '${path.join(NM, "highlight.js", "lib", "languages", "diff.js").replace(/\\/g, "/")}';
|
||||
import nginx from '${path.join(NM, "highlight.js", "lib", "languages", "nginx.js").replace(/\\/g, "/")}';
|
||||
import c from '${path.join(NM, "highlight.js", "lib", "languages", "c.js").replace(/\\/g, "/")}';
|
||||
import cpp from '${path.join(NM, "highlight.js", "lib", "languages", "cpp.js").replace(/\\/g, "/")}';
|
||||
import csharp from '${path.join(NM, "highlight.js", "lib", "languages", "csharp.js").replace(/\\/g, "/")}';
|
||||
import java from '${path.join(NM, "highlight.js", "lib", "languages", "java.js").replace(/\\/g, "/")}';
|
||||
import ruby from '${path.join(NM, "highlight.js", "lib", "languages", "ruby.js").replace(/\\/g, "/")}';
|
||||
import php from '${path.join(NM, "highlight.js", "lib", "languages", "php.js").replace(/\\/g, "/")}';
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('powershell', powershell);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('rust', rust);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('scss', scss);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('ini', ini);
|
||||
hljs.registerLanguage('dockerfile', dockerfile);
|
||||
hljs.registerLanguage('makefile', makefile);
|
||||
hljs.registerLanguage('diff', diff);
|
||||
hljs.registerLanguage('nginx', nginx);
|
||||
hljs.registerLanguage('c', c);
|
||||
hljs.registerLanguage('cpp', cpp);
|
||||
hljs.registerLanguage('csharp', csharp);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('ruby', ruby);
|
||||
hljs.registerLanguage('php', php);
|
||||
globalThis.hljs = hljs;
|
||||
`;
|
||||
const hljsTmp = path.join(ROOT, "scripts", "_hljs-entry.ts");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
FILE_BROWSER_MAX_ICON_ITEMS,
|
||||
validateFileBrowserCommandPayload,
|
||||
} from "./file-browser-security";
|
||||
|
||||
describe("file browser command payload validation", () => {
|
||||
test("caps read and peek sizes", () => {
|
||||
expect(validateFileBrowserCommandPayload("file_read", { path: "C:\\ok.txt", maxSize: 99_999_999 })?.maxSize)
|
||||
.toBe(10 * 1024 * 1024);
|
||||
expect(validateFileBrowserCommandPayload("file_peek", { path: "/tmp/ok", bytes: 99_999 })?.bytes)
|
||||
.toBe(4096);
|
||||
});
|
||||
|
||||
test("rejects control characters and oversized paths", () => {
|
||||
expect(validateFileBrowserCommandPayload("file_read", { path: "C:\\bad\0.txt" })).toBeNull();
|
||||
expect(validateFileBrowserCommandPayload("file_read", { path: "x".repeat(4097) })).toBeNull();
|
||||
});
|
||||
|
||||
test("bounds icon and thumbnail batches", () => {
|
||||
const icons = Array.from({ length: FILE_BROWSER_MAX_ICON_ITEMS + 1 }, (_, i) => ({ key: `ext:${i}`, ext: "txt" }));
|
||||
expect(validateFileBrowserCommandPayload("file_icon", { items: icons })).toBeNull();
|
||||
expect(validateFileBrowserCommandPayload("file_thumb", {
|
||||
items: [{ key: "thumb:ok", path: "C:\\ok.png", size: 96 }],
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
test("only permits agent upload pulls served by this application", () => {
|
||||
expect(validateFileBrowserCommandPayload("file_upload_http", {
|
||||
path: "C:\\ok.bin",
|
||||
url: "/api/file/upload/pull/123e4567-e89b-42d3-a456-426614174000",
|
||||
total: 10,
|
||||
})).not.toBeNull();
|
||||
expect(validateFileBrowserCommandPayload("file_upload_http", {
|
||||
path: "C:\\ok.bin", url: "https://attacker.invalid/payload", total: 10,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export const FILE_BROWSER_MAX_PATH = 4096;
|
||||
export const FILE_BROWSER_MAX_READ_BYTES = 10 * 1024 * 1024;
|
||||
export const FILE_BROWSER_MAX_WRITE_CHARS = 10 * 1024 * 1024;
|
||||
export const FILE_BROWSER_MAX_ICON_ITEMS = 32;
|
||||
export const FILE_BROWSER_MAX_THUMB_ITEMS = 8;
|
||||
|
||||
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
||||
const HASH_ALGORITHMS = new Set(["md5", "sha1", "sha256", "crc32"]);
|
||||
|
||||
function safeString(value: unknown, maxLength: number, allowEmpty = false): value is string {
|
||||
return typeof value === "string"
|
||||
&& value.length <= maxLength
|
||||
&& (allowEmpty || value.length > 0)
|
||||
&& !CONTROL_CHARS.test(value);
|
||||
}
|
||||
|
||||
export function isSafeFileBrowserPath(value: unknown, allowEmpty = false): value is string {
|
||||
return safeString(value, FILE_BROWSER_MAX_PATH, allowEmpty);
|
||||
}
|
||||
|
||||
function safeCommandId(value: unknown): value is string {
|
||||
return safeString(value, 128);
|
||||
}
|
||||
|
||||
function safeItems(value: unknown, limit: number, thumbnail: boolean): boolean {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > limit) return false;
|
||||
return value.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (!safeString(entry.key, FILE_BROWSER_MAX_PATH + 128)) return false;
|
||||
if (entry.path !== undefined && !isSafeFileBrowserPath(entry.path)) return false;
|
||||
if (entry.ext !== undefined && !safeString(entry.ext, 32, true)) return false;
|
||||
if (thumbnail && (!Number.isInteger(entry.size) || Number(entry.size) < 16 || Number(entry.size) > 512)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateFileBrowserCommandPayload(
|
||||
commandType: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const payload = value as Record<string, unknown>;
|
||||
const pathOk = () => isSafeFileBrowserPath(payload.path);
|
||||
|
||||
switch (commandType) {
|
||||
case "file_read":
|
||||
if (!pathOk()) return null;
|
||||
return { ...payload, maxSize: Math.min(FILE_BROWSER_MAX_READ_BYTES, Math.max(1, Number(payload.maxSize) || FILE_BROWSER_MAX_READ_BYTES)) };
|
||||
case "file_write":
|
||||
if (!pathOk() || typeof payload.content !== "string" || payload.content.length > FILE_BROWSER_MAX_WRITE_CHARS) return null;
|
||||
return payload;
|
||||
case "file_request_access":
|
||||
case "file_execute":
|
||||
case "file_dirsize":
|
||||
return pathOk() ? payload : null;
|
||||
case "file_search": {
|
||||
if (!isSafeFileBrowserPath(payload.path, true) || !safeString(payload.pattern, 512)) return null;
|
||||
if (payload.searchId !== undefined && !safeCommandId(payload.searchId)) return null;
|
||||
return { ...payload, maxResults: Math.min(500, Math.max(1, Number(payload.maxResults) || 100)) };
|
||||
}
|
||||
case "file_copy":
|
||||
case "file_move":
|
||||
return isSafeFileBrowserPath(payload.source) && isSafeFileBrowserPath(payload.dest) ? payload : null;
|
||||
case "file_chmod":
|
||||
return pathOk() && safeString(payload.mode, 16) ? payload : null;
|
||||
case "file_icon":
|
||||
return safeItems(payload.items, FILE_BROWSER_MAX_ICON_ITEMS, false) ? payload : null;
|
||||
case "file_thumb":
|
||||
return safeItems(payload.items, FILE_BROWSER_MAX_THUMB_ITEMS, true) ? payload : null;
|
||||
case "file_peek":
|
||||
if (!pathOk()) return null;
|
||||
return { ...payload, bytes: Math.min(4096, Math.max(1, Number(payload.bytes) || 4096)) };
|
||||
case "file_hash": {
|
||||
const algorithm = String(payload.algorithm || "sha256").toLowerCase();
|
||||
return pathOk() && HASH_ALGORITHMS.has(algorithm) ? { ...payload, algorithm } : null;
|
||||
}
|
||||
case "file_upload_http":
|
||||
if (!pathOk() || typeof payload.url !== "string"
|
||||
|| !/^\/api\/file\/upload\/pull\/[0-9a-f-]{36}$/i.test(payload.url)) return null;
|
||||
if (!Number.isSafeInteger(Number(payload.total)) || Number(payload.total) < 0) return null;
|
||||
return payload;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type RateState = {
|
||||
windowStart: number;
|
||||
commands: number;
|
||||
expensiveWindowStart: number;
|
||||
expensiveCommands: number;
|
||||
};
|
||||
|
||||
const rateStates = new WeakMap<object, RateState>();
|
||||
const RATE_WINDOW_MS = 10_000;
|
||||
const RATE_MAX_COMMANDS = 120;
|
||||
const EXPENSIVE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX_EXPENSIVE = 20;
|
||||
|
||||
export function consumeFileBrowserCommandRateLimit(socket: object, expensive: boolean): boolean {
|
||||
const now = Date.now();
|
||||
let state = rateStates.get(socket);
|
||||
if (!state) {
|
||||
state = { windowStart: now, commands: 0, expensiveWindowStart: now, expensiveCommands: 0 };
|
||||
rateStates.set(socket, state);
|
||||
}
|
||||
if (now - state.windowStart >= RATE_WINDOW_MS) {
|
||||
state.windowStart = now;
|
||||
state.commands = 0;
|
||||
}
|
||||
if (now - state.expensiveWindowStart >= EXPENSIVE_WINDOW_MS) {
|
||||
state.expensiveWindowStart = now;
|
||||
state.expensiveCommands = 0;
|
||||
}
|
||||
state.commands++;
|
||||
if (expensive) state.expensiveCommands++;
|
||||
return state.commands <= RATE_MAX_COMMANDS && state.expensiveCommands <= RATE_MAX_EXPENSIVE;
|
||||
}
|
||||
@@ -26,7 +26,10 @@ export type PendingHttpDownload = {
|
||||
nextExpectedOffset?: number;
|
||||
streamErrored?: boolean;
|
||||
onFirstChunk?: () => void;
|
||||
onFirstChunkError?: (error: Error) => void;
|
||||
firstChunkSignaled?: boolean;
|
||||
userId?: number;
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
export type DownloadIntent = {
|
||||
@@ -36,6 +39,7 @@ export type DownloadIntent = {
|
||||
path: string;
|
||||
expiresAt: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
export type UploadIntent = {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
consumeHttpDownloadPayload,
|
||||
STREAM_MAX_CHUNK_BYTES,
|
||||
type PendingHttpDownload,
|
||||
} from "./http-download-consumer";
|
||||
|
||||
function pendingDownload(overrides: Partial<PendingHttpDownload> = {}) {
|
||||
let resolved = false;
|
||||
let streamError = "";
|
||||
const pending: PendingHttpDownload = {
|
||||
commandId: "cmd",
|
||||
clientId: "client",
|
||||
path: "/tmp/file",
|
||||
fileName: "file",
|
||||
total: 0,
|
||||
receivedBytes: 0,
|
||||
receivedOffsets: new Set(),
|
||||
receivedChunks: new Set(),
|
||||
chunkSize: 0,
|
||||
expectedChunks: 0,
|
||||
tmpPath: "",
|
||||
fileHandle: null as any,
|
||||
resolve: () => { resolved = true; },
|
||||
reject: () => {},
|
||||
timeout: setTimeout(() => {}, 60_000),
|
||||
streamController: {
|
||||
enqueue() {},
|
||||
close() {},
|
||||
error(error: Error) { streamError = error.message; },
|
||||
} as any,
|
||||
...overrides,
|
||||
};
|
||||
return { pending, state: () => ({ resolved, streamError }) };
|
||||
}
|
||||
|
||||
describe("HTTP download stream limits", () => {
|
||||
test("rejects chunks larger than the agent protocol maximum", async () => {
|
||||
const fixture = pendingDownload();
|
||||
const entries = new Map([[fixture.pending.commandId, fixture.pending]]);
|
||||
await consumeHttpDownloadPayload({
|
||||
commandId: "cmd", offset: 0, total: STREAM_MAX_CHUNK_BYTES + 1,
|
||||
data: new Uint8Array(STREAM_MAX_CHUNK_BYTES + 1),
|
||||
}, entries);
|
||||
clearTimeout(fixture.pending.timeout);
|
||||
expect(entries.has("cmd")).toBeFalse();
|
||||
expect(fixture.state().streamError).toContain("chunk exceeded");
|
||||
});
|
||||
|
||||
test("enforces preview byte budgets from total metadata", async () => {
|
||||
const fixture = pendingDownload({ maxBytes: 1024 });
|
||||
const entries = new Map([[fixture.pending.commandId, fixture.pending]]);
|
||||
await consumeHttpDownloadPayload({ commandId: "cmd", total: 2048 }, entries);
|
||||
clearTimeout(fixture.pending.timeout);
|
||||
expect(entries.has("cmd")).toBeFalse();
|
||||
expect(fixture.state().streamError).toContain("requested byte limit");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { FileHandle } from "fs/promises";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const STREAM_REORDER_BUFFER_LIMIT = 16;
|
||||
export const STREAM_REORDER_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
||||
export const STREAM_MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type PendingHttpDownload = {
|
||||
commandId: string;
|
||||
@@ -27,7 +29,10 @@ export type PendingHttpDownload = {
|
||||
nextExpectedOffset?: number;
|
||||
streamErrored?: boolean;
|
||||
onFirstChunk?: () => void;
|
||||
onFirstChunkError?: (error: Error) => void;
|
||||
firstChunkSignaled?: boolean;
|
||||
userId?: number;
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
function isStreamMode(pending: PendingHttpDownload): boolean {
|
||||
@@ -41,6 +46,10 @@ async function failStream(pending: PendingHttpDownload, err: Error): Promise<voi
|
||||
pending.streamController?.error(err);
|
||||
} catch {}
|
||||
pending.reorderBuffer?.clear();
|
||||
if (!pending.firstChunkSignaled && pending.onFirstChunkError) {
|
||||
pending.firstChunkSignaled = true;
|
||||
try { pending.onFirstChunkError(err); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueOrdered(pending: PendingHttpDownload, offset: number, data: Uint8Array): boolean {
|
||||
@@ -77,6 +86,12 @@ function enqueueOrdered(pending: PendingHttpDownload, offset: number, data: Uint
|
||||
void failStream(pending, new Error("download chunks arrived too far out of order"));
|
||||
return false;
|
||||
}
|
||||
let bufferedBytes = 0;
|
||||
for (const chunk of pending.reorderBuffer.values()) bufferedBytes += chunk.byteLength;
|
||||
if (bufferedBytes > STREAM_REORDER_BUFFER_MAX_BYTES) {
|
||||
void failStream(pending, new Error("download reorder buffer byte limit exceeded"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -134,6 +149,13 @@ export async function consumeHttpDownloadPayload(
|
||||
pending.total = total;
|
||||
}
|
||||
}
|
||||
if (pending.maxBytes && pending.total > pending.maxBytes) {
|
||||
await failStream(pending, new Error("download exceeded requested byte limit"));
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
pending.resolve(pending);
|
||||
return;
|
||||
}
|
||||
if (pending.total > 0 && !pending.loggedTotal) {
|
||||
pending.loggedTotal = true;
|
||||
logger.debug("[filebrowser] http download total", {
|
||||
@@ -159,6 +181,21 @@ export async function consumeHttpDownloadPayload(
|
||||
const chunkIndex = toNumber(payload?.chunkIndex);
|
||||
const chunksTotal = toNumber(payload?.chunksTotal);
|
||||
|
||||
if (data.byteLength > STREAM_MAX_CHUNK_BYTES) {
|
||||
await failStream(pending, new Error("download chunk exceeded size limit"));
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
pending.resolve(pending);
|
||||
return;
|
||||
}
|
||||
if (pending.maxBytes && pending.receivedBytes + data.byteLength > pending.maxBytes) {
|
||||
await failStream(pending, new Error("download exceeded requested byte limit"));
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
pending.resolve(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset === null) {
|
||||
logger.debug("[filebrowser] http download missing offset", {
|
||||
commandId,
|
||||
@@ -191,6 +228,15 @@ export async function consumeHttpDownloadPayload(
|
||||
}
|
||||
|
||||
const effectiveOffset = offset ?? 0;
|
||||
if (effectiveOffset < 0
|
||||
|| !Number.isSafeInteger(effectiveOffset)
|
||||
|| (pending.total > 0 && effectiveOffset + data.byteLength > pending.total)) {
|
||||
await failStream(pending, new Error("invalid download chunk offset"));
|
||||
clearTimeout(pending.timeout);
|
||||
pendingHttpDownloads.delete(commandId);
|
||||
pending.resolve(pending);
|
||||
return;
|
||||
}
|
||||
const shouldWrite = chunkIndex !== null
|
||||
? !pending.receivedChunks.has(chunkIndex)
|
||||
: !pending.receivedOffsets.has(effectiveOffset);
|
||||
|
||||
@@ -87,6 +87,13 @@ async function serveDownloadById(
|
||||
|
||||
const clientId = intent.clientId;
|
||||
const downloadPath = intent.path;
|
||||
const maxBytes = intent.maxBytes;
|
||||
|
||||
const activeForUser = [...deps.pendingHttpDownloads.values()]
|
||||
.filter((pending) => pending.userId === user.userId).length;
|
||||
if (activeForUser >= 8) {
|
||||
return new Response("Too many concurrent downloads", { status: 429, headers: { "Retry-After": "10" } });
|
||||
}
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
@@ -172,6 +179,9 @@ async function serveDownloadById(
|
||||
reorderBuffer: new Map(),
|
||||
nextExpectedOffset: 0,
|
||||
onFirstChunk: () => firstChunkResolve(),
|
||||
onFirstChunkError: (error) => firstChunkReject(error),
|
||||
userId: user.userId,
|
||||
maxBytes,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +212,7 @@ async function serveDownloadById(
|
||||
type: "command",
|
||||
commandType: "file_download",
|
||||
id: commandId,
|
||||
payload: { path: downloadPath },
|
||||
payload: { path: downloadPath, ...(maxBytes ? { maxBytes } : {}) },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -704,7 +714,15 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Client offline", { status: 404 });
|
||||
}
|
||||
|
||||
const activeIntentsForUser = [...deps.downloadIntents.values()]
|
||||
.filter((intent) => intent.userId === user.userId && intent.expiresAt > Date.now()).length;
|
||||
if (activeIntentsForUser >= 16) {
|
||||
return new Response("Too many pending downloads", { status: 429, headers: { "Retry-After": "10" } });
|
||||
}
|
||||
|
||||
const downloadId = uuidv4();
|
||||
const requestedPreview = body?.preview === true;
|
||||
const maxBytes = requestedPreview ? 50 * 1024 * 1024 : undefined;
|
||||
const expiresAt = Date.now() + 2 * 60_000;
|
||||
const timeout = setTimeout(() => {
|
||||
deps.downloadIntents.delete(downloadId);
|
||||
@@ -717,6 +735,7 @@ export async function handleFileDownloadRoutes(
|
||||
path: downloadPath,
|
||||
expiresAt,
|
||||
timeout,
|
||||
maxBytes,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { authenticateRequest, extractTokenFromRequest } from "../../auth";
|
||||
import { hashTokenForSession } from "../../db";
|
||||
import { logger } from "../../logger";
|
||||
import { isIpBanned } from "../../db";
|
||||
import type { FeatureName, UserRole } from "../../users";
|
||||
@@ -198,6 +199,8 @@ async function tryClientViewerUpgrade(
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
};
|
||||
const token = extractTokenFromRequest(req);
|
||||
if (token) data.authTokenHash = hashTokenForSession(token);
|
||||
if (route.sessionId) {
|
||||
data.sessionId = route.sessionId();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,30 @@ import * as clientManager from "../clientManager";
|
||||
import { logger } from "../logger";
|
||||
import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import { getSessionByTokenHash } from "../db";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import { normalizeFileUploadPayload } from "../fileTransfers";
|
||||
import { canUserAccessClient } from "../users";
|
||||
import { canUserAccessClient, canUserAccessFeature, getUserById } from "../users";
|
||||
import { hasPermission } from "../rbac";
|
||||
import { decodeViewerPayload, safeSendViewer } from "./ws-viewer-utils";
|
||||
import {
|
||||
consumeFileBrowserCommandRateLimit,
|
||||
FILE_BROWSER_MAX_ICON_ITEMS,
|
||||
FILE_BROWSER_MAX_READ_BYTES,
|
||||
FILE_BROWSER_MAX_THUMB_ITEMS,
|
||||
isSafeFileBrowserPath,
|
||||
validateFileBrowserCommandPayload,
|
||||
} from "./file-browser-security";
|
||||
|
||||
function boundedBytes(value: unknown, maxBytes: number): Uint8Array | null {
|
||||
let bytes: Uint8Array;
|
||||
if (value instanceof Uint8Array) bytes = value;
|
||||
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
|
||||
else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
else return null;
|
||||
return bytes.byteLength <= maxBytes ? bytes : null;
|
||||
}
|
||||
|
||||
type FileBrowserViewer = {
|
||||
id: string;
|
||||
@@ -32,11 +51,55 @@ type WsViewerClusterDeps = {
|
||||
|
||||
const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
|
||||
|
||||
const fileBrowserCommandSessions = new Map<string, string>();
|
||||
const fileBrowserCommandSessions = new Map<string, {
|
||||
sessionId: string;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
|
||||
function trackFileBrowserCommand(commandId: string, sessionId: string): void {
|
||||
fileBrowserCommandSessions.set(commandId, sessionId);
|
||||
setTimeout(() => fileBrowserCommandSessions.delete(commandId), 10 * 60 * 1000);
|
||||
const existing = fileBrowserCommandSessions.get(commandId);
|
||||
if (existing) clearTimeout(existing.timeout);
|
||||
const timeout = setTimeout(() => fileBrowserCommandSessions.delete(commandId), 10 * 60 * 1000);
|
||||
fileBrowserCommandSessions.set(commandId, { sessionId, timeout });
|
||||
}
|
||||
|
||||
function finishFileBrowserCommand(commandId: string): void {
|
||||
const entry = fileBrowserCommandSessions.get(commandId);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timeout);
|
||||
fileBrowserCommandSessions.delete(commandId);
|
||||
}
|
||||
|
||||
function viewerCommandId(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128 ? value : fallback;
|
||||
}
|
||||
|
||||
function denyFileBrowserViewer(ws: ServerWebSocket<SocketData>, reason: string): false {
|
||||
logger.warn(`[filebrowser] closing unauthorized viewer: ${reason}`);
|
||||
try { ws.close(1008, reason); } catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLiveFileBrowserAccess(ws: ServerWebSocket<SocketData>): boolean {
|
||||
const { authTokenHash, clientId, userId } = ws.data;
|
||||
if (!authTokenHash || userId === undefined) return denyFileBrowserViewer(ws, "Authentication expired");
|
||||
const session = getSessionByTokenHash(authTokenHash);
|
||||
if (!session || session.userId !== userId || session.expiresAt <= Math.floor(Date.now() / 1000)) {
|
||||
return denyFileBrowserViewer(ws, "Session expired or revoked");
|
||||
}
|
||||
const user = getUserById(userId);
|
||||
if (!user || user.role === "viewer") return denyFileBrowserViewer(ws, "File browser access denied");
|
||||
if (!hasPermission(user.role, "clients:control", user.id)
|
||||
|| !canUserAccessFeature(user.id, user.role, "file_browser")
|
||||
|| !canUserAccessClient(user.id, user.role, clientId)) {
|
||||
return denyFileBrowserViewer(ws, "File browser access revoked");
|
||||
}
|
||||
ws.data.userRole = user.role;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectFileBrowserCommand(ws: ServerWebSocket<SocketData>, message: string): void {
|
||||
safeSendViewer(ws, { type: "command_error", error: message });
|
||||
}
|
||||
|
||||
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
@@ -58,7 +121,16 @@ export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
|
||||
export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
const expensive = payload?.commandType === "file_hash"
|
||||
|| payload?.commandType === "file_thumb"
|
||||
|| payload?.type === "file_download"
|
||||
|| payload?.type === "file_zip";
|
||||
if (!consumeFileBrowserCommandRateLimit(ws, expensive)) {
|
||||
rejectFileBrowserCommand(ws, "File browser command rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
if (!hasLiveFileBrowserAccess(ws)) return;
|
||||
const { clientId } = ws.data;
|
||||
logger.debug(`[DEBUG] File browser message from viewer for client ${clientId}:`, payload.type, payload.commandType || "");
|
||||
|
||||
@@ -74,8 +146,28 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
if (payload.type === "command") {
|
||||
if (typeof payload.commandType !== "string") return;
|
||||
logger.debug(`[DEBUG] Handling command type: ${payload.commandType}`);
|
||||
const actualPayload = payload.payload || {};
|
||||
const routedId = payload.id || commandId;
|
||||
if (payload.commandType === "silent_exec") {
|
||||
const current = getUserById(ws.data.userId!);
|
||||
if (!current || !hasPermission(current.role, "clients:silent-exec", current.id)) {
|
||||
rejectFileBrowserCommand(ws, "Silent execution is not permitted");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.commandType === "silent_exec") {
|
||||
const command = payload.payload?.command;
|
||||
if (typeof command !== "string" || command.length === 0 || command.length > 32_768 || /[\x00]/.test(command)) {
|
||||
rejectFileBrowserCommand(ws, "Invalid silent execution payload");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const actualPayload = payload.commandType === "silent_exec"
|
||||
? payload.payload
|
||||
: validateFileBrowserCommandPayload(payload.commandType, payload.payload || {});
|
||||
if (!actualPayload) {
|
||||
rejectFileBrowserCommand(ws, "Invalid file browser command payload");
|
||||
return;
|
||||
}
|
||||
const routedId = typeof payload.id === "string" && payload.id.length <= 128 ? payload.id : commandId;
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(routedId, ws.data.sessionId);
|
||||
switch (payload.commandType) {
|
||||
case "file_read":
|
||||
@@ -167,6 +259,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
|
||||
switch (payload.type) {
|
||||
case "file_list":
|
||||
if (!isSafeFileBrowserPath(payload.path, true)) return rejectFileBrowserCommand(ws, "Invalid path");
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(commandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_list", id: commandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_list");
|
||||
@@ -181,6 +274,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
});
|
||||
break;
|
||||
case "file_download":
|
||||
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(commandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_download", id: commandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_download");
|
||||
@@ -197,6 +291,10 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
case "file_upload": {
|
||||
const upload = normalizeFileUploadPayload(payload);
|
||||
if (!upload) return;
|
||||
if (!isSafeFileBrowserPath(upload.path)) {
|
||||
rejectFileBrowserCommand(ws, "Invalid upload path");
|
||||
return;
|
||||
}
|
||||
if (upload.total > WS_UPLOAD_MAX_TOTAL) {
|
||||
safeSendViewer(ws, {
|
||||
type: "file_upload_result",
|
||||
@@ -208,7 +306,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const uploadCommandId = payload.commandId || commandId;
|
||||
const uploadCommandId = viewerCommandId(payload.commandId, commandId);
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(uploadCommandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({
|
||||
type: "command",
|
||||
@@ -237,7 +335,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
break;
|
||||
}
|
||||
case "file_delete": {
|
||||
const deleteCommandId = payload.commandId || commandId;
|
||||
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
|
||||
const deleteCommandId = viewerCommandId(payload.commandId, commandId);
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(deleteCommandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_delete", id: deleteCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_delete");
|
||||
@@ -253,7 +352,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
break;
|
||||
}
|
||||
case "file_mkdir": {
|
||||
const mkdirCommandId = payload.commandId || commandId;
|
||||
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
|
||||
const mkdirCommandId = viewerCommandId(payload.commandId, commandId);
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(mkdirCommandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_mkdir", id: mkdirCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_mkdir");
|
||||
@@ -269,7 +369,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
break;
|
||||
}
|
||||
case "file_zip": {
|
||||
const zipCommandId = payload.commandId || commandId;
|
||||
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
|
||||
const zipCommandId = viewerCommandId(payload.commandId, commandId);
|
||||
if (ws.data.sessionId) trackFileBrowserCommand(zipCommandId, ws.data.sessionId);
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_zip", id: zipCommandId, payload: { path: payload.path || "" } } as any));
|
||||
metrics.recordCommand("file_zip");
|
||||
@@ -285,7 +386,9 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
||||
break;
|
||||
}
|
||||
case "command_abort":
|
||||
target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any));
|
||||
if (typeof payload.commandId === "string" && payload.commandId.length <= 128) {
|
||||
target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -304,7 +407,8 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
|
||||
}
|
||||
|
||||
const payloadCommandId = typeof payload?.commandId === "string" ? payload.commandId : undefined;
|
||||
const ownerSessionId = payloadCommandId ? fileBrowserCommandSessions.get(payloadCommandId) : undefined;
|
||||
const commandOwner = payloadCommandId ? fileBrowserCommandSessions.get(payloadCommandId) : undefined;
|
||||
const ownerSessionId = commandOwner?.sessionId;
|
||||
|
||||
let hasSession = false;
|
||||
for (const session of sessionManager.getFileBrowserSessionsByClient(clientId)) {
|
||||
@@ -321,30 +425,65 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
|
||||
continue;
|
||||
}
|
||||
if (payload.type === "file_download" && payload.data) {
|
||||
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
|
||||
safeSendViewer(session.viewer, { ...payload, data });
|
||||
const data = boundedBytes(payload.data, 4 * 1024 * 1024);
|
||||
safeSendViewer(session.viewer, data
|
||||
? { ...payload, data }
|
||||
: { type: "file_download", commandId: payload.commandId, path: payload.path, error: "Download chunk exceeded limit" });
|
||||
} 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;
|
||||
const icons = payload.icons.slice(0, FILE_BROWSER_MAX_ICON_ITEMS).flatMap((item: any) => {
|
||||
if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
|
||||
const png = item.png ? boundedBytes(item.png, 512 * 1024) : null;
|
||||
return [{ key: item.key, ...(png ? { png } : {}), ...(item.error ? { error: String(item.error).slice(0, 512) } : {}) }];
|
||||
});
|
||||
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;
|
||||
const thumbs = payload.thumbs.slice(0, FILE_BROWSER_MAX_THUMB_ITEMS).flatMap((item: any) => {
|
||||
if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
|
||||
const jpeg = item.jpeg ? boundedBytes(item.jpeg, 1024 * 1024) : null;
|
||||
return [{
|
||||
key: item.key,
|
||||
...(jpeg ? { jpeg } : {}),
|
||||
w: Math.min(512, Math.max(0, Number(item.w) || 0)),
|
||||
h: Math.min(512, Math.max(0, Number(item.h) || 0)),
|
||||
...(item.error ? { error: String(item.error).slice(0, 512) } : {}),
|
||||
}];
|
||||
});
|
||||
safeSendViewer(session.viewer, { ...payload, thumbs });
|
||||
} else if (payload.type === "file_peek_result" && payload.data && !(payload.data instanceof Uint8Array)) {
|
||||
safeSendViewer(session.viewer, { ...payload, data: new Uint8Array(payload.data) });
|
||||
} else if (payload.type === "file_peek_result" && payload.data) {
|
||||
const data = boundedBytes(payload.data, 4096);
|
||||
safeSendViewer(session.viewer, data
|
||||
? { ...payload, data }
|
||||
: { type: "file_peek_result", commandId: payload.commandId, path: payload.path, error: "Preview data exceeded limit" });
|
||||
} else if (payload.type === "file_read_result" && typeof payload.content === "string") {
|
||||
safeSendViewer(session.viewer, payload.content.length <= FILE_BROWSER_MAX_READ_BYTES
|
||||
? payload
|
||||
: { type: "file_read_result", commandId: payload.commandId, path: payload.path, error: "File content exceeded editor limit" });
|
||||
} else if (payload.type === "file_search_result" && Array.isArray(payload.results)) {
|
||||
const results = payload.results.slice(0, 500).flatMap((result: any) => {
|
||||
if (!result || !isSafeFileBrowserPath(result.path)) return [];
|
||||
const line = Number(result.line);
|
||||
return [{
|
||||
path: result.path,
|
||||
...(Number.isSafeInteger(line) && line > 0 ? { line } : {}),
|
||||
...(typeof result.match === "string" ? { match: result.match.slice(0, 4096) } : {}),
|
||||
}];
|
||||
});
|
||||
safeSendViewer(session.viewer, { ...payload, results });
|
||||
} else {
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
if (payloadCommandId) {
|
||||
const isTerminalDownload = type === "file_download" && (
|
||||
!!payload?.error
|
||||
|| (Number.isFinite(Number(payload?.chunksTotal))
|
||||
&& Number.isFinite(Number(payload?.chunkIndex))
|
||||
&& Number(payload.chunkIndex) + 1 >= Number(payload.chunksTotal))
|
||||
);
|
||||
if (type === "command_result" || type?.endsWith("_result") || isTerminalDownload) {
|
||||
finishFileBrowserCommand(payloadCommandId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type SocketData = {
|
||||
userRole?: string;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
authTokenHash?: string;
|
||||
wasKnown?: boolean;
|
||||
autoTasksRan?: boolean;
|
||||
autoDeploysRan?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user