diff --git a/Overlord-Server/public/assets/chat-client.js b/Overlord-Server/public/assets/chat-client.js new file mode 100644 index 0000000..f9919ab --- /dev/null +++ b/Overlord-Server/public/assets/chat-client.js @@ -0,0 +1,179 @@ +const UNREAD_KEY = "overlord_chat_unread"; +const MUTE_KEY = "overlord_chat_mute"; + +let ws = null; +let canWrite = false; +let currentUserId = 0; +let messageHandlers = new Set(); +let unreadHandlers = new Set(); +let statusHandlers = new Set(); +let readyHandlers = new Set(); + +if (localStorage.getItem(UNREAD_KEY) === null) { + localStorage.setItem(UNREAD_KEY, "0"); +} +if (localStorage.getItem(MUTE_KEY) === null) { + localStorage.setItem(MUTE_KEY, "none"); +} + +function emitStatus(status) { + for (const handler of statusHandlers) { + try { handler(status); } catch {} + } +} + +function emitReady(history, writable, userId) { + canWrite = writable; + currentUserId = userId || 0; + for (const handler of readyHandlers) { + try { handler(history, writable); } catch {} + } +} + +function emitMessage(msg) { + for (const handler of messageHandlers) { + try { handler(msg); } catch {} + } +} + +export function getUnreadCount() { + return Number(localStorage.getItem(UNREAD_KEY) || "0"); +} + +export function setUnreadCount(value) { + const next = Math.max(0, Number(value) || 0); + localStorage.setItem(UNREAD_KEY, String(next)); + for (const handler of unreadHandlers) { + try { handler(next); } catch {} + } +} + +export function incrementUnread() { + setUnreadCount(getUnreadCount() + 1); +} + +export function resetUnread() { + setUnreadCount(0); +} + +export function getMuteMode() { + return localStorage.getItem(MUTE_KEY) || "none"; +} + +export function setMuteMode(mode) { + const valid = ["none", "mute", "mute_collapse", "hide"]; + if (!valid.includes(mode)) return; + localStorage.setItem(MUTE_KEY, mode); +} + +let audioCtx = null; + +function playNotificationSound() { + const mute = getMuteMode(); + if (mute !== "none") return; + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.type = "sine"; + osc.frequency.setValueAtTime(880, audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(440, audioCtx.currentTime + 0.15); + gain.gain.setValueAtTime(0.08, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.2); + osc.start(audioCtx.currentTime); + osc.stop(audioCtx.currentTime + 0.2); + } catch {} +} + +export function onMessage(cb) { messageHandlers.add(cb); return () => messageHandlers.delete(cb); } +export function onUnreadChanged(cb) { unreadHandlers.add(cb); return () => unreadHandlers.delete(cb); } +export function onStatusChanged(cb) { statusHandlers.add(cb); return () => statusHandlers.delete(cb); } +export function onReady(cb) { readyHandlers.add(cb); return () => readyHandlers.delete(cb); } + +export function getCanWrite() { return canWrite; } + +export function sendMessage(text) { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + if (!canWrite) return false; + const trimmed = (text || "").trim(); + if (!trimmed || trimmed.length > 2000) return false; + ws.send(JSON.stringify({ type: "chat_send", message: trimmed })); + return true; +} + +export async function fetchHistory(before, limit = 50) { + const params = new URLSearchParams(); + if (before) params.set("before", String(before)); + params.set("limit", String(limit)); + try { + const res = await fetch(`/api/chat/history?${params.toString()}`); + if (!res.ok) return { messages: [], canWrite: false }; + return await res.json(); + } catch { + return { messages: [], canWrite: false }; + } +} + +function handleWsMessage(payload) { + if (!payload || typeof payload.type !== "string") return; + + if (payload.type === "chat_ready") { + emitReady(payload.history || [], !!payload.canWrite, payload.userId); + return; + } + + if (payload.type === "chat_message") { + emitMessage(payload); + const isOwnMessage = currentUserId && payload.userId === currentUserId; + if (!isOwnMessage) { + const mute = getMuteMode(); + if (mute === "none") { + incrementUnread(); + playNotificationSound(); + } + } + return; + } + + if (payload.type === "chat_error") { + console.warn("[chat] server error:", payload.error); + } +} + +function connect() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/api/chat/ws`; + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + emitStatus("connected"); + }; + + ws.onmessage = (event) => { + if (typeof event.data === "string") { + try { + handleWsMessage(JSON.parse(event.data)); + } catch {} + return; + } + }; + + ws.onerror = () => { + emitStatus("error"); + }; + + ws.onclose = () => { + emitStatus("disconnected"); + setTimeout(connect, 3000); + }; +} + +let started = false; + +export function start() { + if (started) return; + started = true; + connect(); +} diff --git a/Overlord-Server/public/assets/chat-widget.js b/Overlord-Server/public/assets/chat-widget.js new file mode 100644 index 0000000..5ef025f --- /dev/null +++ b/Overlord-Server/public/assets/chat-widget.js @@ -0,0 +1,389 @@ +import * as chatClient from "./chat-client.js"; + +let panelOpen = false; +let initialized = false; +let bubble = null; +let panel = null; +let messageList = null; +let inputArea = null; +let sendBtn = null; +let muteDropdown = null; +let muteDropdownMenu = null; +let muteDropdownLabel = null; +let oldestTimestamp = null; +let loadingMore = false; +let hasMore = true; + +const ROLE_COLORS = { + admin: "bg-rose-500/80 text-white", + operator: "bg-sky-500/80 text-white", + viewer: "bg-zinc-500/80 text-white", +}; + +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function formatTime(ts) { + const d = new Date(ts); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function createMessageEl(msg) { + const wrapper = document.createElement("div"); + wrapper.className = "flex flex-col gap-0.5 px-3 py-1.5 hover:bg-zinc-800/40 transition-colors"; + wrapper.dataset.msgId = msg.id; + wrapper.dataset.ts = msg.createdAt; + + const header = document.createElement("div"); + header.className = "flex items-center gap-2 text-xs"; + + const badge = document.createElement("span"); + badge.className = `px-1.5 py-0.5 rounded text-[10px] font-medium ${ROLE_COLORS[msg.userRole] || ROLE_COLORS.viewer}`; + badge.textContent = msg.userRole; + + const name = document.createElement("span"); + name.className = "font-semibold text-slate-200"; + name.textContent = msg.username; + + const onlineCount = typeof msg.onlineClients === "number" ? msg.onlineClients : 0; + const clientsBadge = document.createElement("span"); + clientsBadge.className = "px-1.5 py-0.5 rounded text-[10px] font-medium " + + (onlineCount > 0 ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" : "bg-zinc-700/50 text-zinc-500"); + clientsBadge.textContent = onlineCount > 0 ? `${onlineCount} online` : "0 online"; + + const time = document.createElement("span"); + time.className = "text-slate-500 ml-auto whitespace-nowrap"; + time.textContent = formatTime(msg.createdAt); + + header.appendChild(badge); + header.appendChild(name); + header.appendChild(clientsBadge); + header.appendChild(time); + + const body = document.createElement("div"); + body.className = "text-sm text-slate-300 break-words whitespace-pre-wrap pl-0.5"; + body.textContent = msg.message; + + wrapper.appendChild(header); + wrapper.appendChild(body); + return wrapper; +} + +function scrollToBottom() { + if (messageList) { + messageList.scrollTop = messageList.scrollHeight; + } +} + +function updateBubbleBadge() { + if (!bubble) return; + const count = chatClient.getUnreadCount(); + const badge = bubble.querySelector("[data-chat-badge]"); + if (!badge) return; + if (count > 0) { + badge.textContent = count > 99 ? "99+" : String(count); + badge.classList.remove("hidden"); + } else { + badge.classList.add("hidden"); + } +} + +function updateMuteVisibility() { + const mute = chatClient.getMuteMode(); + if (!bubble || !panel) return; + + if (mute === "hide") { + bubble.classList.add("hidden"); + panel.classList.add("hidden"); + panelOpen = false; + } else { + bubble.classList.remove("hidden"); + } + + if (mute === "mute_collapse" && panelOpen) { + // auto collapse handled on new messages + } +} + +function togglePanel() { + if (!panel) return; + panelOpen = !panelOpen; + if (panelOpen) { + panel.classList.remove("hidden"); + panel.style.opacity = "0"; + panel.style.transform = "translateY(10px) scale(0.98)"; + requestAnimationFrame(() => { + panel.style.transition = "opacity 0.15s ease, transform 0.15s ease"; + panel.style.opacity = "1"; + panel.style.transform = "translateY(0) scale(1)"; + }); + chatClient.resetUnread(); + updateBubbleBadge(); + scrollToBottom(); + if (inputArea) inputArea.focus(); + } else { + panel.style.transition = "opacity 0.1s ease, transform 0.1s ease"; + panel.style.opacity = "0"; + panel.style.transform = "translateY(10px) scale(0.98)"; + setTimeout(() => { + if (!panelOpen) panel.classList.add("hidden"); + }, 100); + } +} + +async function loadMoreHistory() { + if (loadingMore || !hasMore || !messageList) return; + loadingMore = true; + + const result = await chatClient.fetchHistory(oldestTimestamp, 50); + const msgs = result.messages || []; + if (msgs.length === 0) { + hasMore = false; + loadingMore = false; + return; + } + + const prevHeight = messageList.scrollHeight; + const prevTop = messageList.scrollTop; + + const frag = document.createDocumentFragment(); + for (const msg of msgs) { + frag.appendChild(createMessageEl(msg)); + if (!oldestTimestamp || msg.createdAt < oldestTimestamp) { + oldestTimestamp = msg.createdAt; + } + } + + messageList.insertBefore(frag, messageList.firstChild); + + const newHeight = messageList.scrollHeight; + messageList.scrollTop = prevTop + (newHeight - prevHeight); + + if (msgs.length < 50) hasMore = false; + loadingMore = false; +} + +function appendMessage(msg) { + if (!messageList) return; + const wasAtBottom = messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight < 40; + messageList.appendChild(createMessageEl(msg)); + if (!oldestTimestamp || msg.createdAt < oldestTimestamp) { + oldestTimestamp = msg.createdAt; + } + if (wasAtBottom) scrollToBottom(); +} + +function handleSend() { + if (!inputArea) return; + const text = inputArea.value; + if (chatClient.sendMessage(text)) { + inputArea.value = ""; + inputArea.style.height = "auto"; + } +} + +function createWidget() { + bubble = document.createElement("div"); + bubble.id = "chat-bubble"; + bubble.className = "fixed bottom-5 right-5 z-[9998] cursor-pointer select-none"; + bubble.innerHTML = ` +
+ + +
+ `; + bubble.addEventListener("click", togglePanel); + + panel = document.createElement("div"); + panel.id = "chat-panel"; + panel.className = "hidden fixed bottom-[4.5rem] right-5 z-[9998] w-[360px] max-h-[500px] flex flex-col rounded-xl border border-zinc-700/70 bg-zinc-900/95 backdrop-blur-md shadow-2xl shadow-black/40 overflow-hidden"; + panel.innerHTML = ` +
+
+ + Team Chat +
+
+
+ + +
+ +
+
+
+ + + `; + + document.body.appendChild(bubble); + document.body.appendChild(panel); + + messageList = panel.querySelector("#chat-messages"); + inputArea = panel.querySelector("#chat-input"); + sendBtn = panel.querySelector("#chat-send-btn"); + muteDropdown = panel.querySelector("#chat-mute-dropdown"); + muteDropdownMenu = panel.querySelector("#chat-mute-menu"); + muteDropdownLabel = panel.querySelector("#chat-mute-label"); + + const closeBtn = panel.querySelector("#chat-close-btn"); + const inputContainer = panel.querySelector("#chat-input-area"); + const readonlyBar = panel.querySelector("#chat-readonly-bar"); + + const MUTE_LABELS = { + none: "All notifications", + mute: "Mute", + mute_collapse: "Mute + Collapse", + hide: "Hide", + }; + + muteDropdownLabel.textContent = MUTE_LABELS[chatClient.getMuteMode()] || MUTE_LABELS.none; + + panel.querySelector("#chat-mute-btn").addEventListener("click", (e) => { + e.stopPropagation(); + muteDropdownMenu.classList.toggle("hidden"); + }); + + muteDropdownMenu.querySelectorAll(".chat-mute-option").forEach((opt) => { + opt.addEventListener("click", () => { + const value = opt.dataset.value; + chatClient.setMuteMode(value); + muteDropdownLabel.textContent = MUTE_LABELS[value] || MUTE_LABELS.none; + muteDropdownMenu.classList.add("hidden"); + updateMuteVisibility(); + }); + }); + + document.addEventListener("click", () => { + if (muteDropdownMenu && !muteDropdownMenu.classList.contains("hidden")) { + muteDropdownMenu.classList.add("hidden"); + } + }); + + closeBtn.addEventListener("click", () => { + if (panelOpen) togglePanel(); + }); + + sendBtn.addEventListener("click", handleSend); + + inputArea.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }); + + inputArea.addEventListener("input", () => { + inputArea.style.height = "auto"; + inputArea.style.height = Math.min(inputArea.scrollHeight, 100) + "px"; + }); + + messageList.addEventListener("scroll", () => { + if (messageList.scrollTop < 40 && hasMore && !loadingMore) { + loadMoreHistory(); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && panelOpen) { + togglePanel(); + } + }); + + document.addEventListener("mousedown", (e) => { + if (panelOpen && !panel.contains(e.target) && !bubble.contains(e.target)) { + togglePanel(); + } + }); + + chatClient.onReady((history, writable) => { + messageList.innerHTML = ""; + oldestTimestamp = null; + hasMore = true; + + for (const msg of history) { + messageList.appendChild(createMessageEl(msg)); + if (!oldestTimestamp || msg.createdAt < oldestTimestamp) { + oldestTimestamp = msg.createdAt; + } + } + scrollToBottom(); + + if (writable) { + inputContainer.classList.remove("hidden"); + readonlyBar.classList.add("hidden"); + } else { + inputContainer.classList.add("hidden"); + readonlyBar.classList.remove("hidden"); + } + }); + + chatClient.onMessage((msg) => { + appendMessage(msg); + const mute = chatClient.getMuteMode(); + if (panelOpen) { + chatClient.resetUnread(); + updateBubbleBadge(); + } + if (mute === "mute_collapse" && panelOpen) { + togglePanel(); + } + }); + + chatClient.onUnreadChanged(() => { + updateBubbleBadge(); + }); + + updateBubbleBadge(); + updateMuteVisibility(); + + chatClient.start(); +} + +export function init() { + if (initialized) return; + initialized = true; + createWidget(); +} + +export function show() { + if (chatClient.getMuteMode() === "hide") { + chatClient.setMuteMode("none"); + } + if (bubble) bubble.classList.remove("hidden"); + updateMuteVisibility(); +} + +export function isHidden() { + return chatClient.getMuteMode() === "hide"; +} diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 351c508..af44444 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -39,6 +39,7 @@ const bulkScreenshotBtn = document.getElementById("bulk-screenshot"); const bulkDisconnectBtn = document.getElementById("bulk-disconnect"); const bulkUninstallBtn = document.getElementById("bulk-uninstall"); const bulkClearBtn = document.getElementById("bulk-clear"); +const bulkGroupBtn = document.getElementById("bulk-group"); const serverVersionText = document.getElementById("server-version-text"); const selectedClients = new Set(); let lastNonOnlineStatus = "all"; @@ -785,6 +786,115 @@ bulkUninstallBtn?.addEventListener("click", async () => { setTimeout(() => loadWithOptions({ force: true }), 1000); }); +bulkGroupBtn?.addEventListener("click", () => { + if (selectedClients.size === 0) return; + openBulkGroupPicker([...selectedClients]); +}); + +async function openBulkGroupPicker(clientIds) { + const groups = await loadGroups(); + + const overlay = document.createElement("div"); + overlay.className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60"; + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); + + const modal = document.createElement("div"); + modal.className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4"; + modal.innerHTML = ` +

Set Group for ${clientIds.length} client(s)

+
+
+

Create new group

+
+ + + +
+
+ `; + + const listEl = modal.querySelector(".bulk-group-list"); + + async function applyGroup(groupId) { + overlay.remove(); + try { + const res = await fetch("/api/clients/bulk-group", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientIds, groupId }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "Failed to set group"); + return; + } + } catch (err) { + console.error(err); + alert("Failed to set group"); + return; + } + selectedClients.clear(); + document.querySelectorAll(".client-checkbox").forEach((cb) => (cb.checked = false)); + updateBulkToolbar(); + refreshGroupFilter(); + setTimeout(() => loadWithOptions({ force: true }), 200); + } + + function renderList() { + listEl.innerHTML = ""; + const noneBtn = document.createElement("button"); + noneBtn.className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800"; + noneBtn.innerHTML = ' No Group'; + noneBtn.addEventListener("click", () => applyGroup(null)); + listEl.appendChild(noneBtn); + + groups.forEach((g) => { + const btn = document.createElement("button"); + btn.className = "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors text-slate-300 hover:bg-slate-800"; + btn.innerHTML = ` ${escapeHtml(g.name)}`; + btn.addEventListener("click", () => applyGroup(g.id)); + listEl.appendChild(btn); + }); + } + + renderList(); + + const createBtn = modal.querySelector(".group-create-btn"); + createBtn.addEventListener("click", async () => { + const nameInput = modal.querySelector(".group-new-name"); + const colorInput = modal.querySelector(".group-new-color"); + const name = nameInput.value.trim(); + const color = colorInput.value; + if (!name) { nameInput.focus(); return; } + try { + const res = await fetch("/api/groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, color }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "Failed to create group"); + return; + } + const newGroup = await res.json(); + groups.push(newGroup); + nameInput.value = ""; + renderList(); + refreshGroupFilter(); + } catch (err) { + console.error(err); + alert("Failed to create group"); + } + }); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + modal.querySelector(".group-new-name").addEventListener("keydown", (e) => { + if (e.key === "Enter") createBtn.click(); + }); +} + window.toggleClientSelection = toggleClientSelection; window.isClientSelected = (clientId) => selectedClients.has(clientId); window.syncClientSelection = syncSelectionState; diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index ecce22d..4462512 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -10,6 +10,7 @@ import { mountNav } from "./nav/template.js"; import { createAdaptiveNavController } from "./nav/layout.js"; import { applyUserRoleUI } from "./nav/role-ui.js"; import { showCertBannerIfNeeded } from "./cert-banner.js"; +import * as chatWidget from "./chat-widget.js"; const host = document.getElementById("top-nav"); if (host) { @@ -137,4 +138,19 @@ if (host) { if (refs.usernameDisplay && refs.roleBadge) { loadCurrentUser(); } + + 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); + } } diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index ea28330..2507342 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -694,6 +694,53 @@ async function saveAppearanceSettings(event) { showMessage("Custom CSS saved. Reload the page to apply the new styles."); } +const chatSettingsSection = document.getElementById("chat-settings-section"); +const chatSettingsForm = document.getElementById("chat-settings-form"); +const chatRetentionDaysInput = document.getElementById("chat-retention-days"); + +async function loadChatSettings() { + if (!isAdmin(currentUser?.role)) return; + if (chatSettingsSection) chatSettingsSection.classList.remove("hidden"); + try { + const res = await fetch("/api/settings/chat", { credentials: "include" }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.chat) { + if (chatRetentionDaysInput) chatRetentionDaysInput.value = data.chat.retentionDays ?? 30; + } + } catch { + console.warn("Failed to load chat settings"); + } +} + +async function saveChatSettings(event) { + event.preventDefault(); + if (!isAdmin(currentUser?.role)) { + showMessage("Admin role required.", "error"); + return; + } + + const retentionDays = Number(chatRetentionDaysInput?.value); + if (!Number.isFinite(retentionDays) || retentionDays < 0 || retentionDays > 365) { + showMessage("Retention must be 0-365 days.", "error"); + return; + } + + const res = await fetch("/api/settings/chat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ retentionDays }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + showMessage(data.error || "Failed to save chat settings.", "error"); + return; + } + + showMessage("Chat settings saved."); +} + function showExportImportMessage(text, type = "ok") { if (!exportImportMessage) return; exportImportMessage.textContent = text; @@ -1015,6 +1062,7 @@ async function init() { await loadSecurityPolicy(); await loadTlsSettings(); await loadAppearanceSettings(); + await loadChatSettings(); await loadBannedIps(); passwordForm.addEventListener("submit", updatePassword); @@ -1039,6 +1087,7 @@ async function init() { tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings); + if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings); if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings); if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings); refreshBansBtn.addEventListener("click", loadBannedIps); diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index b731efd..7936af3 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -154,6 +154,12 @@ > Uninstall + + + +
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index 5fb9fad..69f2381 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -61,6 +61,9 @@ export interface Config { plugins: { trustedKeys: string[]; }; + chat: { + retentionDays: number; + }; } const DEFAULT_CONFIG: Config = { @@ -120,6 +123,9 @@ const DEFAULT_CONFIG: Config = { plugins: { trustedKeys: [], }, + chat: { + retentionDays: 30, + }, }; type SaveSecrets = { @@ -447,6 +453,12 @@ export function loadConfig(): Config { return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys; })(), }, + chat: { + retentionDays: + Number(process.env.OVERLORD_CHAT_RETENTION_DAYS) || + fileConfig.chat?.retentionDays || + DEFAULT_CONFIG.chat.retentionDays, + }, }; if (saveChanged) { @@ -656,6 +668,35 @@ export async function updatePluginsConfig( return next; } +export async function updateChatConfig( + updates: Partial, +): Promise { + const current = getConfig(); + + const toNumberOr = (value: unknown, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const next: Config["chat"] = { + ...current.chat, + ...updates, + }; + + const raw = toNumberOr(next.retentionDays, 30); + next.retentionDays = raw === 0 ? 0 : Math.min(365, Math.max(1, raw)); + + configCache = { + ...current, + chat: next, + }; + + const fileConfig = readFileConfigForUpdate(); + fileConfig.chat = next; + await writePersistentFileConfig(fileConfig); + return next; +} + export function getExportableConfig(serverVersion: string): Record { const config = getConfig(); return { @@ -673,6 +714,7 @@ export function getExportableConfig(serverVersion: string): Record): Promise<{ app applied.push("plugins"); } + if (data.chat && typeof data.chat === "object") { + await updateChatConfig(data.chat); + applied.push("chat"); + } + if (data.auth && typeof data.auth === "object") { const dataDir = ensureDataDir(); const savePath = resolve(dataDir, "save.json"); diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 3e60414..61ed45f 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -293,6 +293,81 @@ db.run( `CREATE INDEX IF NOT EXISTS idx_auto_deploy_runs_ts ON auto_deploy_runs(ts DESC);`, ); +db.run(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + user_role TEXT NOT NULL, + message TEXT NOT NULL, + created_at INTEGER NOT NULL + ); +`); +db.run( + `CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at DESC);`, +); + +export type ChatMessageRecord = { + id: number; + userId: number; + username: string; + userRole: string; + message: string; + createdAt: number; +}; + +export function insertChatMessage(userId: number, username: string, userRole: string, message: string): ChatMessageRecord { + const createdAt = Date.now(); + const result = db.run( + `INSERT INTO chat_messages (user_id, username, user_role, message, created_at) VALUES (?, ?, ?, ?, ?)`, + userId, username, userRole, message, createdAt, + ); + const id = Number((result as any).lastInsertRowid); + return { id, userId, username, userRole, message, createdAt }; +} + +export function getChatHistory(before?: number, limit: number = 50, retentionMs?: number): ChatMessageRecord[] { + const maxLimit = Math.min(Math.max(1, limit), 200); + const cutoff = retentionMs && retentionMs > 0 ? Date.now() - retentionMs : 0; + let rows: any[]; + if (before) { + if (cutoff > 0) { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? AND created_at > ? ORDER BY created_at DESC LIMIT ?`, + ).all(before, cutoff, maxLimit); + } else { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`, + ).all(before, maxLimit); + } + } else { + if (cutoff > 0) { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at > ? ORDER BY created_at DESC LIMIT ?`, + ).all(cutoff, maxLimit); + } else { + rows = db.query( + `SELECT id, user_id, username, user_role, message, created_at FROM chat_messages ORDER BY created_at DESC LIMIT ?`, + ).all(maxLimit); + } + } + return rows.map((r: any) => ({ + id: r.id, + userId: r.user_id, + username: r.username, + userRole: r.user_role, + message: r.message, + createdAt: r.created_at, + })).reverse(); +} + +export function deleteExpiredChatMessages(retentionMs: number): number { + if (retentionMs <= 0) return 0; + const cutoff = Date.now() - retentionMs; + const result = db.run(`DELETE FROM chat_messages WHERE created_at < ?`, cutoff); + return Number((result as any).changes ?? 0); +} + db.run(` CREATE TABLE IF NOT EXISTS shared_files ( id TEXT PRIMARY KEY, @@ -1164,6 +1239,15 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum }; } +export function getOnlineClientCountForUser(userId: number): number { + const row = db + .query<{ online: number }>( + `SELECT SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients WHERE built_by_user_id = ?`, + ) + .get(userId); + return Number(row?.online) || 0; +} + export function countBuildsForUser(userId: number): number { const row = db .query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`) diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 6cfbc56..3224404 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -4,7 +4,7 @@ import { logger } from "./logger"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs/promises"; -import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles } from "./db"; +import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages } from "./db"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { ClientInfo, ClientRole } from "./types"; @@ -12,13 +12,14 @@ import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "./auth"; import { loadConfig, getConfig } from "./config"; import { flushAuditLogsSync } from "./auditLog"; -import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users"; +import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users"; import { requireAuth, requirePermission } from "./rbac"; import { metrics } from "./metrics"; import { ensureDataDir } from "./paths"; import { handleAuthRoutes } from "./server/routes/auth-routes"; import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes"; import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes"; +import { handleChatRoutes } from "./server/routes/chat-routes"; import { handleBuildRoutes } from "./server/routes/build-routes"; import { handleSolRoutes } from "./server/routes/sol-routes"; import { handleAssetsRoutes } from "./server/routes/assets-routes"; @@ -507,6 +508,54 @@ async function startServer() { }); }, handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen, + handleChatViewerOpen: (ws: import("bun").ServerWebSocket) => { + const id = crypto.randomUUID(); + ws.data.sessionId = id; + const userId = ws.data.userId; + const userRole = ws.data.userRole || "viewer"; + const user = userId ? getUserById(userId) : null; + const username = user?.username || "Unknown"; + sessionManager.addChatSession({ + id, + viewer: ws, + createdAt: Date.now(), + userId: userId || 0, + username, + userRole, + }); + const retDays = getConfig().chat?.retentionDays ?? 30; + const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined; + const history = getChatHistory(undefined, 50, retMs); + const enrichedHistory = history.map((m) => ({ + ...m, + onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0, + })); + const canWrite = userId ? hasPermission(userRole as any, "chat:write", userId) : false; + ws.send(JSON.stringify({ type: "chat_ready", history: enrichedHistory, canWrite, userId: userId || 0 })); + }, + handleChatViewerMessage: (ws: import("bun").ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => { + try { + const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw as ArrayBuffer); + const parsed = JSON.parse(text); + if (parsed.type !== "chat_send" || typeof parsed.message !== "string") return; + const userId = ws.data.userId; + const userRole = ws.data.userRole || "viewer"; + if (!userId || !hasPermission(userRole as any, "chat:write", userId)) { + ws.send(JSON.stringify({ type: "chat_error", error: "Permission denied" })); + return; + } + const msg = parsed.message.trim(); + if (!msg || msg.length > 2000) { + ws.send(JSON.stringify({ type: "chat_error", error: msg ? "Message too long (max 2000 chars)" : "Message is empty" })); + return; + } + const user = getUserById(userId); + const username = user?.username || "Unknown"; + const record = insertChatMessage(userId, username, userRole, msg); + const onlineClients = getOnlineClientCountForUser(userId); + sessionManager.broadcastChatMessage(JSON.stringify({ type: "chat_message", ...record, onlineClients })); + } catch {} + }, handleConsoleViewerMessage, handleRemoteDesktopViewerMessage, handleWebcamViewerMessage, @@ -586,6 +635,7 @@ async function startServer() { handleAutoScriptsRoutes, handleAutoDeployRoutes, handleEnrollmentRoutes, + handleChatRoutes, handleSolRoutes, handleUsersRoutes, handleBuildRoutes, @@ -625,6 +675,22 @@ async function startServer() { } if (expiredPaths.length) logger.info(`[db] Cleaned up ${expiredPaths.length} expired shared files`); + const chatRetentionDays = getConfig().chat?.retentionDays ?? 30; + if (chatRetentionDays > 0) { + const retentionMs = chatRetentionDays * 24 * 60 * 60 * 1000; + const purged = deleteExpiredChatMessages(retentionMs); + if (purged > 0) logger.info(`[db] Cleaned up ${purged} expired chat messages (retention: ${chatRetentionDays}d)`); + } + + setInterval(() => { + const days = getConfig().chat?.retentionDays ?? 30; + if (days > 0) { + const ms = days * 24 * 60 * 60 * 1000; + const count = deleteExpiredChatMessages(ms); + if (count > 0) logger.info(`[chat] Purged ${count} expired messages (retention: ${days}d)`); + } + }, 60 * 60 * 1000); // every hour + startMaintenanceLoops({ getClients: clientManager.getAllClients, setOnlineState, diff --git a/Overlord-Server/src/rbac.ts b/Overlord-Server/src/rbac.ts index c3b13ca..6e05283 100644 --- a/Overlord-Server/src/rbac.ts +++ b/Overlord-Server/src/rbac.ts @@ -7,7 +7,8 @@ export type Permission = | "clients:view" | "clients:build" | "clients:enroll" - | "audit:view"; + | "audit:view" + | "chat:write"; export function checkPermission( user: AuthenticatedUser | null, @@ -80,6 +81,8 @@ export function getPermissionDescription(permission: Permission): string { return "Manage client enrollment approvals"; case "audit:view": return "View audit logs"; + case "chat:write": + return "Send messages in team chat"; default: return "Unknown permission"; } diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index a585203..1ab076b 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -41,6 +41,7 @@ type HttpDispatchDeps< handleAutoScriptsRoutes: RouteHandler; handleAutoDeployRoutes: RouteHandlerWithDeps; handleEnrollmentRoutes: RouteHandler; + handleChatRoutes: RouteHandler; handleSolRoutes: RouteHandler; handleUsersRoutes: RouteHandlerWithServer; handleBuildRoutes: RouteHandlerWithServerDeps; @@ -128,6 +129,9 @@ export function createHttpFetchHandler< const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url); if (enrollmentResponse) return enrollmentResponse; + const chatResponse = await deps.handleChatRoutes(req, url); + if (chatResponse) return chatResponse; + const solResponse = await deps.handleSolRoutes(req, url); if (solResponse) return solResponse; diff --git a/Overlord-Server/src/server/routes/chat-routes.ts b/Overlord-Server/src/server/routes/chat-routes.ts new file mode 100644 index 0000000..d6ed20d --- /dev/null +++ b/Overlord-Server/src/server/routes/chat-routes.ts @@ -0,0 +1,43 @@ +import { authenticateRequest } from "../../auth"; +import { getConfig } from "../../config"; +import { getChatHistory, getOnlineClientCountForUser } from "../../db"; +import { hasPermission } from "../../users"; + +export async function handleChatRoutes( + req: Request, + url: URL, +): Promise { + if (req.method === "GET" && url.pathname === "/api/chat/history") { + const user = await authenticateRequest(req); + if (!user) { + return new Response(JSON.stringify({ error: "Not authenticated" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const beforeParam = url.searchParams.get("before"); + const limitParam = url.searchParams.get("limit"); + const before = beforeParam ? Number(beforeParam) : undefined; + const limit = limitParam ? Number(limitParam) : 50; + + if (before !== undefined && (isNaN(before) || before <= 0)) { + return Response.json({ error: "Invalid 'before' parameter" }, { status: 400 }); + } + if (isNaN(limit) || limit < 1 || limit > 200) { + return Response.json({ error: "Invalid 'limit' parameter (1-200)" }, { status: 400 }); + } + + const retDays = getConfig().chat?.retentionDays ?? 30; + const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined; + const messages = getChatHistory(before, limit, retMs); + const enriched = messages.map((m) => ({ + ...m, + onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0, + })); + const canWrite = hasPermission(user.role, "chat:write", user.userId); + return Response.json({ messages: enriched, canWrite }); + } + + return null; +} diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 44a1e05..45dad8d 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -850,6 +850,44 @@ export async function handleClientRoutes( } } + if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { requirePermission(user, "clients:control"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const clientIds = body?.clientIds; + if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) { + return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 }); + } + if (clientIds.length > 500) { + return Response.json({ error: "Too many clients (max 500)" }, { status: 400 }); + } + + const groupId = body?.groupId === null ? null : Number(body?.groupId); + if (groupId !== null && (isNaN(groupId) || groupId < 1)) { + return Response.json({ error: "Invalid groupId" }, { status: 400 }); + } + + let updated = 0; + for (const cid of clientIds) { + if (!canUserAccessClient(user.userId, user.role, cid)) continue; + if (setClientGroup(cid, groupId)) updated++; + } + + notifyDashboardViewers(); + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_group:${groupId ?? "none"}:${updated}/${clientIds.length}`, success: true }); + return Response.json({ ok: true, updated, total: clientIds.length, groupId }, { headers: deps.CORS_HEADERS }); + } + const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/); if (req.method === "PATCH" && clientGroupMatch) { const user = await authenticateRequest(req); diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 8a2ac3a..aa657fa 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -1,6 +1,6 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; -import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, getExportableConfig, importFullConfig } from "../../config"; +import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig } from "../../config"; import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; @@ -493,6 +493,45 @@ export async function handleMiscRoutes( } } + if (url.pathname === "/api/settings/chat") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + if (req.method === "GET") { + return Response.json({ chat: getConfig().chat }, { headers: deps.CORS_HEADERS }); + } + + if (req.method === "PUT") { + let body: any = {}; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const updated = await updateChatConfig({ + retentionDays: Number(body?.retentionDays), + }); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: `Updated chat settings: retention=${updated.retentionDays} days`, + success: true, + }); + + return Response.json({ ok: true, chat: updated }, { headers: deps.CORS_HEADERS }); + } + } + if (url.pathname === "/api/settings/appearance") { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index e2887cc..5df9ea5 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -45,6 +45,8 @@ type WsLifecycleDeps = { handleKeyloggerViewerOpen: (ws: ServerWebSocket) => void; handleVoiceViewerOpen: (ws: ServerWebSocket) => void; handleNotificationViewerOpen: (ws: ServerWebSocket) => void; + handleChatViewerOpen: (ws: ServerWebSocket) => void; + handleChatViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleConsoleViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleRemoteDesktopViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; handleWebcamViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void; @@ -151,6 +153,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws); if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws); if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws); + if (role === "chat_viewer") return deps.handleChatViewerOpen(ws); const id = clientId || uuidv4(); ws.data.clientId = id; @@ -202,6 +205,7 @@ export async function handleWebSocketMessage( if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message); if (socketRole === "notifications_viewer") return; if (socketRole === "dashboard_viewer") return; + if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message); const { clientId, ip } = ws.data; @@ -740,6 +744,13 @@ export function handleWebSocketClose( return; } + if (role === "chat_viewer") { + if (sessionId) { + sessionManager.deleteChatSession(sessionId); + } + return; + } + const currentClient = clientManager.getClient(clientId); if (currentClient && currentClient.ws !== ws) { logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`); diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts index e748c44..dbb0d03 100644 --- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -271,5 +271,17 @@ export async function handleWsUpgradeRoutes( return new Response("Upgrade failed", { status: 500 }); } + if (req.method === "GET" && url.pathname === "/api/chat/ws") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + const ip = server.requestIP(req)?.address || ""; + if (server.upgrade(req, { data: { role: "chat_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) { + return new Response(); + } + return new Response("Upgrade failed", { status: 500 }); + } + return null; } diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts index eb09ede..44eeff5 100644 --- a/Overlord-Server/src/sessions/sessionManager.ts +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -7,6 +7,7 @@ import type { VoiceViewer, NotificationsViewer, KeyloggerViewer, + ChatViewer, SocketData, } from "./types"; @@ -36,6 +37,7 @@ const keyloggerSessionsByClient = new Map>(); const voiceSessions = new Map(); const voiceSessionsByClient = new Map>(); const dashboardSessions = new Map(); +const chatSessions = new Map(); function addSessionToClientIndex( index: Map>, @@ -495,3 +497,29 @@ export function notifyDashboardViewers(): void { } }, DASHBOARD_DEBOUNCE_MS); } + +export function addChatSession(session: ChatViewer): void { + chatSessions.set(session.id, session); +} + +export function deleteChatSession(sessionId: string): boolean { + return chatSessions.delete(sessionId); +} + +export function getAllChatSessions(): Map { + return chatSessions; +} + +export function getChatSessionCount(): number { + return chatSessions.size; +} + +export function broadcastChatMessage(msg: string): void { + for (const [id, session] of chatSessions) { + try { + session.viewer.send(msg); + } catch { + chatSessions.delete(id); + } + } +} diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index 90871fd..90bb769 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -11,7 +11,8 @@ export type SocketRole = | "notifications_viewer" | "keylogger_viewer" | "voice_viewer" - | "dashboard_viewer"; + | "dashboard_viewer" + | "chat_viewer"; export type SocketData = { role: SocketRole; @@ -77,3 +78,12 @@ export type VoiceViewer = { viewer: ServerWebSocket; createdAt: number; }; + +export type ChatViewer = { + id: string; + viewer: ServerWebSocket; + createdAt: number; + userId: number; + username: string; + userRole: string; +}; diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index eba5951..f88995a 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -202,6 +202,15 @@ for (const col of clientEventColumns) { } } +try { + db.exec(`ALTER TABLE users ADD COLUMN chat_write INTEGER DEFAULT NULL`); + logger.info("[users] Added chat_write column to existing database"); +} catch (err: any) { + if (!err.message?.includes("duplicate column name")) { + logger.error("[users] Migration error:", err); + } +} + const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number; }; @@ -747,6 +756,9 @@ export function hasPermission(role: UserRole, permission: string, userId?: numbe return canManageEnrollment(role); case "audit:view": return canViewAuditLogs(role); + case "chat:write": + if (userId !== undefined) return canChatWrite(userId, role); + return role === "admin" || role === "operator"; default: return false; } @@ -784,6 +796,30 @@ export function setUserCanUploadFiles( } } +export function canChatWrite(userId: number, role: UserRole): boolean { + if (role === "admin") return true; + const user = getUserById(userId); + if (!user) return false; + const chatWrite = (user as any).chat_write; + if (chatWrite === null || chatWrite === undefined) { + return role === "operator"; + } + return chatWrite === 1; +} + +export function setUserChatWrite( + userId: number, + canWrite: boolean | null, +): { success: boolean; error?: string } { + try { + db.prepare("UPDATE users SET chat_write = ? WHERE id = ?").run(canWrite === null ? null : (canWrite ? 1 : 0), userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] setUserChatWrite error:", err); + return { success: false, error: err.message || "Failed to update chat write permission" }; + } +} + export function setUserTelegramChatId( userId: number, chatId: string | null,