From a21222f44a0b9b58561e4d28494454572f3cd2a0 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Fri, 13 Mar 2026 23:17:24 -0500 Subject: [PATCH] better notification system --- .../public/assets/notifications.js | 449 +++++++++++++----- Overlord-Server/public/assets/settings.js | 2 - Overlord-Server/public/notifications.html | 190 ++++++-- Overlord-Server/public/settings.html | 27 +- Overlord-Server/src/main-server.ts | 29 +- .../src/server/notification-delivery.ts | 161 ++++--- .../routes/notifications-config-routes.ts | 211 ++++++++ .../src/server/routes/ws-upgrade-routes.ts | 2 +- .../src/server/ws-notifications-plugin.ts | 26 +- Overlord-Server/src/sessions/types.ts | 3 + Overlord-Server/src/users.ts | 116 +++++ 11 files changed, 958 insertions(+), 258 deletions(-) diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 4d70649..86ec28f 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -6,19 +6,42 @@ import { markAllNotificationsRead, } from "./notify-client.js"; +// ── DOM refs ────────────────────────────────────────────────────────────────── const wsStatus = document.getElementById("ws-status"); const listEl = document.getElementById("notification-list"); const emptyState = document.getElementById("empty-state"); +const notificationScopeHint = document.getElementById("notification-scope-hint"); + +// Keyword section +const keywordSection = document.getElementById("keyword-section"); const keywordInput = document.getElementById("keyword-input"); const saveKeywordsBtn = document.getElementById("save-keywords"); const keywordHint = document.getElementById("keyword-hint"); + +// Webhook section const webhookEnabledInput = document.getElementById("webhook-enabled"); const webhookUrlInput = document.getElementById("webhook-url"); +const webhookTemplateInput = document.getElementById("webhook-template"); const saveWebhookBtn = document.getElementById("save-webhook"); +const webhookUrlToggle = document.getElementById("webhook-url-toggle"); +const resetWebhookTemplateBtn = document.getElementById("reset-webhook-template"); +const formatWebhookTemplateBtn = document.getElementById("format-webhook-template"); +const previewWebhookTemplateBtn = document.getElementById("preview-webhook-template"); +const webhookTemplatePreview = document.getElementById("webhook-template-preview"); + +// Telegram section const telegramEnabledInput = document.getElementById("telegram-enabled"); const telegramBotTokenInput = document.getElementById("telegram-bot-token"); const telegramChatIdInput = document.getElementById("telegram-chat-id"); +const telegramTemplateInput = document.getElementById("telegram-template"); const saveTelegramBtn = document.getElementById("save-telegram"); +const telegramTokenToggle = document.getElementById("telegram-token-toggle"); +const telegramChatidToggle = document.getElementById("telegram-chatid-toggle"); +const resetTelegramTemplateBtn = document.getElementById("reset-telegram-template"); +const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template"); +const telegramTemplatePreview = document.getElementById("telegram-template-preview"); + +// Preview modal const panel = document.getElementById("notification-panel"); const previewModal = document.getElementById("notification-preview-modal"); const previewModalImg = document.getElementById("notification-preview-image"); @@ -26,6 +49,87 @@ const previewModalClose = document.getElementById("notification-preview-close"); const MAX_ROWS = 200; +// Defaults loaded from /api/notifications/my-settings +let defaultWebhookTemplate = ""; +let defaultTelegramTemplate = ""; +let webhookTemplateEditor = null; + +function tryFormatJsonText(text) { + const source = String(text || "").trim(); + if (!source) return ""; + try { + return JSON.stringify(JSON.parse(source), null, 2); + } catch { + return null; + } +} + +function initWebhookTemplateEditor() { + if (!webhookTemplateInput || typeof window.ace === "undefined") return; + try { + webhookTemplateEditor = window.ace.edit("webhook-template"); + webhookTemplateEditor.setTheme("ace/theme/tomorrow_night"); + webhookTemplateEditor.session.setMode("ace/mode/json"); + webhookTemplateEditor.session.setUseWrapMode(true); + webhookTemplateEditor.setShowPrintMargin(false); + webhookTemplateEditor.setOption("fontSize", "12px"); + webhookTemplateEditor.setOption("tabSize", 2); + webhookTemplateEditor.setOption("useSoftTabs", true); + webhookTemplateEditor.on("blur", () => { + const formatted = tryFormatJsonText(webhookTemplateEditor.getValue()); + if (formatted !== null && formatted !== webhookTemplateEditor.getValue()) { + const position = webhookTemplateEditor.getCursorPosition(); + webhookTemplateEditor.setValue(formatted, -1); + webhookTemplateEditor.moveCursorToPosition(position); + } + }); + } catch { + webhookTemplateEditor = null; + } +} + +function getWebhookTemplateValue() { + if (webhookTemplateEditor) return webhookTemplateEditor.getValue(); + return webhookTemplateInput?.value || ""; +} + +function setWebhookTemplateValue(text) { + const value = text || ""; + const formatted = tryFormatJsonText(value); + const nextValue = formatted ?? value; + if (webhookTemplateEditor) { + webhookTemplateEditor.setValue(nextValue, -1); + return; + } + if (webhookTemplateInput) webhookTemplateInput.value = nextValue; +} + +function focusWebhookTemplateEditor() { + if (webhookTemplateEditor) webhookTemplateEditor.focus(); +} + +// ── Sample data for template previews ──────────────────────────────────────── +const SAMPLE_RECORD = { + title: "Online Banking — Secure Login", + keyword: "bank", + clientId: "abc123def456", + user: "john.doe", + host: "DESKTOP-7G2ABK1", + process: "chrome.exe", + os: "windows", + pid: "4392", + ts: String(Date.now()), +}; + +function renderSampleTemplate(template, defaultTpl) { + const tpl = template && template.trim() ? template : defaultTpl; + return Object.entries(SAMPLE_RECORD).reduce( + (s, [k, v]) => s.replaceAll(`{${k}}`, v), + tpl, + ); +} + +// ── Status indicator ────────────────────────────────────────────────────────── function setStatus(text, tone = "neutral") { if (!wsStatus) return; const icon = wsStatus.querySelector("i"); @@ -41,14 +145,10 @@ function setStatus(text, tone = "neutral") { } } +// ── Helpers ─────────────────────────────────────────────────────────────────── function formatTime(ts) { if (ts == null) return "-"; - let normalized = ts; - if (typeof normalized === "bigint") { - normalized = Number(normalized); - } else if (typeof normalized === "string") { - normalized = Number(normalized); - } + let normalized = typeof ts === "bigint" ? Number(ts) : Number(ts); if (!Number.isFinite(normalized)) return "-"; const date = new Date(normalized); if (Number.isNaN(date.getTime())) return "-"; @@ -61,6 +161,19 @@ function escapeHtml(text) { return div.innerHTML; } +function wireToggle(inputEl, btnEl) { + if (!inputEl || !btnEl) return; + btnEl.addEventListener("click", () => { + const show = inputEl.type === "password"; + inputEl.type = show ? "text" : "password"; + const icon = btnEl.querySelector("i"); + if (icon) { + icon.className = show ? "fa-solid fa-eye-slash" : "fa-solid fa-eye"; + } + }); +} + +// ── Notification table row ──────────────────────────────────────────────────── function renderRow(item, prepend = true) { if (!listEl) return; const row = document.createElement("tr"); @@ -111,9 +224,7 @@ function renderRow(item, prepend = true) { preview.innerHTML = ""; preview.className = ""; preview.appendChild(img); - img.addEventListener("load", () => { - img.classList.remove("opacity-0"); - }); + img.addEventListener("load", () => img.classList.remove("opacity-0")); img.addEventListener("error", () => { preview.textContent = "-"; preview.className = "text-slate-500"; @@ -121,17 +232,14 @@ function renderRow(item, prepend = true) { }); img.src = objectUrl; img.dataset.previewUrl = objectUrl; - if (img.decode) { - img.decode().catch(() => {}); - } - + if (img.decode) img.decode().catch(() => {}); img.addEventListener("click", () => { if (!previewModal || !previewModalImg) return; previewModalImg.src = img.dataset.previewUrl || objectUrl; previewModal.classList.remove("hidden"); previewModal.classList.add("flex"); }); - } catch (err) { + } catch { if (attempts < maxAttempts) { setTimeout(fetchPreview, 1000 * attempts); return; @@ -151,26 +259,14 @@ function renderRow(item, prepend = true) { } const rows = listEl.querySelectorAll("tr"); - if (rows.length > MAX_ROWS) { - rows[rows.length - 1].remove(); - } + if (rows.length > MAX_ROWS) rows[rows.length - 1].remove(); - if (emptyState) { - emptyState.classList.toggle("hidden", listEl.children.length > 0); - } + if (emptyState) emptyState.classList.toggle("hidden", listEl.children.length > 0); } -function handleNotification(item) { - console.log("[notifications] item", item); - renderRow(item, true); -} - - +// ── Keyword section ─────────────────────────────────────────────────────────── function parseKeywords(text) { - return text - .split(/\r?\n/) - .map((k) => k.trim()) - .filter(Boolean); + return text.split(/\r?\n/).map((k) => k.trim()).filter(Boolean); } function renderKeywordHint(count) { @@ -184,25 +280,9 @@ async function loadKeywords() { const res = await fetch("/api/notifications/config"); if (!res.ok) return; const data = await res.json(); - const notifications = data?.notifications || {}; - const keywords = notifications.keywords || []; + const keywords = data?.notifications?.keywords || []; keywordInput.value = keywords.join("\n"); renderKeywordHint(keywords.length); - if (webhookEnabledInput) { - webhookEnabledInput.checked = !!notifications.webhookEnabled; - } - if (webhookUrlInput) { - webhookUrlInput.value = notifications.webhookUrl || ""; - } - if (telegramEnabledInput) { - telegramEnabledInput.checked = !!notifications.telegramEnabled; - } - if (telegramBotTokenInput) { - telegramBotTokenInput.value = notifications.telegramBotToken || ""; - } - if (telegramChatIdInput) { - telegramChatIdInput.value = notifications.telegramChatId || ""; - } } catch {} } @@ -231,106 +311,226 @@ function wireKeywordSave() { }); keywordInput.addEventListener("input", () => { - const keywords = parseKeywords(keywordInput.value); - renderKeywordHint(keywords.length); + renderKeywordHint(parseKeywords(keywordInput.value).length); }); } +// ── Per-user settings ───────────────────────────────────────────────────────── +function applyMySettings(settings) { + if (webhookEnabledInput) webhookEnabledInput.checked = !!settings.webhook_enabled; + if (webhookUrlInput) webhookUrlInput.value = settings.webhook_url || ""; + setWebhookTemplateValue(settings.webhook_template || ""); + if (telegramEnabledInput) telegramEnabledInput.checked = !!settings.telegram_enabled; + if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || ""; + if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || ""; + if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || ""; +} + +async function loadMySettings() { + try { + const res = await fetch("/api/notifications/my-settings"); + if (!res.ok) return; + const data = await res.json(); + if (data.defaults) { + defaultWebhookTemplate = data.defaults.webhookTemplate || ""; + defaultTelegramTemplate = data.defaults.telegramTemplate || ""; + if (telegramTemplateInput) telegramTemplateInput.placeholder = `Premade template:\n${defaultTelegramTemplate}`; + } + if (data.settings) { + applyMySettings(data.settings); + if (!getWebhookTemplateValue().trim()) { + setWebhookTemplateValue(defaultWebhookTemplate); + } + if (telegramTemplateInput && !telegramTemplateInput.value.trim()) { + telegramTemplateInput.value = defaultTelegramTemplate; + } + } + } catch {} +} + +async function saveMySettings(patch) { + const res = await fetch("/api/notifications/my-settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || "Failed to save settings"); + } + const data = await res.json(); + if (data.settings) applyMySettings(data.settings); +} + +async function postWebhookPreview(webhookUrl, webhookTemplate) { + const res = await fetch("/api/notifications/my-settings/preview/webhook", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ webhookUrl, webhookTemplate }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.error || "Failed to send preview webhook"); + } + if (!data?.ok) { + const details = data?.responseBody ? `: ${String(data.responseBody).slice(0, 120)}` : ""; + throw new Error(`Webhook responded with ${data?.status || "unknown status"}${details}`); + } + return data; +} + function wireWebhookSave() { - if (!saveWebhookBtn || !webhookUrlInput || !webhookEnabledInput) return; - saveWebhookBtn.addEventListener("click", async () => { - const webhookUrl = webhookUrlInput.value.trim(); - const webhookEnabled = !!webhookEnabledInput.checked; - try { - const res = await fetch("/api/notifications/config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ webhookUrl, webhookEnabled }), - }); - if (!res.ok) { - window.showToast?.("Failed to save webhook", "error", 4000); + wireToggle(webhookUrlInput, webhookUrlToggle); + + if (resetWebhookTemplateBtn) { + resetWebhookTemplateBtn.addEventListener("click", () => { + setWebhookTemplateValue(defaultWebhookTemplate); + focusWebhookTemplateEditor(); + if (webhookTemplatePreview) { + webhookTemplatePreview.textContent = renderSampleTemplate(defaultWebhookTemplate, defaultWebhookTemplate); + webhookTemplatePreview.classList.remove("hidden"); + } + }); + } + + if (formatWebhookTemplateBtn) { + formatWebhookTemplateBtn.addEventListener("click", () => { + const current = getWebhookTemplateValue().trim(); + if (!current) { + setWebhookTemplateValue(defaultWebhookTemplate); return; } - const data = await res.json(); - const notifications = data?.notifications || {}; - webhookEnabledInput.checked = !!notifications.webhookEnabled; - webhookUrlInput.value = notifications.webhookUrl || ""; - window.showToast?.("Webhook updated", "success", 3000); - } catch { - window.showToast?.("Failed to save webhook", "error", 4000); + const formatted = tryFormatJsonText(current); + if (formatted !== null) { + setWebhookTemplateValue(formatted); + } else { + window.showToast?.("Template JSON is invalid. Fix syntax before formatting.", "error", 3500); + } + }); + } + + if (previewWebhookTemplateBtn && webhookTemplatePreview && webhookTemplateInput) { + previewWebhookTemplateBtn.addEventListener("click", async () => { + const webhookTemplateText = getWebhookTemplateValue(); + const rendered = renderSampleTemplate(webhookTemplateText, defaultWebhookTemplate); + webhookTemplatePreview.textContent = rendered; + webhookTemplatePreview.classList.remove("hidden"); + + const webhookUrl = webhookUrlInput?.value?.trim() || ""; + const webhookTemplate = webhookTemplateText.trim() || defaultWebhookTemplate; + if (!webhookUrl) { + window.showToast?.("Enter a webhook URL first", "error", 3500); + return; + } + + try { + const result = await postWebhookPreview(webhookUrl, webhookTemplate); + const modeHint = result.mode === "discord" ? " (Discord mode)" : ""; + window.showToast?.(`Preview sent to webhook (HTTP ${result.status})${modeHint}`, "success", 3500); + } catch (err) { + window.showToast?.(err?.message || "Failed to send preview webhook", "error", 4000); + } + }); + } + + if (!saveWebhookBtn) return; + saveWebhookBtn.addEventListener("click", async () => { + try { + const templateText = getWebhookTemplateValue().trim() || defaultWebhookTemplate; + await saveMySettings({ + webhook_enabled: webhookEnabledInput?.checked ?? false, + webhook_url: webhookUrlInput?.value?.trim() || "", + webhook_template: templateText, + }); + window.showToast?.("Webhook settings saved", "success", 3000); + } catch (err) { + window.showToast?.(err?.message || "Failed to save webhook", "error", 4000); } }); } function wireTelegramSave() { - if (!saveTelegramBtn || !telegramBotTokenInput || !telegramChatIdInput || !telegramEnabledInput) return; - saveTelegramBtn.addEventListener("click", async () => { - const telegramBotToken = telegramBotTokenInput.value.trim(); - const telegramChatId = telegramChatIdInput.value.trim(); - const telegramEnabled = !!telegramEnabledInput.checked; - try { - const res = await fetch("/api/notifications/config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ telegramBotToken, telegramChatId, telegramEnabled }), - }); - if (!res.ok) { - window.showToast?.("Failed to save Telegram settings", "error", 4000); - return; + wireToggle(telegramBotTokenInput, telegramTokenToggle); + wireToggle(telegramChatIdInput, telegramChatidToggle); + + if (resetTelegramTemplateBtn) { + resetTelegramTemplateBtn.addEventListener("click", () => { + if (telegramTemplateInput) telegramTemplateInput.value = defaultTelegramTemplate; + if (telegramTemplatePreview) { + telegramTemplatePreview.textContent = renderSampleTemplate(defaultTelegramTemplate, defaultTelegramTemplate); + telegramTemplatePreview.classList.remove("hidden"); } - const data = await res.json(); - const notifications = data?.notifications || {}; - telegramEnabledInput.checked = !!notifications.telegramEnabled; - telegramBotTokenInput.value = notifications.telegramBotToken || ""; - telegramChatIdInput.value = notifications.telegramChatId || ""; - window.showToast?.("Telegram settings updated", "success", 3000); - } catch { - window.showToast?.("Failed to save Telegram settings", "error", 4000); + }); + } + + if (previewTelegramTemplateBtn && telegramTemplatePreview && telegramTemplateInput) { + previewTelegramTemplateBtn.addEventListener("click", () => { + const rendered = renderSampleTemplate(telegramTemplateInput.value, defaultTelegramTemplate); + telegramTemplatePreview.textContent = rendered; + telegramTemplatePreview.classList.remove("hidden"); + }); + } + + if (!saveTelegramBtn) return; + saveTelegramBtn.addEventListener("click", async () => { + try { + const templateText = telegramTemplateInput?.value?.trim() || defaultTelegramTemplate; + await saveMySettings({ + telegram_enabled: telegramEnabledInput?.checked ?? false, + telegram_bot_token: telegramBotTokenInput?.value?.trim() || "", + telegram_chat_id: telegramChatIdInput?.value?.trim() || "", + telegram_template: templateText, + }); + window.showToast?.("Telegram settings saved", "success", 3000); + } catch (err) { + window.showToast?.(err?.message || "Failed to save Telegram settings", "error", 4000); } }); } +// ── Init role-based UI ──────────────────────────────────────────────────────── +async function initRoleUi() { + try { + const res = await fetch("/api/auth/me"); + if (!res.ok) return; + const data = await res.json(); + const role = data?.role || ""; + if ((role === "admin" || role === "operator") && keywordSection) { + keywordSection.classList.remove("hidden"); + loadKeywords(); + } + if (notificationScopeHint) { + notificationScopeHint.textContent = + role === "admin" + ? "Showing all notifications (admin)." + : "Showing notifications for your accessible clients."; + } + } catch {} +} +// ── WebSocket connection ────────────────────────────────────────────────────── function connect() { startNotificationClient(); - if (panel) { - markAllNotificationsRead(); - } + if (panel) markAllNotificationsRead(); + subscribeStatus((status) => { if (status === "connected") setStatus("Connected", "ok"); if (status === "error") setStatus("Error", "error"); if (status === "disconnected") setStatus("Disconnected", "warn"); }); + subscribeReady((history) => { if (listEl) listEl.innerHTML = ""; history.reverse().forEach((item) => renderRow(item, false)); - if (emptyState) { - emptyState.classList.toggle("hidden", history.length > 0); - } + if (emptyState) emptyState.classList.toggle("hidden", history.length > 0); markAllNotificationsRead(); }); - subscribeNotifications(handleNotification); + + subscribeNotifications((item) => renderRow(item, true)); } -wireKeywordSave(); -wireWebhookSave(); -wireTelegramSave(); -loadKeywords(); -connect(); - -function wireActiveClear() { - const clearIfActive = () => { - if (document.visibilityState === "visible") { - markAllNotificationsRead(); - } - }; - document.addEventListener("visibilitychange", clearIfActive); - window.addEventListener("focus", clearIfActive); - clearIfActive(); -} - -wireActiveClear(); - +// ── Preview modal ───────────────────────────────────────────────────────────── if (previewModal && previewModalClose && previewModalImg) { const closePreview = () => { previewModal.classList.add("hidden"); @@ -342,3 +542,20 @@ if (previewModal && previewModalClose && previewModalImg) { if (event.target === previewModal) closePreview(); }); } + +// ── Clear unread badge when tab is visible ──────────────────────────────────── +const clearIfActive = () => { + if (document.visibilityState === "visible") markAllNotificationsRead(); +}; +document.addEventListener("visibilitychange", clearIfActive); +window.addEventListener("focus", clearIfActive); +clearIfActive(); + +// ── Bootstrap ───────────────────────────────────────────────────────────────── +initWebhookTemplateEditor(); +wireKeywordSave(); +wireWebhookSave(); +wireTelegramSave(); +loadMySettings(); +initRoleUi(); +connect(); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 4dbd24c..fe12630 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -580,14 +580,12 @@ async function init() { try { await loadCurrentUser(); loadPrefs(); - await loadMyTelegram(); await loadSecurityPolicy(); await loadTlsSettings(); await loadBannedIps(); passwordForm.addEventListener("submit", updatePassword); prefsForm.addEventListener("submit", savePrefs); - if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram); securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html index 4a3f4dc..fedb16f 100644 --- a/Overlord-Server/public/notifications.html +++ b/Overlord-Server/public/notifications.html @@ -19,6 +19,7 @@ referrerpolicy="no-referrer" /> + @@ -28,6 +29,8 @@
+ +

Notifications

@@ -46,12 +49,13 @@
-
+ +