add chat system
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="relative flex items-center justify-center w-12 h-12 rounded-full bg-sky-600 hover:bg-sky-500 shadow-lg shadow-sky-900/30 transition-colors">
|
||||||
|
<i class="fa-solid fa-comments text-white text-lg"></i>
|
||||||
|
<span data-chat-badge class="hidden absolute -top-1 -right-1 min-w-[20px] h-5 px-1 rounded-full bg-rose-500 text-white text-xs flex items-center justify-center font-semibold"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-zinc-700/60 bg-zinc-800/60 shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-comments text-sky-400 text-sm"></i>
|
||||||
|
<span class="font-semibold text-slate-200 text-sm">Team Chat</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div id="chat-mute-dropdown" class="relative">
|
||||||
|
<button id="chat-mute-btn" class="flex items-center gap-1.5 bg-zinc-700/70 border border-zinc-600 text-slate-300 text-xs rounded px-2 py-1 outline-none cursor-pointer hover:bg-zinc-600/70 transition-colors" title="Notification mode">
|
||||||
|
<span id="chat-mute-label">All notifications</span>
|
||||||
|
<i class="fa-solid fa-chevron-down text-[10px] text-slate-400"></i>
|
||||||
|
</button>
|
||||||
|
<div id="chat-mute-menu" class="hidden absolute right-0 top-full mt-1 w-44 bg-zinc-800 border border-zinc-600 rounded-lg shadow-xl shadow-black/50 py-1 z-[9999]">
|
||||||
|
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="none">All notifications</div>
|
||||||
|
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="mute">Mute</div>
|
||||||
|
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="mute_collapse">Mute + Collapse</div>
|
||||||
|
<div class="chat-mute-option px-3 py-1.5 text-xs text-slate-300 hover:bg-zinc-700 cursor-pointer transition-colors" data-value="hide">Hide</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="chat-close-btn" class="text-slate-400 hover:text-slate-200 transition-colors" title="Close">
|
||||||
|
<i class="fa-solid fa-xmark text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" class="flex-1 overflow-y-auto min-h-0 py-1" style="max-height: 370px;"></div>
|
||||||
|
<div id="chat-input-area" class="hidden shrink-0 border-t border-zinc-700/60 bg-zinc-800/40 p-2">
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<textarea id="chat-input" rows="1" maxlength="2000"
|
||||||
|
class="flex-1 bg-zinc-800 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none resize-none leading-snug"
|
||||||
|
placeholder="Type a message..." style="max-height: 100px;"></textarea>
|
||||||
|
<button id="chat-send-btn" class="px-3 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors shrink-0" title="Send">
|
||||||
|
<i class="fa-solid fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chat-readonly-bar" class="hidden shrink-0 border-t border-zinc-700/60 bg-zinc-800/40 px-3 py-2">
|
||||||
|
<span class="text-xs text-slate-500 italic">Read-only — you can view but not send messages</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ const bulkScreenshotBtn = document.getElementById("bulk-screenshot");
|
|||||||
const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
|
const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
|
||||||
const bulkUninstallBtn = document.getElementById("bulk-uninstall");
|
const bulkUninstallBtn = document.getElementById("bulk-uninstall");
|
||||||
const bulkClearBtn = document.getElementById("bulk-clear");
|
const bulkClearBtn = document.getElementById("bulk-clear");
|
||||||
|
const bulkGroupBtn = document.getElementById("bulk-group");
|
||||||
const serverVersionText = document.getElementById("server-version-text");
|
const serverVersionText = document.getElementById("server-version-text");
|
||||||
const selectedClients = new Set();
|
const selectedClients = new Set();
|
||||||
let lastNonOnlineStatus = "all";
|
let lastNonOnlineStatus = "all";
|
||||||
@@ -785,6 +786,115 @@ bulkUninstallBtn?.addEventListener("click", async () => {
|
|||||||
setTimeout(() => loadWithOptions({ force: true }), 1000);
|
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 = `
|
||||||
|
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group for ${clientIds.length} client(s)</h3>
|
||||||
|
<div class="bulk-group-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
|
||||||
|
<div class="border-t border-slate-700 pt-3">
|
||||||
|
<p class="text-xs text-slate-400 mb-2">Create new group</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
|
||||||
|
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
|
||||||
|
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<i class="fa-solid fa-xmark text-slate-500"></i> 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 = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background:${g.color}"></span> ${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.toggleClientSelection = toggleClientSelection;
|
||||||
window.isClientSelected = (clientId) => selectedClients.has(clientId);
|
window.isClientSelected = (clientId) => selectedClients.has(clientId);
|
||||||
window.syncClientSelection = syncSelectionState;
|
window.syncClientSelection = syncSelectionState;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { mountNav } from "./nav/template.js";
|
|||||||
import { createAdaptiveNavController } from "./nav/layout.js";
|
import { createAdaptiveNavController } from "./nav/layout.js";
|
||||||
import { applyUserRoleUI } from "./nav/role-ui.js";
|
import { applyUserRoleUI } from "./nav/role-ui.js";
|
||||||
import { showCertBannerIfNeeded } from "./cert-banner.js";
|
import { showCertBannerIfNeeded } from "./cert-banner.js";
|
||||||
|
import * as chatWidget from "./chat-widget.js";
|
||||||
|
|
||||||
const host = document.getElementById("top-nav");
|
const host = document.getElementById("top-nav");
|
||||||
if (host) {
|
if (host) {
|
||||||
@@ -137,4 +138,19 @@ if (host) {
|
|||||||
if (refs.usernameDisplay && refs.roleBadge) {
|
if (refs.usernameDisplay && refs.roleBadge) {
|
||||||
loadCurrentUser();
|
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 = '<i class="fa-solid fa-comments"></i><span class="sb-text">Chat</span>';
|
||||||
|
restoreBtn.addEventListener("click", () => {
|
||||||
|
chatWidget.show();
|
||||||
|
restoreBtn.remove();
|
||||||
|
});
|
||||||
|
refs.navUtility.insertBefore(restoreBtn, refs.navUtility.firstChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -694,6 +694,53 @@ async function saveAppearanceSettings(event) {
|
|||||||
showMessage("Custom CSS saved. Reload the page to apply the new styles.");
|
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") {
|
function showExportImportMessage(text, type = "ok") {
|
||||||
if (!exportImportMessage) return;
|
if (!exportImportMessage) return;
|
||||||
exportImportMessage.textContent = text;
|
exportImportMessage.textContent = text;
|
||||||
@@ -1015,6 +1062,7 @@ async function init() {
|
|||||||
await loadSecurityPolicy();
|
await loadSecurityPolicy();
|
||||||
await loadTlsSettings();
|
await loadTlsSettings();
|
||||||
await loadAppearanceSettings();
|
await loadAppearanceSettings();
|
||||||
|
await loadChatSettings();
|
||||||
await loadBannedIps();
|
await loadBannedIps();
|
||||||
|
|
||||||
passwordForm.addEventListener("submit", updatePassword);
|
passwordForm.addEventListener("submit", updatePassword);
|
||||||
@@ -1039,6 +1087,7 @@ async function init() {
|
|||||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||||
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
||||||
|
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
|
||||||
if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings);
|
if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings);
|
||||||
if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings);
|
if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings);
|
||||||
refreshBansBtn.addEventListener("click", loadBannedIps);
|
refreshBansBtn.addEventListener("click", loadBannedIps);
|
||||||
|
|||||||
@@ -154,6 +154,12 @@
|
|||||||
>
|
>
|
||||||
<i class="fa-solid fa-trash"></i> Uninstall
|
<i class="fa-solid fa-trash"></i> Uninstall
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="bulk-group"
|
||||||
|
class="px-3 py-2 rounded-lg bg-blue-800/60 hover:bg-blue-700 text-blue-100 border border-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-layer-group"></i> Set Group
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
id="bulk-clear"
|
id="bulk-clear"
|
||||||
class="px-3 py-2 rounded-lg bg-slate-800/60 hover:bg-slate-700 text-slate-300 border border-slate-600 transition-colors"
|
class="px-3 py-2 rounded-lg bg-slate-800/60 hover:bg-slate-700 text-slate-300 border border-slate-600 transition-colors"
|
||||||
|
|||||||
@@ -586,6 +586,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Team Chat -->
|
||||||
|
<section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
|
||||||
|
<i class="fa-solid fa-comments text-sky-400"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Team Chat</h2>
|
||||||
|
<p class="text-sm text-slate-400">Admin-only. Configure message retention for the team chat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="chat-settings-form" class="space-y-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-300 font-medium">Message Retention (days)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="chat-retention-days"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
step="1"
|
||||||
|
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Messages older than this will be automatically deleted. Set to 0 to keep messages forever. Max 365 days.</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="chat-settings-save-btn"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-700 hover:bg-sky-600 transition-colors text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>Save Chat Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export interface Config {
|
|||||||
plugins: {
|
plugins: {
|
||||||
trustedKeys: string[];
|
trustedKeys: string[];
|
||||||
};
|
};
|
||||||
|
chat: {
|
||||||
|
retentionDays: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
@@ -120,6 +123,9 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
trustedKeys: [],
|
trustedKeys: [],
|
||||||
},
|
},
|
||||||
|
chat: {
|
||||||
|
retentionDays: 30,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SaveSecrets = {
|
type SaveSecrets = {
|
||||||
@@ -447,6 +453,12 @@ export function loadConfig(): Config {
|
|||||||
return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys;
|
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) {
|
if (saveChanged) {
|
||||||
@@ -656,6 +668,35 @@ export async function updatePluginsConfig(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateChatConfig(
|
||||||
|
updates: Partial<Config["chat"]>,
|
||||||
|
): Promise<Config["chat"]> {
|
||||||
|
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<string, unknown> {
|
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
return {
|
return {
|
||||||
@@ -673,6 +714,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
|||||||
enrollment: config.enrollment,
|
enrollment: config.enrollment,
|
||||||
appearance: config.appearance,
|
appearance: config.appearance,
|
||||||
plugins: config.plugins,
|
plugins: config.plugins,
|
||||||
|
chat: config.chat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +773,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
|
|||||||
applied.push("plugins");
|
applied.push("plugins");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.chat && typeof data.chat === "object") {
|
||||||
|
await updateChatConfig(data.chat);
|
||||||
|
applied.push("chat");
|
||||||
|
}
|
||||||
|
|
||||||
if (data.auth && typeof data.auth === "object") {
|
if (data.auth && typeof data.auth === "object") {
|
||||||
const dataDir = ensureDataDir();
|
const dataDir = ensureDataDir();
|
||||||
const savePath = resolve(dataDir, "save.json");
|
const savePath = resolve(dataDir, "save.json");
|
||||||
|
|||||||
@@ -293,6 +293,81 @@ db.run(
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_auto_deploy_runs_ts ON auto_deploy_runs(ts DESC);`,
|
`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<any>(
|
||||||
|
`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<any>(
|
||||||
|
`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<any>(
|
||||||
|
`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<any>(
|
||||||
|
`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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS shared_files (
|
CREATE TABLE IF NOT EXISTS shared_files (
|
||||||
id TEXT PRIMARY KEY,
|
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 {
|
export function countBuildsForUser(userId: number): number {
|
||||||
const row = db
|
const row = db
|
||||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`)
|
.query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { logger } from "./logger";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
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 { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
|
||||||
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
||||||
import { ClientInfo, ClientRole } from "./types";
|
import { ClientInfo, ClientRole } from "./types";
|
||||||
@@ -12,13 +12,14 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { authenticateRequest } from "./auth";
|
import { authenticateRequest } from "./auth";
|
||||||
import { loadConfig, getConfig } from "./config";
|
import { loadConfig, getConfig } from "./config";
|
||||||
import { flushAuditLogsSync } from "./auditLog";
|
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 { requireAuth, requirePermission } from "./rbac";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||||
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||||
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
|
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
|
||||||
|
import { handleChatRoutes } from "./server/routes/chat-routes";
|
||||||
import { handleBuildRoutes } from "./server/routes/build-routes";
|
import { handleBuildRoutes } from "./server/routes/build-routes";
|
||||||
import { handleSolRoutes } from "./server/routes/sol-routes";
|
import { handleSolRoutes } from "./server/routes/sol-routes";
|
||||||
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
||||||
@@ -507,6 +508,54 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
||||||
|
handleChatViewerOpen: (ws: import("bun").ServerWebSocket<SocketData>) => {
|
||||||
|
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<SocketData>, 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,
|
handleConsoleViewerMessage,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
handleWebcamViewerMessage,
|
handleWebcamViewerMessage,
|
||||||
@@ -586,6 +635,7 @@ async function startServer() {
|
|||||||
handleAutoScriptsRoutes,
|
handleAutoScriptsRoutes,
|
||||||
handleAutoDeployRoutes,
|
handleAutoDeployRoutes,
|
||||||
handleEnrollmentRoutes,
|
handleEnrollmentRoutes,
|
||||||
|
handleChatRoutes,
|
||||||
handleSolRoutes,
|
handleSolRoutes,
|
||||||
handleUsersRoutes,
|
handleUsersRoutes,
|
||||||
handleBuildRoutes,
|
handleBuildRoutes,
|
||||||
@@ -625,6 +675,22 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
if (expiredPaths.length) logger.info(`[db] Cleaned up ${expiredPaths.length} expired shared files`);
|
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({
|
startMaintenanceLoops({
|
||||||
getClients: clientManager.getAllClients,
|
getClients: clientManager.getAllClients,
|
||||||
setOnlineState,
|
setOnlineState,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type Permission =
|
|||||||
| "clients:view"
|
| "clients:view"
|
||||||
| "clients:build"
|
| "clients:build"
|
||||||
| "clients:enroll"
|
| "clients:enroll"
|
||||||
| "audit:view";
|
| "audit:view"
|
||||||
|
| "chat:write";
|
||||||
|
|
||||||
export function checkPermission(
|
export function checkPermission(
|
||||||
user: AuthenticatedUser | null,
|
user: AuthenticatedUser | null,
|
||||||
@@ -80,6 +81,8 @@ export function getPermissionDescription(permission: Permission): string {
|
|||||||
return "Manage client enrollment approvals";
|
return "Manage client enrollment approvals";
|
||||||
case "audit:view":
|
case "audit:view":
|
||||||
return "View audit logs";
|
return "View audit logs";
|
||||||
|
case "chat:write":
|
||||||
|
return "Send messages in team chat";
|
||||||
default:
|
default:
|
||||||
return "Unknown permission";
|
return "Unknown permission";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type HttpDispatchDeps<
|
|||||||
handleAutoScriptsRoutes: RouteHandler;
|
handleAutoScriptsRoutes: RouteHandler;
|
||||||
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
|
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
|
||||||
handleEnrollmentRoutes: RouteHandler;
|
handleEnrollmentRoutes: RouteHandler;
|
||||||
|
handleChatRoutes: RouteHandler;
|
||||||
handleSolRoutes: RouteHandler;
|
handleSolRoutes: RouteHandler;
|
||||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||||
@@ -128,6 +129,9 @@ export function createHttpFetchHandler<
|
|||||||
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
|
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
|
||||||
if (enrollmentResponse) return enrollmentResponse;
|
if (enrollmentResponse) return enrollmentResponse;
|
||||||
|
|
||||||
|
const chatResponse = await deps.handleChatRoutes(req, url);
|
||||||
|
if (chatResponse) return chatResponse;
|
||||||
|
|
||||||
const solResponse = await deps.handleSolRoutes(req, url);
|
const solResponse = await deps.handleSolRoutes(req, url);
|
||||||
if (solResponse) return solResponse;
|
if (solResponse) return solResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -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<Response | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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$/);
|
const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/);
|
||||||
if (req.method === "PATCH" && clientGroupMatch) {
|
if (req.method === "PATCH" && clientGroupMatch) {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
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 { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { requirePermission } from "../../rbac";
|
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") {
|
if (url.pathname === "/api/settings/appearance") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ type WsLifecycleDeps = {
|
|||||||
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleVoiceViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleVoiceViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
|
handleChatViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
|
handleChatViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
@@ -151,6 +153,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
|||||||
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
|
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
|
||||||
if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws);
|
if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws);
|
||||||
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
|
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
|
||||||
|
if (role === "chat_viewer") return deps.handleChatViewerOpen(ws);
|
||||||
|
|
||||||
const id = clientId || uuidv4();
|
const id = clientId || uuidv4();
|
||||||
ws.data.clientId = id;
|
ws.data.clientId = id;
|
||||||
@@ -202,6 +205,7 @@ export async function handleWebSocketMessage(
|
|||||||
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
|
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
|
||||||
if (socketRole === "notifications_viewer") return;
|
if (socketRole === "notifications_viewer") return;
|
||||||
if (socketRole === "dashboard_viewer") return;
|
if (socketRole === "dashboard_viewer") return;
|
||||||
|
if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message);
|
||||||
|
|
||||||
const { clientId, ip } = ws.data;
|
const { clientId, ip } = ws.data;
|
||||||
|
|
||||||
@@ -740,6 +744,13 @@ export function handleWebSocketClose(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "chat_viewer") {
|
||||||
|
if (sessionId) {
|
||||||
|
sessionManager.deleteChatSession(sessionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentClient = clientManager.getClient(clientId);
|
const currentClient = clientManager.getClient(clientId);
|
||||||
if (currentClient && currentClient.ws !== ws) {
|
if (currentClient && currentClient.ws !== ws) {
|
||||||
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
|
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
|
||||||
|
|||||||
@@ -271,5 +271,17 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Upgrade failed", { status: 500 });
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
VoiceViewer,
|
VoiceViewer,
|
||||||
NotificationsViewer,
|
NotificationsViewer,
|
||||||
KeyloggerViewer,
|
KeyloggerViewer,
|
||||||
|
ChatViewer,
|
||||||
SocketData,
|
SocketData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const keyloggerSessionsByClient = new Map<string, Set<string>>();
|
|||||||
const voiceSessions = new Map<string, VoiceViewer>();
|
const voiceSessions = new Map<string, VoiceViewer>();
|
||||||
const voiceSessionsByClient = new Map<string, Set<string>>();
|
const voiceSessionsByClient = new Map<string, Set<string>>();
|
||||||
const dashboardSessions = new Map<string, DashboardViewer>();
|
const dashboardSessions = new Map<string, DashboardViewer>();
|
||||||
|
const chatSessions = new Map<string, ChatViewer>();
|
||||||
|
|
||||||
function addSessionToClientIndex(
|
function addSessionToClientIndex(
|
||||||
index: Map<string, Set<string>>,
|
index: Map<string, Set<string>>,
|
||||||
@@ -495,3 +497,29 @@ export function notifyDashboardViewers(): void {
|
|||||||
}
|
}
|
||||||
}, DASHBOARD_DEBOUNCE_MS);
|
}, 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<string, ChatViewer> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export type SocketRole =
|
|||||||
| "notifications_viewer"
|
| "notifications_viewer"
|
||||||
| "keylogger_viewer"
|
| "keylogger_viewer"
|
||||||
| "voice_viewer"
|
| "voice_viewer"
|
||||||
| "dashboard_viewer";
|
| "dashboard_viewer"
|
||||||
|
| "chat_viewer";
|
||||||
|
|
||||||
export type SocketData = {
|
export type SocketData = {
|
||||||
role: SocketRole;
|
role: SocketRole;
|
||||||
@@ -77,3 +78,12 @@ export type VoiceViewer = {
|
|||||||
viewer: ServerWebSocket<SocketData>;
|
viewer: ServerWebSocket<SocketData>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatViewer = {
|
||||||
|
id: string;
|
||||||
|
viewer: ServerWebSocket<SocketData>;
|
||||||
|
createdAt: number;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
userRole: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
@@ -747,6 +756,9 @@ export function hasPermission(role: UserRole, permission: string, userId?: numbe
|
|||||||
return canManageEnrollment(role);
|
return canManageEnrollment(role);
|
||||||
case "audit:view":
|
case "audit:view":
|
||||||
return canViewAuditLogs(role);
|
return canViewAuditLogs(role);
|
||||||
|
case "chat:write":
|
||||||
|
if (userId !== undefined) return canChatWrite(userId, role);
|
||||||
|
return role === "admin" || role === "operator";
|
||||||
default:
|
default:
|
||||||
return false;
|
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(
|
export function setUserTelegramChatId(
|
||||||
userId: number,
|
userId: number,
|
||||||
chatId: string | null,
|
chatId: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user