From 18321994d80038c74a17555473a92b4823a5ff2d Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 4 Jun 2026 00:04:49 -0500 Subject: [PATCH] fixed dashboard cards, add fuse js, fix load times, fix csp in scripts, fix remote desktop not working first try. --- Overlord-Server/bun.lock | 3 + Overlord-Server/package.json | 1 + .../public/assets/command-palette.js | 1 + .../public/assets/dashboard-stats.js | 14 + Overlord-Server/public/assets/data.js | 24 ++ Overlord-Server/public/assets/main.css | 32 ++ Overlord-Server/public/assets/nav.js | 95 +++-- .../public/assets/remotedesktop.js | 82 +++- Overlord-Server/public/assets/soft-nav.js | 357 ++++++++++++++++++ Overlord-Server/src/client-order.test.ts | 33 +- Overlord-Server/src/db/repositories.ts | 347 ++++++++++++----- Overlord-Server/src/db/schema.ts | 76 ++++ .../src/server/http-security.test.ts | 1 + Overlord-Server/src/server/http-security.ts | 2 +- .../src/server/ws-console-rd-hvnc.ts | 24 +- 15 files changed, 974 insertions(+), 118 deletions(-) create mode 100644 Overlord-Server/public/assets/soft-nav.js diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index 602d889..7fae326 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -9,6 +9,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", "jose": "^6.1.3", @@ -311,6 +312,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fuse.js": ["fuse.js@7.4.1", "", {}, "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw=="], + "geoip-lite": ["geoip-lite@2.0.1", "", { "dependencies": { "chalk": "4.1 - 4.1.2", "iconv-lite": "0.4.13 - 0.6.3", "ip-address": "5.8.9 - 5.9.4", "lazy": "1.0.11", "yauzl": "^3.2.1" } }, "sha512-cR9E28nu1a6dsvzB1tANhdmCyXWV1L4AiSCT9alHLIUl06599EGu33mqY99ieU0twQob0kfcDQ/sAUBvHb7swA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 8ce510b..6f85831 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -20,6 +20,7 @@ "@solana/web3.js": "^1.98.4", "adm-zip": "^0.5.12", "ansi-to-html": "0.7.2", + "fuse.js": "^7.4.1", "geoip-lite": "latest", "gridstack": "^12.6.0", "jose": "^6.1.3", diff --git a/Overlord-Server/public/assets/command-palette.js b/Overlord-Server/public/assets/command-palette.js index f3d0781..cffeeaa 100644 --- a/Overlord-Server/public/assets/command-palette.js +++ b/Overlord-Server/public/assets/command-palette.js @@ -268,6 +268,7 @@ function close() { function navigate(href, newTab = false) { pushRecent(href); if (newTab) window.open(href, "_blank", "noopener"); + else if (window.overlordSoftNavigate) window.overlordSoftNavigate(href); else window.location.href = href; close(); } diff --git a/Overlord-Server/public/assets/dashboard-stats.js b/Overlord-Server/public/assets/dashboard-stats.js index 37439b0..f3ced54 100644 --- a/Overlord-Server/public/assets/dashboard-stats.js +++ b/Overlord-Server/public/assets/dashboard-stats.js @@ -22,6 +22,18 @@ const DEFAULT_GRID_LAYOUT = { }; let statsGrid = null; +function teardownDashboardStats() { + if (metricsTimer) clearInterval(metricsTimer); + metricsTimer = null; + onlineChart?.destroy?.(); + osChart?.destroy?.(); + onlineChart = null; + osChart = null; + statsGrid?.destroy?.(false); + statsGrid = null; + initialized = false; +} + const palette = { text: "#cbd5e1", muted: "#94a3b8", @@ -629,6 +641,8 @@ async function fetchDashboardMetrics() { export function initDashboardStats() { if (initialized) return; initialized = true; + window.removeEventListener("pagehide", teardownDashboardStats); + window.addEventListener("pagehide", teardownDashboardStats); if (!makeCharts()) { setTimeout(() => { if (makeCharts()) applyHeightMode(getSavedHeight()); diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index 9d8ecb7..7efedd9 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -14,6 +14,29 @@ let dashboardWsConnected = false; let wsReconnectTimer = null; const manuallyDisconnecting = new Set(); +function stopDashboardRealtime() { + clearInterval(pollTimer); + pollTimer = null; + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + if (dashboardWs) { + try { + dashboardWs.onopen = null; + dashboardWs.onmessage = null; + dashboardWs.onerror = null; + dashboardWs.onclose = null; + dashboardWs.close(1000, "page hidden"); + } catch {} + } + dashboardWs = null; + dashboardWsConnected = false; +} + +function bindDashboardPagehideCleanup() { + window.removeEventListener("pagehide", stopDashboardRealtime); + window.addEventListener("pagehide", stopDashboardRealtime); +} + function moveClientCardImmediately(msg) { const clientId = typeof msg?.clientId === "string" ? msg.clientId : ""; if (!clientId) return; @@ -243,6 +266,7 @@ function adjustPollingForWs() { } export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) { + bindDashboardPagehideCleanup(); connectDashboardWs(); const effectiveInterval = dashboardWsConnected ? FALLBACK_POLL_MS diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index a53c724..fc433c3 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1828,6 +1828,35 @@ mark.search-current { z-index: 10; width: 100%; min-height: 60px; + background: rgba(2, 6, 23, 0.92); + border-bottom: 1px solid rgba(30, 41, 59, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +#top-nav:empty::before { + content: "Overlord"; + display: flex; + align-items: center; + height: 60px; + padding: 0 20px; + color: #e2e8f0; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +html.nav-pre-sidebar #top-nav:empty::before { + align-items: flex-start; + height: auto; + padding: 18px 14px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +html.nav-pre-sidebar.nav-pre-collapsed #top-nav:empty::before { + content: "O"; + justify-content: center; + padding-inline: 0; } #top-nav nav a { @@ -4515,7 +4544,10 @@ body.sb-ready #top-nav { } .dashboard-stats-content { + height: calc(var(--dashboard-stats-height) + 12px); min-height: calc(var(--dashboard-stats-height) + 12px); + contain: layout paint; + overflow: hidden; } .dashboard-stats-shell.is-collapsed .dashboard-stats-content, diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 9fceded..d6e704e 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -12,13 +12,19 @@ import { createAdaptiveNavController } from "./nav/layout.js"; import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js"; import { loadPluginNavItems } from "./nav/plugins-loader.js"; import { init as initCommandPalette } from "./command-palette.js"; +import { + installPageResourceTracker, + runWithoutPageTracking, + setupSoftNavigation, + startPageTracking, +} from "./soft-nav.js"; const host = document.getElementById("top-nav"); if (host) { const refs = mountNav(host); initCommandPalette(); import("./cert-banner.js").then(({ showCertBannerIfNeeded }) => { - showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host); + runWithoutPageTracking(() => showCertBannerIfNeeded(document.getElementById("sb-mobile-bar") || host)); }); const { applyAdaptiveNavLayout, navHide } = createAdaptiveNavController(host, refs); @@ -44,13 +50,18 @@ if (host) { "/purgatory": "enrollment-link", }; - loadPluginNavItems(activeMap).then(() => { - const activeId = activeMap[path]; + const applyActivePath = (nextPath = window.location.pathname) => { + host.querySelectorAll(".nav-active").forEach((el) => el.classList.remove("nav-active")); + host.querySelectorAll(".sb-group-btn[aria-expanded='true']").forEach((btn) => btn.setAttribute("aria-expanded", "false")); + host.querySelectorAll(".sb-group-children.sb-group-open").forEach((children) => children.classList.remove("sb-group-open")); + host.querySelectorAll(".sb-chevron.sb-chevron-open").forEach((chevron) => chevron.classList.remove("sb-chevron-open")); + refs.accountSettingsBtn?.classList.remove("ring-1", "ring-sky-500/60", "bg-slate-700"); + + const activeId = activeMap[nextPath]; if (activeId) { const el = document.getElementById(activeId); if (el) { el.classList.add("nav-active"); - // Also expand the parent sidebar group if applicable const group = el.closest(".sb-group"); if (group) { const btn = group.querySelector(".sb-group-btn"); @@ -62,6 +73,14 @@ if (host) { } } } + + if (nextPath === "/settings" && refs.accountSettingsBtn) { + refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); + } + }; + + loadPluginNavItems(activeMap).then(() => { + applyActivePath(path); }); if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) { @@ -91,14 +110,14 @@ if (host) { if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) { refs.accountSettingsBtn.dataset.boundSettings = "true"; refs.accountSettingsBtn.addEventListener("click", () => { - window.location.href = "/settings"; + if (window.overlordSoftNavigate) { + window.overlordSoftNavigate("/settings"); + } else { + window.location.href = "/settings"; + } }); } - if (path === "/settings" && refs.accountSettingsBtn) { - refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700"); - } - const updateToggle = () => { const enabled = getNotificationsEnabled(); if (refs.notifyToggle) { @@ -139,6 +158,32 @@ if (host) { } }); + const prefetchedPages = new Set(); + const prefetchPage = (href) => { + try { + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin) return; + if (url.pathname === window.location.pathname && url.search === window.location.search) return; + const key = `${url.pathname}${url.search}`; + if (prefetchedPages.has(key)) return; + prefetchedPages.add(key); + const link = document.createElement("link"); + link.rel = "prefetch"; + link.as = "document"; + link.href = key; + document.head.appendChild(link); + } catch {} + }; + + host.addEventListener("pointerenter", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }, true); + host.addEventListener("focusin", (event) => { + const link = event.target?.closest?.("a[href]"); + if (link) prefetchPage(link.getAttribute("href")); + }); + async function loadCurrentUser() { try { const res = await fetch("/api/auth/me", { credentials: "include" }); @@ -178,19 +223,25 @@ if (host) { } import("./chat-widget.js").then((chatWidget) => { - chatWidget.init(); + runWithoutPageTracking(() => { + chatWidget.init(); - if (chatWidget.isHidden() && refs.navUtility) { - const restoreBtn = document.createElement("button"); - restoreBtn.id = "chat-restore-btn"; - restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; - restoreBtn.title = "Show team chat"; - restoreBtn.innerHTML = 'Chat'; - restoreBtn.addEventListener("click", () => { - chatWidget.show(); - restoreBtn.remove(); - }); - refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); - } + if (chatWidget.isHidden() && refs.navUtility) { + const restoreBtn = document.createElement("button"); + restoreBtn.id = "chat-restore-btn"; + restoreBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 text-slate-500 hover:text-slate-300 hover:bg-slate-800 text-xs transition-colors"; + restoreBtn.title = "Show team chat"; + restoreBtn.innerHTML = 'Chat'; + restoreBtn.addEventListener("click", () => { + chatWidget.show(); + restoreBtn.remove(); + }); + refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild); + } + }); }); + + installPageResourceTracker(); + setupSoftNavigation(host, { onPathChange: applyActivePath }); + startPageTracking(); } diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index ce6c337..8871285 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -111,6 +111,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let clientOs = ""; let clientIsAdmin = false; let firewallWarningAcked = false; + let firstFrameLogged = false; + + function rdDebug(label, data = {}) { + try { + console.debug(`rd: ${label} ${JSON.stringify(data)}`); + } catch { + console.debug(`rd: ${label}`, data); + } + } function resetH264RuntimeState() { h264TimestampUs = 0; @@ -730,6 +739,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function handleStatus(msg) { if (!msg || msg.type !== "status" || !msg.status) return; + rdDebug("status", { + status: msg.status, + reason: msg.reason || "", + desiredStreaming, + streamState, + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + sessionId: msg.sessionId || "", + }); if (msg.status === "offline") { scheduleOffline(msg.reason); return; @@ -880,10 +897,26 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; return; } if (ws.readyState !== WebSocket.OPEN) { + rdDebug("send skipped websocket not open", { + type, + readyState: ws.readyState, + streamState, + desiredStreaming, + }); return; } const msg = { type, ...payload }; - console.debug("rd: send", msg); + rdDebug("send", { + msg, + readyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + transport: getWebrtcMode(), + lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : null, + }); ws.send(encodeMsgpack(msg)); } @@ -1021,6 +1054,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const res = await fetch("/api/clients"); const data = await res.json(); const client = data.items.find((c) => c.id === activeClientId); + rdDebug("client info", { + found: !!client, + monitors: client?.monitors, + monitorInfo: client?.monitorInfo, + os: client?.os || "", + isAdmin: !!client?.isAdmin, + }); if (client) { clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`; clientOs = (client.os || "").toLowerCase(); @@ -1115,6 +1155,19 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; startBtn.addEventListener("click", function () { const mode = getWebrtcMode(); + rdDebug("start click", { + mode, + wsReadyState: ws.readyState, + streamState, + desiredStreaming, + display: displaySelect?.value ?? "", + quality: qualitySlider?.value ?? "", + resolution: resolutionSelect?.value ?? "", + prefersH264, + duplication: !!duplicationCtrl?.checked, + clientOs, + clientIsAdmin, + }); if (needsWebrtcFirewallWarning(mode)) { if (!confirm("This agent is not elevated. Starting WebRTC will trigger a Windows Defender Firewall prompt on the target machine.\n\nContinue?")) { return; @@ -1132,6 +1185,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; pushResolution(); desiredStreaming = true; lastFrameAt = 0; + firstFrameLogged = false; resetH264SessionState(); setStreamState("starting", "Starting stream"); if (mode === "relayed") { @@ -1267,6 +1321,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function markFrameReceived() { lastFrameAt = performance.now(); clearOfflineTimer(); + if (!firstFrameLogged) { + firstFrameLogged = true; + rdDebug("first frame received", { + streamState, + desiredStreaming, + canvas: { width: canvas.width, height: canvas.height }, + transport: getWebrtcMode(), + }); + } if (streamState !== "streaming" && desiredStreaming) { setStreamState("streaming", "Streaming"); } @@ -1700,6 +1763,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); ws.addEventListener("open", function () { + rdDebug("ws open", { + url: ws.url, + readyState: ws.readyState, + clientId, + }); if (qualitySlider) { pushQuality(qualitySlider.value); } @@ -1718,7 +1786,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); }); - ws.addEventListener("close", function () { + ws.addEventListener("close", function (event) { + rdDebug("ws close", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + streamState, + desiredStreaming, + }); desiredStreaming = false; disconnectAudio(); destroyVideoDecoder(); @@ -1729,7 +1804,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; setStreamState("disconnected", "Disconnected"); }); - ws.addEventListener("error", function () { + ws.addEventListener("error", function (event) { + console.warn("rd: ws error", event); destroyVideoDecoder(); stopAllWebrtc(); setStreamState("error", "WebSocket error"); diff --git a/Overlord-Server/public/assets/soft-nav.js b/Overlord-Server/public/assets/soft-nav.js new file mode 100644 index 0000000..758b31c --- /dev/null +++ b/Overlord-Server/public/assets/soft-nav.js @@ -0,0 +1,357 @@ +let installedTracker = false; +let pageTrackingEnabled = false; +let navSeq = 0; + +const tracked = { + events: new Set(), + intervals: new Set(), + sockets: new Set(), + workers: new Set(), +}; + +const original = {}; +const loadedClassicScripts = new Set(); +const persistentIds = new Set([ + "top-nav", + "sb-mobile-bar", + "sb-backdrop", + "nav-reveal-btn", + "chat-bubble", + "chat-panel", + "cert-trust-banner", +]); + +function normalizeUrl(value, base = window.location.href) { + try { + return new URL(value, base).href; + } catch { + return value || ""; + } +} + +function scriptPath(value) { + try { + return new URL(value, window.location.href).pathname; + } catch { + return ""; + } +} + +function shouldSkipScript(script) { + const path = scriptPath(script.getAttribute("src") || ""); + return path === "/assets/nav.js" || path === "/assets/nav-prelude.js"; +} + +function isPersistentNode(node) { + return node instanceof Element && persistentIds.has(node.id); +} + +function cleanupTrackedResources() { + pageTrackingEnabled = false; + window.dispatchEvent(new Event("pagehide")); + + for (const id of tracked.intervals) original.clearInterval(id); + for (const entry of tracked.events) { + original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options); + } + for (const socket of tracked.sockets) { + try { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "soft navigation"); + } catch {} + } + for (const worker of tracked.workers) { + try { + worker.terminate(); + } catch {} + } + + tracked.events.clear(); + tracked.intervals.clear(); + tracked.sockets.clear(); + tracked.workers.clear(); + + document.querySelectorAll("script[data-soft-nav-script]").forEach((script) => script.remove()); + document.getElementById("cmdp-root")?.remove(); + pageTrackingEnabled = true; +} + +export function runWithoutPageTracking(fn) { + const previous = pageTrackingEnabled; + pageTrackingEnabled = false; + try { + const result = fn(); + if (result && typeof result.then === "function") { + return result.finally(() => { + pageTrackingEnabled = previous; + }); + } + pageTrackingEnabled = previous; + return result; + } catch (err) { + pageTrackingEnabled = previous; + throw err; + } +} + +export function startPageTracking() { + pageTrackingEnabled = true; +} + +export function installPageResourceTracker() { + if (installedTracker) return; + installedTracker = true; + + document.querySelectorAll("script[src]").forEach((script) => { + const src = script.getAttribute("src"); + if (src && script.type !== "module") loadedClassicScripts.add(normalizeUrl(src)); + }); + + original.addEventListener = EventTarget.prototype.addEventListener; + original.removeEventListener = EventTarget.prototype.removeEventListener; + original.setInterval = window.setInterval; + original.clearInterval = window.clearInterval; + original.WebSocket = window.WebSocket; + original.Worker = window.Worker; + + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (pageTrackingEnabled && listener) { + tracked.events.add({ target: this, type, listener, options }); + } + return original.addEventListener.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function (type, listener, options) { + for (const entry of tracked.events) { + if (entry.target === this && entry.type === type && entry.listener === listener) { + tracked.events.delete(entry); + } + } + return original.removeEventListener.call(this, type, listener, options); + }; + + window.setInterval = function (...args) { + const id = original.setInterval.apply(window, args); + if (pageTrackingEnabled) tracked.intervals.add(id); + return id; + }; + + window.clearInterval = function (id) { + tracked.intervals.delete(id); + return original.clearInterval.call(window, id); + }; + + if (typeof original.WebSocket === "function") { + window.WebSocket = function (...args) { + const socket = new original.WebSocket(...args); + if (pageTrackingEnabled) tracked.sockets.add(socket); + return socket; + }; + window.WebSocket.prototype = original.WebSocket.prototype; + Object.defineProperty(window.WebSocket, "CONNECTING", { value: original.WebSocket.CONNECTING }); + Object.defineProperty(window.WebSocket, "OPEN", { value: original.WebSocket.OPEN }); + Object.defineProperty(window.WebSocket, "CLOSING", { value: original.WebSocket.CLOSING }); + Object.defineProperty(window.WebSocket, "CLOSED", { value: original.WebSocket.CLOSED }); + } + + if (typeof original.Worker === "function") { + window.Worker = function (...args) { + const worker = new original.Worker(...args); + if (pageTrackingEnabled) tracked.workers.add(worker); + return worker; + }; + window.Worker.prototype = original.Worker.prototype; + } +} + +function mergeHead(nextDoc, baseUrl) { + document.title = nextDoc.title || document.title; + + const currentHtmlClasses = Array.from(document.documentElement.classList).filter((name) => ( + name.startsWith("nav-pre") || name === "dark" + )); + document.documentElement.className = nextDoc.documentElement.className || ""; + currentHtmlClasses.forEach((name) => document.documentElement.classList.add(name)); + + nextDoc.head.querySelectorAll("link").forEach((link) => { + const href = link.getAttribute("href"); + const rel = link.getAttribute("rel") || ""; + if (!href) return; + const normalized = normalizeUrl(href, baseUrl); + const exists = Array.from(document.head.querySelectorAll("link")).some((existing) => ( + (existing.getAttribute("rel") || "") === rel + && normalizeUrl(existing.getAttribute("href") || "") === normalized + )); + if (!exists) { + const clone = link.cloneNode(true); + clone.setAttribute("href", normalized); + document.head.appendChild(clone); + } + }); + + nextDoc.head.querySelectorAll("style").forEach((style) => { + const text = style.textContent || ""; + const exists = Array.from(document.head.querySelectorAll("style[data-soft-nav-style]")) + .some((existing) => existing.textContent === text); + if (!exists) { + const clone = style.cloneNode(true); + clone.dataset.softNavStyle = "true"; + document.head.appendChild(clone); + } + }); +} + +function replaceBody(nextDoc) { + const preserved = Array.from(document.body.children).filter(isPersistentNode); + const nav = document.getElementById("top-nav"); + const anchor = document.getElementById("cert-trust-banner") + || document.getElementById("sb-mobile-bar") + || nav; + const preservedClasses = Array.from(document.body.classList).filter((name) => ( + name.startsWith("sb-") || name === "nav-hidden" + )); + + Array.from(document.body.children).forEach((node) => { + if (!preserved.includes(node)) node.remove(); + }); + + document.body.className = nextDoc.body.className || ""; + preservedClasses.forEach((name) => document.body.classList.add(name)); + + let insertAfter = anchor; + Array.from(nextDoc.body.children).forEach((node) => { + if (node.matches?.("script") || node.id === "top-nav") return; + const clone = document.importNode(node, true); + if (insertAfter?.parentNode) { + insertAfter.parentNode.insertBefore(clone, insertAfter.nextSibling); + insertAfter = clone; + } else { + document.body.appendChild(clone); + insertAfter = clone; + } + }); +} + +function appendScript(script, baseUrl) { + if (shouldSkipScript(script)) return Promise.resolve(); + + const src = script.getAttribute("src"); + const type = script.getAttribute("type") || ""; + if (src && type !== "module") { + const normalized = normalizeUrl(src, baseUrl); + if (loadedClassicScripts.has(normalized)) return Promise.resolve(); + loadedClassicScripts.add(normalized); + } + + return new Promise((resolve, reject) => { + const next = document.createElement("script"); + for (const { name, value } of script.attributes) { + if (name !== "src") next.setAttribute(name, value); + } + next.dataset.softNavScript = "true"; + next.onload = () => resolve(); + next.onerror = () => reject(new Error(`Failed to load ${src || "inline script"}`)); + + if (src) { + const url = new URL(src, baseUrl); + if (type === "module" && url.origin === window.location.origin) { + url.searchParams.set("softNav", String(navSeq)); + } + next.src = url.href; + } else { + next.textContent = script.textContent || ""; + } + + document.body.appendChild(next); + if (!src) resolve(); + }); +} + +async function runPageScripts(nextDoc, baseUrl) { + const scripts = Array.from(nextDoc.querySelectorAll("script")); + for (const script of scripts) { + await appendScript(script, baseUrl); + } +} + +function refreshGlobalEnhancements() { + if (typeof window.addRippleEffect === "function") { + document + .querySelectorAll("button:not(.no-ripple), .button:not(.no-ripple)") + .forEach((button) => window.addRippleEffect(button)); + } +} + +function isSoftNavigationCandidate(url, link) { + if (url.origin !== window.location.origin) return false; + if (link?.target && link.target !== "_self") return false; + if (link?.hasAttribute("download")) return false; + if (url.pathname.startsWith("/api/")) return false; + if (url.pathname.includes(".")) return false; + if (url.pathname === window.location.pathname && url.search === window.location.search) return false; + return true; +} + +export function setupSoftNavigation(host, { onPathChange } = {}) { + if (!host || host.dataset.softNavReady === "true") return; + host.dataset.softNavReady = "true"; + + async function navigateTo(href, { replace = false } = {}) { + const url = new URL(href, window.location.href); + if (!isSoftNavigationCandidate(url)) { + window.location.href = url.href; + return; + } + + navSeq += 1; + document.body.classList.add("soft-nav-loading"); + + try { + const res = await fetch(url.href, { credentials: "include" }); + if (!res.ok) throw new Error(`Navigation failed: ${res.status}`); + const html = await res.text(); + const nextDoc = new DOMParser().parseFromString(html, "text/html"); + if (!nextDoc.getElementById("top-nav")) { + window.location.href = url.href; + return; + } + + cleanupTrackedResources(); + mergeHead(nextDoc, url.href); + replaceBody(nextDoc); + window.scrollTo(0, 0); + if (replace) history.replaceState({}, "", url.href); + else history.pushState({}, "", url.href); + onPathChange?.(url.pathname); + await runPageScripts(nextDoc, url.href); + refreshGlobalEnhancements(); + window.dispatchEvent(new CustomEvent("overlord:soft-nav", { detail: { path: url.pathname } })); + } catch (err) { + console.error("Soft navigation failed:", err); + window.location.href = url.href; + } finally { + document.body.classList.remove("soft-nav-loading"); + } + } + + host.addEventListener("click", (event) => { + const link = event.target?.closest?.("a[href]"); + if (!link) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + + const url = new URL(link.getAttribute("href"), window.location.href); + if (!isSoftNavigationCandidate(url, link)) return; + + event.preventDefault(); + navigateTo(url.href); + }, true); + + window.addEventListener("popstate", () => { + navigateTo(window.location.href, { replace: true }); + }); + + window.overlordSoftNavigate = navigateTo; +} diff --git a/Overlord-Server/src/client-order.test.ts b/Overlord-Server/src/client-order.test.ts index c5007cf..2c89f32 100644 --- a/Overlord-Server/src/client-order.test.ts +++ b/Overlord-Server/src/client-order.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; -import { deleteClientRow, listClients, setClientBookmark, upsertClientRow } from "./db"; +import { deleteClientRow, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db"; const createdClientIds: string[] = []; @@ -110,4 +110,33 @@ describe("client list ordering", () => { cleanupCreatedClients(); } }); -}); \ No newline at end of file + + test("search tolerates typos across client metadata", () => { + try { + const prefix = `fuse-${Date.now().toString(36)}`; + const id = `${prefix}-finance-terminal`; + + createTempClient(id, { + online: true, + lastSeen: Date.now(), + host: "finance-terminal", + }); + setClientTag(id, "Payroll Workstation", "Quarterly reporting machine"); + + const result = listClients({ + page: 1, + pageSize: 12, + search: "payrol workstaton", + sort: "last_seen_desc", + statusFilter: "all", + osFilter: "all", + countryFilter: "all", + enrollmentFilter: "all", + }); + + expect(result.items.some((item) => item.id === id)).toBe(true); + } finally { + cleanupCreatedClients(); + } + }); +}); diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 37901b4..a4d9550 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -1,4 +1,5 @@ import type { ClientInfo, ListFilters, ListResult, ClientRole } from "../types"; +import Fuse from "fuse.js"; import { getThumbnailSummaries } from "../thumbnails"; import { db, dbPath } from "./connection"; import "./schema"; @@ -770,6 +771,119 @@ export function computeClientSuspiciousFlags( return flags; } +const CLIENT_SEARCH_KEYS = [ + { name: "nickname", weight: 0.32 }, + { name: "host", weight: 0.28 }, + { name: "user", weight: 0.18 }, + { name: "id", weight: 0.18 }, + { name: "customTag", weight: 0.2 }, + { name: "customTagNote", weight: 0.1 }, + { name: "groupName", weight: 0.14 }, + { name: "os", weight: 0.12 }, + { name: "ip", weight: 0.1 }, + { name: "hwid", weight: 0.08 }, + { name: "country", weight: 0.06 }, + { name: "version", weight: 0.06 }, + { name: "buildTag", weight: 0.06 }, + { name: "cpu", weight: 0.05 }, + { name: "gpu", weight: 0.05 }, + { name: "ram", weight: 0.04 }, +] as const; + +const CLIENT_SEARCH_SQL_COLUMNS = [ + "COALESCE(c.nickname,'')", + "COALESCE(c.host,'')", + "COALESCE(c.user,'')", + "c.id", + "COALESCE(c.custom_tag,'')", + "COALESCE(c.custom_tag_note,'')", + "COALESCE(g.name,'')", + "COALESCE(c.os,'')", + "COALESCE(c.ip,'')", + "COALESCE(c.hwid,'')", + "COALESCE(c.country,'')", + "COALESCE(c.version,'')", + "COALESCE(c.build_tag,'')", + "COALESCE(c.cpu,'')", + "COALESCE(c.gpu,'')", + "COALESCE(c.ram,'')", +] as const; + +function escapeLikeTerm(term: string): string { + return term.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function buildFuzzyCandidateWhere(search: string): { sql: string; params: string[] } { + const query = search.trim().toLowerCase(); + if (!query) return { sql: "", params: [] }; + + const terms = Array.from(new Set([ + query, + ...query.split(/[\s,;:|/\\]+/), + ].map((term) => term.trim()).filter((term) => term.length >= 2))).slice(0, 8); + + if (!terms.length) return { sql: "", params: [] }; + + const fieldClause = CLIENT_SEARCH_SQL_COLUMNS + .map((column) => `LOWER(${column}) LIKE ? ESCAPE '\\'`) + .join(" OR "); + const sql = terms.map(() => `(${fieldClause})`).join(" OR "); + const params = terms.flatMap((term) => { + const like = `%${escapeLikeTerm(term)}%`; + return CLIENT_SEARCH_SQL_COLUMNS.map(() => like); + }); + + return { sql: `(${sql})`, params }; +} + +function buildClientSearchFtsQuery(search: string): string { + const terms = Array.from(new Set( + search + .trim() + .split(/[\s,;:|/\\]+/) + .map((term) => term.replace(/["*]/g, "").trim()) + .filter((term) => term.length >= 2), + )).slice(0, 8); + + return terms.map((term) => `"${term}"*`).join(" OR "); +} + +function getFtsClientSearchCandidateIds(search: string, limit: number): string[] | null { + const ftsQuery = buildClientSearchFtsQuery(search); + if (!ftsQuery) return []; + + try { + return db + .query<{ id: string }>( + `SELECT id + FROM client_search_fts + WHERE client_search_fts MATCH ? + ORDER BY rank + LIMIT ?`, + ) + .all(ftsQuery, limit) + .map((row) => row.id) + .filter(Boolean); + } catch { + return null; + } +} + +function fuzzySearchClientRows(rows: any[], search: string): any[] { + const query = search.trim(); + if (!query) return rows; + + const fuse = new Fuse(rows, { + keys: CLIENT_SEARCH_KEYS as any, + threshold: 0.36, + distance: 120, + ignoreLocation: true, + shouldSort: true, + }); + + return fuse.search(query).map((result) => result.item); +} + export function listClients(filters: ListFilters): ListResult { const { page, @@ -789,14 +903,6 @@ export function listClients(filters: ListFilters): ListResult { const where: string[] = []; const params: any[] = []; - if (search) { - where.push( - "(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)", - ); - const needle = `%${search}%`; - params.push(needle, needle, needle, needle, needle, needle, needle); - } - if (statusFilter === "online") { where.push("c.online=1"); } else if (statusFilter === "offline") { @@ -859,11 +965,9 @@ export function listClients(filters: ListFilters): ListResult { const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const needsGroupJoinForFilter = - !!search || sort === "group_asc" || sort === "group_desc" || (groupFilter !== undefined && groupFilter !== "all"); - const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : ""; const orderBy = (() => { const online = "c.online DESC"; @@ -894,49 +998,99 @@ export function listClients(filters: ListFilters): ListResult { } })(); - const totalRow = db - .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`) - .get(...params) ?? { c: 0 }; - const onlineRow = db - .query<{ c: number }>( - `SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`, - ) - .get(...params) ?? { c: 0 }; const offset = (page - 1) * pageSize; const clientFields = "c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason"; - const rows = needsGroupJoinForFilter - ? db - .query( - `SELECT ${clientFields}, g.name as groupName, g.color as groupColor - FROM clients c - LEFT JOIN client_groups g ON g.id = c.group_id - ${whereSql} - ${orderBy} - LIMIT ? OFFSET ?`, - ) - .all(...params, pageSize, offset) - : db - .query( - `WITH page_clients AS ( - SELECT ${clientFields} + let rows: any[] | undefined; + let totalCount = 0; + let onlineCount = 0; + + if (search) { + const searchWhere = [...where]; + const searchParams = [...params]; + const candidateLimit = Math.min(5000, Math.max(1000, offset + pageSize * 8)); + const ftsCandidateIds = getFtsClientSearchCandidateIds(search, candidateLimit); + + if (ftsCandidateIds) { + if (!ftsCandidateIds.length) { + rows = []; + totalCount = 0; + onlineCount = 0; + } else { + searchWhere.push(`c.id IN (${ftsCandidateIds.map(() => "?").join(",")})`); + searchParams.push(...ftsCandidateIds); + } + } else { + const searchCandidate = buildFuzzyCandidateWhere(search); + if (searchCandidate.sql) { + searchWhere.push(searchCandidate.sql); + searchParams.push(...searchCandidate.params); + } + } + + if (rows === undefined) { + const searchWhereSql = searchWhere.length ? `WHERE ${searchWhere.join(" AND ")}` : ""; + const candidates = db + .query( + `SELECT ${clientFields}, g.name as groupName, g.color as groupColor FROM clients c + LEFT JOIN client_groups g ON g.id = c.group_id + ${searchWhereSql} + ${orderBy}`, + ) + .all(...searchParams); + const matches = fuzzySearchClientRows(candidates, search); + totalCount = matches.length; + onlineCount = matches.filter((row: any) => row.online === 1).length; + rows = matches.slice(offset, offset + pageSize); + } + } else { + const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : ""; + const totalRow = db + .query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`) + .get(...params) ?? { c: 0 }; + const onlineRow = db + .query<{ c: number }>( + `SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`, + ) + .get(...params) ?? { c: 0 }; + totalCount = totalRow.c; + onlineCount = onlineRow.c; + + rows = needsGroupJoinForFilter + ? db + .query( + `SELECT ${clientFields}, g.name as groupName, g.color as groupColor + FROM clients c + LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql} ${orderBy} - LIMIT ? OFFSET ? - ) - SELECT pc.*, g.name as groupName, g.color as groupColor - FROM page_clients pc - LEFT JOIN client_groups g ON g.id = pc.groupId`, - ) - .all(...params, pageSize, offset); + LIMIT ? OFFSET ?`, + ) + .all(...params, pageSize, offset) + : db + .query( + `WITH page_clients AS ( + SELECT ${clientFields} + FROM clients c + ${whereSql} + ${orderBy} + LIMIT ? OFFSET ? + ) + SELECT pc.*, g.name as groupName, g.color as groupColor + FROM page_clients pc + LEFT JOIN client_groups g ON g.id = pc.groupId`, + ) + .all(...params, pageSize, offset); + } - const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS; + const shouldScanSuspicious = !search && (SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalCount <= SUSPICIOUS_SCAN_MAX_CLIENTS); const suspiciousSets = shouldScanSuspicious ? getFloodSetsWithCache() : { floodedHwids: new Set(), floodedHardware: new Set(), floodedIps: new Set() }; + rows = rows || []; const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id)); const items = rows.map((c: any) => { @@ -986,7 +1140,7 @@ export function listClients(filters: ListFilters): ListResult { }; }); - return { page, pageSize, total: totalRow.c, online: onlineRow.c, items }; + return { page, pageSize, total: totalCount, online: onlineCount, items }; } export type ClientMetricsSummary = { @@ -998,7 +1152,56 @@ export type ClientMetricsSummary = { byCountryOnline: Record; }; +const CLIENT_METRICS_SUMMARY_TTL_MS = 4_000; +let clientMetricsSummaryCache: { expiresAt: number; summary: ClientMetricsSummary } | null = null; +const userClientMetricsSummaryCache = new Map(); + +function cloneClientMetricsSummary(summary: ClientMetricsSummary): ClientMetricsSummary { + return { + total: summary.total, + online: summary.online, + byOS: { ...summary.byOS }, + byCountry: { ...summary.byCountry }, + byOSOnline: { ...summary.byOSOnline }, + byCountryOnline: { ...summary.byCountryOnline }, + }; +} + +function makeClientMetricsSummary( + counts: { total: number | null; online: number | null }, + osRows: { key: string; total: number; online: number | null }[], + countryRows: { key: string; total: number; online: number | null }[], +): ClientMetricsSummary { + const byOS: Record = {}; + const byOSOnline: Record = {}; + for (const row of osRows) { + byOS[row.key] = Number(row.total) || 0; + byOSOnline[row.key] = Number(row.online) || 0; + } + + const byCountry: Record = {}; + const byCountryOnline: Record = {}; + for (const row of countryRows) { + byCountry[row.key] = Number(row.total) || 0; + byCountryOnline[row.key] = Number(row.online) || 0; + } + + return { + total: Number(counts.total) || 0, + online: Number(counts.online) || 0, + byOS, + byCountry, + byOSOnline, + byCountryOnline, + }; +} + export function getClientMetricsSummary(): ClientMetricsSummary { + const now = Date.now(); + if (clientMetricsSummaryCache && clientMetricsSummaryCache.expiresAt > now) { + return cloneClientMetricsSummary(clientMetricsSummaryCache.summary); + } + const counts = db .query<{ total: number; online: number }>( `SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients`, @@ -1027,31 +1230,21 @@ export function getClientMetricsSummary(): ClientMetricsSummary { ) .all(); - const byOS: Record = {}; - const byOSOnline: Record = {}; - for (const row of osRows) { - byOS[row.key] = Number(row.total) || 0; - byOSOnline[row.key] = Number(row.online) || 0; - } - - const byCountry: Record = {}; - const byCountryOnline: Record = {}; - for (const row of countryRows) { - byCountry[row.key] = Number(row.total) || 0; - byCountryOnline[row.key] = Number(row.online) || 0; - } - - return { - total: Number(counts.total) || 0, - online: Number(counts.online) || 0, - byOS, - byCountry, - byOSOnline, - byCountryOnline, + const summary = makeClientMetricsSummary(counts, osRows, countryRows); + clientMetricsSummaryCache = { + expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS, + summary, }; + return cloneClientMetricsSummary(summary); } export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary { + const now = Date.now(); + const cached = userClientMetricsSummaryCache.get(userId); + if (cached && cached.expiresAt > now) { + return cloneClientMetricsSummary(cached.summary); + } + const filter = `WHERE built_by_user_id = ?`; const counts = db @@ -1082,28 +1275,12 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum ) .all(userId); - const byOS: Record = {}; - const byOSOnline: Record = {}; - for (const row of osRows) { - byOS[row.key] = Number(row.total) || 0; - byOSOnline[row.key] = Number(row.online) || 0; - } - - const byCountry: Record = {}; - const byCountryOnline: Record = {}; - for (const row of countryRows) { - byCountry[row.key] = Number(row.total) || 0; - byCountryOnline[row.key] = Number(row.online) || 0; - } - - return { - total: Number(counts.total) || 0, - online: Number(counts.online) || 0, - byOS, - byCountry, - byOSOnline, - byCountryOnline, - }; + const summary = makeClientMetricsSummary(counts, osRows, countryRows); + userClientMetricsSummaryCache.set(userId, { + expiresAt: now + CLIENT_METRICS_SUMMARY_TTL_MS, + summary, + }); + return cloneClientMetricsSummary(summary); } export function getOnlineClientCountForUser(userId: number): number { diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 9cbc316..ec61773 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -148,6 +148,82 @@ try { } catch {} db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`); +try { + db.run(` + CREATE VIRTUAL TABLE IF NOT EXISTS client_search_fts USING fts5( + id UNINDEXED, + host, + user, + nickname, + custom_tag, + custom_tag_note, + os, + ip, + hwid, + country, + version, + build_tag, + cpu, + gpu, + ram, + tokenize = 'unicode61' + ); + `); + + const counts = db + .query<{ clients: number; indexed: number }>( + `SELECT + (SELECT COUNT(*) FROM clients) as clients, + (SELECT COUNT(*) FROM client_search_fts) as indexed`, + ) + .get() ?? { clients: 0, indexed: 0 }; + + if (counts.clients !== counts.indexed) { + db.run(`DELETE FROM client_search_fts`); + db.run(` + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + SELECT + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + FROM clients + `); + } + + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_ai AFTER INSERT ON clients BEGIN + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + VALUES ( + new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note, + new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram + ); + END; + `); + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_ad AFTER DELETE ON clients BEGIN + DELETE FROM client_search_fts WHERE rowid = old.rowid; + END; + `); + db.run(` + CREATE TRIGGER IF NOT EXISTS clients_search_au AFTER UPDATE ON clients BEGIN + DELETE FROM client_search_fts WHERE rowid = old.rowid; + INSERT INTO client_search_fts( + rowid, id, host, user, nickname, custom_tag, custom_tag_note, + os, ip, hwid, country, version, build_tag, cpu, gpu, ram + ) + VALUES ( + new.rowid, new.id, new.host, new.user, new.nickname, new.custom_tag, new.custom_tag_note, + new.os, new.ip, new.hwid, new.country, new.version, new.build_tag, new.cpu, new.gpu, new.ram + ); + END; + `); +} catch {} + db.run(` CREATE TABLE IF NOT EXISTS banned_ips ( ip TEXT PRIMARY KEY, diff --git a/Overlord-Server/src/server/http-security.test.ts b/Overlord-Server/src/server/http-security.test.ts index dccc516..7080ba1 100644 --- a/Overlord-Server/src/server/http-security.test.ts +++ b/Overlord-Server/src/server/http-security.test.ts @@ -21,6 +21,7 @@ describe("SECURITY_HEADERS", () => { const csp = SECURITY_HEADERS["Content-Security-Policy"]; expect(csp).toContain("default-src 'self'"); expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("font-src 'self' data:"); expect(csp).toContain("frame-ancestors 'none'"); }); diff --git a/Overlord-Server/src/server/http-security.ts b/Overlord-Server/src/server/http-security.ts index 202135b..bdaaf4a 100644 --- a/Overlord-Server/src/server/http-security.ts +++ b/Overlord-Server/src/server/http-security.ts @@ -5,7 +5,7 @@ export const CORS_HEADERS = { }; export const SECURITY_HEADERS = { - "Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self'; connect-src 'self'; object-src blob:; frame-ancestors 'none'", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.discordapp.com https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self'; object-src blob:; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block", diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index 1f6017e..1098477 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -160,6 +160,7 @@ function broadcastFrameToViewers( } const rdSendStats = { lastLog: 0, frames: 0, sendMs: 0, bytes: 0 }; +const rdDebugFrameLogAt = new Map(); export const rdStreamingState = new Map, commandId: string) { - if (!target) return false; + if (!target) { + logger.warn(`[rd-debug] send command skipped, target missing command=${commandType} id=${commandId}`); + return false; + } try { - logger.debug(`[rd] send command ${commandType} -> ${target.id}`); + logger.debug(`[rd-debug] send command command=${commandType} client=${target.id} id=${commandId} payload=${JSON.stringify(payload || {})}`); target.ws.send(encodeMessage({ type: "command", commandType: commandType as any, payload, id: commandId })); metrics.recordCommand(commandType); return true; @@ -326,6 +330,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`); switch (payload.type) { case "desktop_start": + logger.debug(`[rd-debug] desktop_start requested client=${clientId} session=${ws.data.sessionId || ""} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length} webrtc=${(payload as any).webrtc === true}`); if (!state.isStreaming) { if (target.os === "darwin" && target.permissions) { const missing: string[] = []; @@ -363,14 +368,15 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket sendDesktopCommand(target, "desktop_start", {}); state.isStreaming = true; rdStreamingState.set(clientId, state); - logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`); + logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`); } else { - logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`); + logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`); } break; case "desktop_stop": { const otherViewers = sessionManager.getRdSessionsForClient(clientId) .filter(s => s.id !== ws.data.sessionId); + logger.debug(`[rd-debug] desktop_stop requested client=${clientId} session=${ws.data.sessionId || ""} otherViewers=${otherViewers.length} state=${JSON.stringify(state)}`); if (otherViewers.length === 0) { stopRemoteDesktopRecording(clientId, "desktop stopped"); sendDesktopCommand(target, "desktop_stop", {}); @@ -648,6 +654,12 @@ function handleRemoteDesktopFrame(payload: any) { const frameFps = Number(header?.fps) || 0; if (frameFps > 0) state.lastFps = frameFps; rdStreamingState.set(clientId, state); + const now = Date.now(); + const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0; + if (!lastDebugFrameLog || now - lastDebugFrameLog >= 2000) { + rdDebugFrameLogAt.set(clientId, now); + logger.debug(`[rd-debug] frame received client=${clientId} bytes=${bytes?.byteLength || 0} fps=${frameFps || 0} viewers=${sessionManager.getRdSessionsForClient(clientId).length} header=${JSON.stringify(header || {})}`); + } broadcastRemoteDesktopFrame(clientId, bytes, header); } @@ -660,7 +672,9 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header } recordRemoteDesktopFrame(clientId, header, bytes); const buf = buildViewerFrameBuffer(bytes, header); - return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header); + const sessions = sessionManager.getRdSessionsForClient(clientId); + const sent = broadcastFrameToViewers(sessions, buf, header); + return sent; } (globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {