1763 lines
60 KiB
JavaScript
1763 lines
60 KiB
JavaScript
import { state } from "./state.js";
|
|
import { debounce } from "./utils.js";
|
|
import { createRenderer } from "./render.js";
|
|
import { openMenu, closeMenu, openModal, wireModalClose, menu } from "./ui.js";
|
|
import {
|
|
registerRenderer,
|
|
loadWithOptions,
|
|
startAutoRefresh,
|
|
sendCommand,
|
|
requestPreview,
|
|
requestThumbnail,
|
|
markManualDisconnect,
|
|
} from "./data.js";
|
|
import { ThumbnailLoader } from "./thumbnail-loader.js";
|
|
|
|
const grid = document.getElementById("grid");
|
|
const totalPill = document.getElementById("total-pill");
|
|
const pageLabel = document.getElementById("page-label");
|
|
const prevBtn = document.getElementById("prev");
|
|
const nextBtn = document.getElementById("next");
|
|
const searchInput = document.getElementById("search");
|
|
const sortSelect = document.getElementById("sort");
|
|
const filterStatusSelect = document.getElementById("filter-status");
|
|
const filterOsSelect = document.getElementById("filter-os");
|
|
const filterGroupSelect = document.getElementById("filter-group");
|
|
const showOfflineToggle = document.getElementById("toggle-offline");
|
|
const selectAllBtn = document.getElementById("select-all");
|
|
const clearSelectionBtn = document.getElementById("clear-selection");
|
|
const logoutBtn = document.getElementById("logout-btn");
|
|
const usernameDisplay = document.getElementById("username-display");
|
|
const roleBadge = document.getElementById("role-badge");
|
|
const usersLink = document.getElementById("users-link");
|
|
const buildLink = document.getElementById("build-link");
|
|
const deployLink = document.getElementById("deploy-link");
|
|
|
|
const bulkToolbar = document.getElementById("bulk-toolbar");
|
|
const selectedCountSpan = document.getElementById("selected-count");
|
|
const bulkScreenshotBtn = document.getElementById("bulk-screenshot");
|
|
const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
|
|
const bulkUninstallBtn = document.getElementById("bulk-uninstall");
|
|
const bulkClearBtn = document.getElementById("bulk-clear");
|
|
const bulkGroupBtn = document.getElementById("bulk-group");
|
|
const bulkMuteBtn = document.getElementById("bulk-mute");
|
|
const bulkUnmuteBtn = document.getElementById("bulk-unmute");
|
|
const serverVersionText = document.getElementById("server-version-text");
|
|
const selectedClients = new Set();
|
|
let lastNonOnlineStatus = "all";
|
|
const PREF_FILTER_STATUS_KEY = "overlord_filter_status";
|
|
const PREF_SORT_KEY = "overlord_sort";
|
|
const PREF_FILTER_OS_KEY = "overlord_filter_os";
|
|
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
|
|
const PREF_FILTER_GROUP_KEY = "overlord_filter_group";
|
|
|
|
let currentUser = null;
|
|
let contextCard = null;
|
|
let availableOsList = new Set();
|
|
|
|
function setServerVersionLabel(version) {
|
|
if (!serverVersionText) return;
|
|
serverVersionText.textContent = "Server version: ";
|
|
const value = document.createElement("span");
|
|
value.className = "server-version-number";
|
|
value.textContent = version;
|
|
serverVersionText.appendChild(value);
|
|
}
|
|
|
|
async function loadServerVersion() {
|
|
if (!serverVersionText) return;
|
|
try {
|
|
const res = await fetch("/api/version", { credentials: "include" });
|
|
if (!res.ok) {
|
|
serverVersionText.textContent = "Server version: unavailable";
|
|
return;
|
|
}
|
|
const payload = await res.json();
|
|
const version = typeof payload?.version === "string" && payload.version.trim()
|
|
? payload.version.trim()
|
|
: "unknown";
|
|
setServerVersionLabel(version);
|
|
} catch {
|
|
serverVersionText.textContent = "Server version: unavailable";
|
|
}
|
|
}
|
|
|
|
const setContext = (id) => {
|
|
contextCard = id;
|
|
};
|
|
const clearContext = () => {
|
|
contextCard = null;
|
|
};
|
|
|
|
let _pluginCache = null;
|
|
let _pluginCacheAge = 0;
|
|
const PLUGIN_CACHE_TTL = 30_000;
|
|
|
|
async function refreshPluginCache() {
|
|
try {
|
|
const res = await fetch("/api/plugins");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
_pluginCache = Array.isArray(data.plugins) ? data.plugins : [];
|
|
_pluginCacheAge = Date.now();
|
|
} catch {}
|
|
}
|
|
|
|
function getCachedPlugins() {
|
|
if (_pluginCache && Date.now() - _pluginCacheAge < PLUGIN_CACHE_TTL) {
|
|
return _pluginCache;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function detectClientPlatform(clientId) {
|
|
if (!clientId) {
|
|
return "unknown";
|
|
}
|
|
const selectorId =
|
|
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
? CSS.escape(clientId)
|
|
: clientId;
|
|
const card = document.querySelector(`[data-client-row][data-id="${selectorId}"]`);
|
|
const os = String(card?.dataset?.os || "").toLowerCase();
|
|
if (os.includes("windows")) return "windows";
|
|
if (os.includes("darwin") || os.includes("mac")) return "mac";
|
|
if (os.includes("linux")) return "linux";
|
|
return "unknown";
|
|
}
|
|
|
|
function getClientCard(clientId) {
|
|
if (!clientId) return null;
|
|
const selectorId =
|
|
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
? CSS.escape(clientId)
|
|
: clientId;
|
|
return document.querySelector(`[data-client-row][data-id="${selectorId}"]`);
|
|
}
|
|
|
|
window.__uninstallingClientIds = window.__uninstallingClientIds || new Set();
|
|
|
|
function flipFallAndRemove(clientId, { delayMs = 0 } = {}) {
|
|
const card = getClientCard(clientId);
|
|
if (!card || card.classList.contains("card-uninstalling")) return;
|
|
window.__uninstallingClientIds.add(clientId);
|
|
if (delayMs > 0) card.style.animationDelay = `${delayMs}ms`;
|
|
card.classList.add("card-uninstalling");
|
|
|
|
let removed = false;
|
|
const cleanup = () => {
|
|
if (removed) return;
|
|
removed = true;
|
|
card.remove();
|
|
setTimeout(() => window.__uninstallingClientIds.delete(clientId), 2500);
|
|
};
|
|
card.addEventListener("animationend", cleanup, { once: true });
|
|
setTimeout(cleanup, 1100 + delayMs + 400);
|
|
}
|
|
|
|
function openTagNoteEditor(clientId, currentTag, currentNote) {
|
|
return new Promise((resolve) => {
|
|
const overlay = document.createElement("div");
|
|
overlay.className =
|
|
"fixed inset-0 z-[10001] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4";
|
|
overlay.innerHTML = `
|
|
<div class="w-full max-w-xl rounded-xl border border-slate-700 bg-slate-900 p-5 shadow-2xl">
|
|
<h3 class="text-lg font-semibold text-slate-100">Custom Tag</h3>
|
|
<p id="tag-editor-client" class="mt-1 text-sm text-slate-400"></p>
|
|
<label class="mt-4 block text-sm text-slate-300">Tag</label>
|
|
<input id="tag-editor-input" type="text" class="mt-1 w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="e.g. VIP, Priority, Finance">
|
|
<label class="mt-4 block text-sm text-slate-300">Note</label>
|
|
<textarea id="tag-editor-note" class="mt-1 min-h-[220px] w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Write as much as you need. No note length limit."></textarea>
|
|
<div class="mt-4 flex items-center justify-between gap-2">
|
|
<button id="tag-editor-clear" class="rounded-lg border border-rose-700 bg-rose-900/50 px-3 py-2 text-sm text-rose-100 hover:bg-rose-800">Clear</button>
|
|
<div class="flex items-center gap-2">
|
|
<button id="tag-editor-cancel" class="rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700">Cancel</button>
|
|
<button id="tag-editor-save" class="rounded-lg border border-blue-700 bg-blue-700 px-3 py-2 text-sm text-white hover:bg-blue-600">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const clientLabel = overlay.querySelector("#tag-editor-client");
|
|
if (clientLabel) clientLabel.textContent = clientId;
|
|
const tagInput = overlay.querySelector("#tag-editor-input");
|
|
if (tagInput) tagInput.value = currentTag || "";
|
|
const textarea = overlay.querySelector("#tag-editor-note");
|
|
textarea.value = currentNote || "";
|
|
|
|
const closeWith = (result) => {
|
|
overlay.remove();
|
|
resolve(result);
|
|
};
|
|
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) closeWith(null);
|
|
});
|
|
|
|
overlay
|
|
.querySelector("#tag-editor-cancel")
|
|
?.addEventListener("click", () => closeWith(null));
|
|
overlay
|
|
.querySelector("#tag-editor-clear")
|
|
?.addEventListener("click", () => closeWith({ tag: "", note: "" }));
|
|
overlay.querySelector("#tag-editor-save")?.addEventListener("click", () => {
|
|
const tag = String(overlay.querySelector("#tag-editor-input")?.value || "").trim();
|
|
const note = String(overlay.querySelector("#tag-editor-note")?.value || "");
|
|
closeWith({ tag, note });
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector("#tag-editor-input")?.focus();
|
|
});
|
|
}
|
|
|
|
function isClientOnline(clientId) {
|
|
return getClientCard(clientId)?.dataset.online === "true";
|
|
}
|
|
|
|
function applyMenuSupportRules(clientId) {
|
|
const platform = detectClientPlatform(clientId);
|
|
const isWindows = platform === "windows";
|
|
const isOnline = isClientOnline(clientId);
|
|
|
|
const setAvailability = (btn, enabled, reason) => {
|
|
if (!btn) return;
|
|
btn.disabled = !enabled;
|
|
btn.setAttribute("aria-disabled", String(!enabled));
|
|
btn.classList.toggle("opacity-50", !enabled);
|
|
btn.classList.toggle("cursor-not-allowed", !enabled);
|
|
btn.classList.toggle("hover:bg-slate-700", enabled);
|
|
btn.classList.toggle("hover:bg-slate-800/50", !enabled);
|
|
btn.title = enabled ? "" : reason;
|
|
};
|
|
|
|
const hvncBtn = menu.querySelector('[data-open="Backstage"]');
|
|
setAvailability(hvncBtn, isOnline && isWindows, isOnline ? "Backstage is only supported on Windows clients." : "Client is offline");
|
|
|
|
const webcamBtn = menu.querySelector('[data-open="webcam"]');
|
|
setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline");
|
|
|
|
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
|
setAvailability(
|
|
keyloggerBtn,
|
|
isOnline && isWindows,
|
|
isOnline ? "Keylogger capture is only fully supported on Windows clients." : "Client is offline",
|
|
);
|
|
|
|
const winreBtn = menu.querySelector('[data-open="winre"]');
|
|
if (winreBtn) {
|
|
setAvailability(winreBtn, isOnline && isWindows, isOnline ? "WinRE Persistence is only supported on Windows clients." : "Client is offline");
|
|
const label = winreBtn.querySelector("span");
|
|
if (label) label.style.textDecoration = isWindows ? "" : "line-through";
|
|
}
|
|
|
|
const elevateBtn = menu.querySelector('[data-action="elevate"]');
|
|
if (elevateBtn) {
|
|
const canElevate = platform === "mac" || platform === "windows";
|
|
elevateBtn.style.display = canElevate ? "" : "none";
|
|
if (canElevate) setAvailability(elevateBtn, isOnline, "Client is offline");
|
|
}
|
|
|
|
setAvailability(menu.querySelector('[data-open="console"]'), isOnline, "Client is offline");
|
|
setAvailability(menu.querySelector('[data-open="remotedesktop"]'), isOnline, "Client is offline");
|
|
setAvailability(menu.querySelector('[data-open="voice"]'), isOnline, "Client is offline");
|
|
setAvailability(menu.querySelector('[data-open="processes"]'), isOnline, "Client is offline");
|
|
setAvailability(menu.querySelector('[data-open="files"]'), isOnline, "Client is offline");
|
|
const silentExecBtn = menu.querySelector('[data-open="silent-exec"]');
|
|
if (silentExecBtn && !silentExecBtn.classList.contains("hidden")) {
|
|
setAvailability(silentExecBtn, isOnline, "Client is offline");
|
|
}
|
|
|
|
["remote-access", "monitoring", "system"].forEach(groupId => {
|
|
setAvailability(menu.querySelector(`[data-group-toggle="${groupId}"]`), isOnline, "Client is offline");
|
|
});
|
|
|
|
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
|
|
setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline");
|
|
});
|
|
|
|
applyFeaturePermissionRules();
|
|
}
|
|
|
|
const MENU_OPEN_TO_FEATURE = {
|
|
console: "console",
|
|
remotedesktop: "remote_desktop",
|
|
Backstage: "hvnc",
|
|
webcam: "webcam",
|
|
files: "file_browser",
|
|
processes: "processes",
|
|
keylogger: "keylogger",
|
|
voice: "voice",
|
|
};
|
|
|
|
function applyFeaturePermissionRules() {
|
|
const perms = currentUser?.featurePermissions;
|
|
if (!perms) return;
|
|
|
|
for (const [openKey, feature] of Object.entries(MENU_OPEN_TO_FEATURE)) {
|
|
const btn = menu.querySelector(`[data-open="${openKey}"]`);
|
|
if (!btn) continue;
|
|
if (perms[feature] === false) {
|
|
btn.disabled = true;
|
|
btn.setAttribute("aria-disabled", "true");
|
|
btn.classList.add("opacity-50", "cursor-not-allowed");
|
|
btn.classList.remove("hover:bg-slate-700");
|
|
btn.classList.add("hover:bg-slate-800/50");
|
|
btn.title = "Feature access disabled by administrator";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadCurrentUser() {
|
|
loadServerVersion();
|
|
try {
|
|
const res = await fetch("/api/auth/me");
|
|
if (res.ok) {
|
|
currentUser = await res.json();
|
|
if (currentUser && currentUser.username && currentUser.role) {
|
|
if (!usernameDisplay || !roleBadge) {
|
|
return;
|
|
}
|
|
usernameDisplay.textContent = currentUser.username;
|
|
|
|
const roleBadges = {
|
|
admin: '<i class="fa-solid fa-crown mr-1"></i>Admin',
|
|
operator: '<i class="fa-solid fa-sliders mr-1"></i>Operator',
|
|
viewer: '<i class="fa-solid fa-eye mr-1"></i>Viewer',
|
|
};
|
|
if (roleBadges[currentUser.role]) {
|
|
roleBadge.innerHTML = roleBadges[currentUser.role];
|
|
} else {
|
|
roleBadge.textContent = currentUser.role || "";
|
|
}
|
|
|
|
if (currentUser.role === "admin") {
|
|
roleBadge.classList.add(
|
|
"bg-purple-900/50",
|
|
"text-purple-300",
|
|
"border",
|
|
"border-purple-800",
|
|
);
|
|
} else if (currentUser.role === "operator") {
|
|
roleBadge.classList.add(
|
|
"bg-blue-900/50",
|
|
"text-blue-300",
|
|
"border",
|
|
"border-blue-800",
|
|
);
|
|
} else {
|
|
roleBadge.classList.add(
|
|
"bg-slate-700",
|
|
"text-slate-300",
|
|
"border",
|
|
"border-slate-600",
|
|
);
|
|
}
|
|
|
|
if (currentUser.role === "admin") {
|
|
usersLink?.classList.remove("hidden");
|
|
}
|
|
|
|
if (currentUser.role === "admin" && !localStorage.getItem("overlord_settings_exported")) {
|
|
localStorage.setItem("overlord_settings_exported", "1");
|
|
try {
|
|
const expRes = await fetch("/api/settings/export", { credentials: "include" });
|
|
if (expRes.ok) {
|
|
const blob = await expRes.blob();
|
|
const disposition = expRes.headers.get("Content-Disposition") || "";
|
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
|
const filename = match ? match[1] : "overlord-settings.json";
|
|
const dlUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = dlUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(dlUrl);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) {
|
|
buildLink?.classList.remove("hidden");
|
|
}
|
|
|
|
if (currentUser.role === "admin") {
|
|
const pluginsLink = document.getElementById("plugins-link");
|
|
pluginsLink?.classList.remove("hidden");
|
|
deployLink?.classList.remove("hidden");
|
|
document.getElementById("menu-silent-exec")?.classList.remove("hidden");
|
|
}
|
|
|
|
const scriptsLink = document.getElementById("scripts-link");
|
|
if (currentUser.role !== "viewer") {
|
|
scriptsLink?.classList.remove("hidden");
|
|
}
|
|
|
|
initializeRenderer();
|
|
}
|
|
|
|
initializeRenderer();
|
|
refreshPluginCache();
|
|
} else {
|
|
window.location.href = "/";
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load user:", err);
|
|
}
|
|
}
|
|
|
|
function showPluginConfirmModal(pluginId, clientId, sigInfo) {
|
|
document.getElementById("plugin-confirm-modal")?.remove();
|
|
|
|
const isSigned = sigInfo && sigInfo.signed && sigInfo.valid;
|
|
const statusText = isSigned
|
|
? `This plugin is signed but the signer's key is not trusted.`
|
|
: `This plugin is not signed and its origin cannot be verified.`;
|
|
const fpText = sigInfo?.fingerprint
|
|
? `Signer fingerprint: ${sigInfo.fingerprint}`
|
|
: "No signature present";
|
|
|
|
const modal = document.createElement("div");
|
|
modal.id = "plugin-confirm-modal";
|
|
modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
|
|
modal.innerHTML = `
|
|
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center ${isSigned ? 'bg-yellow-900/60 text-yellow-400' : 'bg-orange-900/60 text-orange-400'}">
|
|
<i class="fa-solid ${isSigned ? 'fa-shield' : 'fa-shield-halved'} text-lg"></i>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-lg">${isSigned ? 'Untrusted Plugin' : 'Unsigned Plugin'}</h3>
|
|
<p class="text-sm text-slate-400">${pluginId}</p>
|
|
</div>
|
|
</div>
|
|
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
|
|
<p class="text-xs text-slate-500 font-mono mb-4">${fpText}</p>
|
|
<p class="text-sm text-slate-300 mb-3">Type <strong class="text-white">confirm</strong> below to load this plugin:</p>
|
|
<input
|
|
id="plugin-confirm-input"
|
|
type="text"
|
|
placeholder="Type confirm…"
|
|
autocomplete="off"
|
|
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-slate-100 text-sm placeholder-slate-500 focus:outline-none focus:border-emerald-600 mb-4"
|
|
/>
|
|
<div class="flex gap-3 justify-end">
|
|
<button id="plugin-confirm-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
|
|
<button id="plugin-confirm-load" disabled class="px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 text-emerald-100 opacity-50 cursor-not-allowed">Load Anyway</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
const input = document.getElementById("plugin-confirm-input");
|
|
const loadBtn = document.getElementById("plugin-confirm-load");
|
|
const cancelBtn = document.getElementById("plugin-confirm-cancel");
|
|
|
|
input.addEventListener("input", () => {
|
|
const match = input.value.trim().toLowerCase() === "confirm";
|
|
loadBtn.disabled = !match;
|
|
loadBtn.classList.toggle("opacity-50", !match);
|
|
loadBtn.classList.toggle("cursor-not-allowed", !match);
|
|
loadBtn.classList.toggle("hover:bg-emerald-800/60", match);
|
|
});
|
|
|
|
cancelBtn.addEventListener("click", () => modal.remove());
|
|
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
|
|
|
|
loadBtn.addEventListener("click", async () => {
|
|
if (loadBtn.disabled) return;
|
|
loadBtn.disabled = true;
|
|
loadBtn.textContent = "Loading…";
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/plugins/${pluginId}/load`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirmed: true }),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
alert(`Plugin load failed: ${text}`);
|
|
} else {
|
|
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
|
|
}
|
|
} catch {
|
|
alert("Plugin load failed");
|
|
}
|
|
modal.remove();
|
|
});
|
|
|
|
input.focus();
|
|
}
|
|
|
|
function renderPluginMenu(plugins) {
|
|
const section = document.getElementById("plugin-section");
|
|
const container = document.getElementById("plugin-menu");
|
|
if (!section || !container) return;
|
|
container.innerHTML = "";
|
|
|
|
const enabled = plugins.filter(p => p.enabled !== false);
|
|
if (!enabled.length) {
|
|
section.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
section.classList.remove("hidden");
|
|
for (const plugin of enabled) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "ctx-item ctx-plugin-item";
|
|
btn.dataset.plugin = plugin.id;
|
|
btn.dataset.loaded = plugin.loaded ? "true" : "false";
|
|
if (plugin.lastError) {
|
|
btn.title = `Last error: ${plugin.lastError}`;
|
|
}
|
|
|
|
const sig = plugin.signature;
|
|
if (sig && sig.signed && !sig.valid) {
|
|
btn.disabled = true;
|
|
btn.setAttribute("aria-disabled", "true");
|
|
btn.title = "Plugin signature is invalid — cannot load";
|
|
}
|
|
|
|
const labelIcon = document.createElement("i");
|
|
labelIcon.className =
|
|
"fa-solid fa-puzzle-piece ctx-icon " +
|
|
(plugin.loaded ? "text-emerald-400" : "text-fuchsia-400");
|
|
btn.appendChild(labelIcon);
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "ctx-plugin-label";
|
|
label.textContent = plugin.name || plugin.id;
|
|
btn.appendChild(label);
|
|
|
|
if (sig) {
|
|
const trustIcon = document.createElement("i");
|
|
if (sig.signed && !sig.valid) {
|
|
trustIcon.className = "fa-solid fa-shield-xmark ctx-plugin-trust text-red-400";
|
|
trustIcon.title = "Invalid signature";
|
|
} else if (sig.signed && sig.valid && sig.trusted) {
|
|
trustIcon.className = "fa-solid fa-shield-check ctx-plugin-trust text-emerald-400";
|
|
trustIcon.title = "Trusted";
|
|
} else if (sig.signed && sig.valid && !sig.trusted) {
|
|
trustIcon.className = "fa-solid fa-shield ctx-plugin-trust text-yellow-400";
|
|
trustIcon.title = "Signed but untrusted";
|
|
} else {
|
|
trustIcon.className = "fa-solid fa-shield-halved ctx-plugin-trust text-orange-400";
|
|
trustIcon.title = "Unsigned";
|
|
}
|
|
btn.appendChild(trustIcon);
|
|
}
|
|
|
|
const badge = document.createElement("span");
|
|
badge.className = "ctx-plugin-badge" + (plugin.loaded ? " is-loaded" : "");
|
|
badge.textContent = plugin.loaded ? "loaded" : "available";
|
|
btn.appendChild(badge);
|
|
container.appendChild(btn);
|
|
|
|
if (plugin.loaded) {
|
|
const unloadBtn = document.createElement("button");
|
|
unloadBtn.className = "ctx-item";
|
|
unloadBtn.dataset.pluginUnload = plugin.id;
|
|
const unloadIcon = document.createElement("i");
|
|
unloadIcon.className = "fa-solid fa-plug-circle-xmark ctx-icon text-red-400";
|
|
unloadBtn.appendChild(unloadIcon);
|
|
const unloadText = document.createElement("span");
|
|
unloadText.textContent = `Unload ${plugin.name || plugin.id}`;
|
|
unloadBtn.appendChild(unloadText);
|
|
container.appendChild(unloadBtn);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderScriptMenu(scripts) {
|
|
const section = document.getElementById("script-section");
|
|
const container = document.getElementById("script-menu");
|
|
if (!section || !container) return;
|
|
container.innerHTML = "";
|
|
|
|
if (!Array.isArray(scripts) || scripts.length === 0) {
|
|
section.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
section.classList.remove("hidden");
|
|
const sorted = scripts.slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
for (const script of sorted) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "ctx-item ctx-plugin-item";
|
|
btn.dataset.scriptId = script.id;
|
|
btn.title = `${script.scriptType} — click to open in Scripts with this client preselected`;
|
|
|
|
const labelIcon = document.createElement("i");
|
|
labelIcon.className = "fa-solid fa-scroll ctx-icon text-cyan-400";
|
|
btn.appendChild(labelIcon);
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "ctx-plugin-label";
|
|
label.textContent = script.name || script.id;
|
|
btn.appendChild(label);
|
|
|
|
const badge = document.createElement("span");
|
|
badge.className = "ctx-plugin-badge";
|
|
badge.textContent = script.scriptType || "";
|
|
btn.appendChild(badge);
|
|
|
|
container.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
let _scriptMenuCache = null;
|
|
async function loadSavedScriptsForMenu() {
|
|
const section = document.getElementById("script-section");
|
|
const container = document.getElementById("script-menu");
|
|
if (!section || !container) return;
|
|
|
|
if (_scriptMenuCache) renderScriptMenu(_scriptMenuCache);
|
|
|
|
try {
|
|
const res = await fetch("/api/saved-scripts");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const scripts = Array.isArray(data.items) ? data.items : [];
|
|
_scriptMenuCache = scripts;
|
|
renderScriptMenu(scripts);
|
|
} catch {
|
|
// ignore — cached render (if any) is already showing
|
|
}
|
|
}
|
|
|
|
async function loadPluginsForClient(clientId) {
|
|
const section = document.getElementById("plugin-section");
|
|
const container = document.getElementById("plugin-menu");
|
|
if (!section || !container) return;
|
|
container.innerHTML = "";
|
|
section.classList.add("hidden");
|
|
|
|
const cached = getCachedPlugins();
|
|
if (cached && cached.length) {
|
|
renderPluginMenu(cached);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/plugins`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const plugins = Array.isArray(data.plugins) ? data.plugins : [];
|
|
renderPluginMenu(plugins);
|
|
} catch {
|
|
// ignore — cache render is already showing
|
|
}
|
|
}
|
|
|
|
const PREF_LAYOUT_KEY = "overlord_layout";
|
|
let rendererSetLayout = null;
|
|
|
|
function applyLayoutToggleUI(layout) {
|
|
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
|
|
const active = btn.dataset.layout === layout;
|
|
btn.classList.toggle("is-active", active);
|
|
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
function setLayout(layout) {
|
|
const next = ["rows", "table", "cards"].includes(layout) ? layout : "rows";
|
|
localStorage.setItem(PREF_LAYOUT_KEY, next);
|
|
applyLayoutToggleUI(next);
|
|
if (rendererSetLayout) rendererSetLayout(next);
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
}
|
|
|
|
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => setLayout(btn.dataset.layout));
|
|
});
|
|
|
|
let dashboardThumbnailLoader = null;
|
|
|
|
async function isDashboardThumbnailEnabled() {
|
|
try {
|
|
const res = await fetch("/api/settings/thumbnails", { credentials: "include" });
|
|
if (!res.ok) return true;
|
|
const data = await res.json();
|
|
return data?.thumbnails?.dashboardEnabled !== false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function setupDashboardThumbnailLoader() {
|
|
if (!grid || dashboardThumbnailLoader) return;
|
|
|
|
if (!(await isDashboardThumbnailEnabled())) return;
|
|
|
|
const stored = Number(localStorage.getItem("overlord_dash_thumb_interval_ms"));
|
|
const refreshIntervalMs = Number.isFinite(stored) && stored >= 2000 ? stored : 15_000;
|
|
|
|
dashboardThumbnailLoader = new ThumbnailLoader({
|
|
refreshIntervalMs,
|
|
rootMargin: "300px",
|
|
threshold: 0.05,
|
|
});
|
|
|
|
const wire = (el) => {
|
|
const id = el.dataset.thumbClient;
|
|
if (!id) return;
|
|
if (el.dataset.thumbOnline !== "1") return;
|
|
const v = Number(el.dataset.thumbVersion) || 0;
|
|
dashboardThumbnailLoader.observe(el, id, v);
|
|
};
|
|
const unwire = (el) => dashboardThumbnailLoader.unobserve(el);
|
|
|
|
const mo = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
m.addedNodes.forEach((n) => {
|
|
if (!(n instanceof HTMLElement)) return;
|
|
if (n.matches?.("[data-thumb-host]")) wire(n);
|
|
n.querySelectorAll?.("[data-thumb-host]").forEach(wire);
|
|
});
|
|
m.removedNodes.forEach((n) => {
|
|
if (!(n instanceof HTMLElement)) return;
|
|
if (n.matches?.("[data-thumb-host]")) unwire(n);
|
|
n.querySelectorAll?.("[data-thumb-host]").forEach(unwire);
|
|
});
|
|
}
|
|
});
|
|
mo.observe(grid, { childList: true, subtree: true });
|
|
grid.querySelectorAll("[data-thumb-host]").forEach(wire);
|
|
}
|
|
|
|
function initializeRenderer() {
|
|
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
|
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
|
applyLayoutToggleUI(grid?.dataset.layout || "rows");
|
|
|
|
const { renderMerge, setLayout: rSetLayout } = createRenderer({
|
|
grid,
|
|
totalPill,
|
|
pageLabel,
|
|
openMenu: (id, x, y) => {
|
|
applyMenuSupportRules(id);
|
|
const card = getClientCard(id);
|
|
const notificationsMuted = card?.dataset.notificationsMuted === "true";
|
|
openMenu(id, x, y, setContext, { isOnline: isClientOnline(id), notificationsMuted });
|
|
loadPluginsForClient(id);
|
|
loadSavedScriptsForMenu();
|
|
},
|
|
openModal,
|
|
requestPreview,
|
|
requestThumbnail,
|
|
pingClient: (id) => sendCommand(id, "ping"),
|
|
userRole: currentUser?.role,
|
|
});
|
|
rendererSetLayout = rSetLayout;
|
|
registerRenderer(renderMerge);
|
|
setupDashboardThumbnailLoader();
|
|
refreshGroupFilter();
|
|
loadWithOptions();
|
|
startAutoRefresh();
|
|
|
|
if (typeof anime !== "undefined") {
|
|
anime
|
|
.timeline({ easing: "easeOutQuad" })
|
|
.add({
|
|
targets: "main > div > div:first-child",
|
|
opacity: [0, 1],
|
|
translateY: [15, 0],
|
|
duration: 500,
|
|
})
|
|
.add(
|
|
{
|
|
targets: "main > div > div:nth-child(2)",
|
|
opacity: [0, 1],
|
|
translateY: [15, 0],
|
|
duration: 500,
|
|
},
|
|
"-=350",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
|
logoutBtn.dataset.boundLogout = "true";
|
|
logoutBtn.addEventListener("click", async () => {
|
|
if (!confirm("Are you sure you want to logout?")) return;
|
|
|
|
try {
|
|
const res = await fetch("/api/logout", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
if (res.ok) {
|
|
window.location.href = "/";
|
|
} else {
|
|
alert("Logout failed. Please try again.");
|
|
}
|
|
} catch (err) {
|
|
console.error("Logout error:", err);
|
|
alert("Logout failed. Please try again.");
|
|
}
|
|
});
|
|
}
|
|
|
|
wireModalClose();
|
|
|
|
const debouncedSearch = debounce(() => {
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
}, 200);
|
|
|
|
searchInput?.addEventListener("input", (e) => {
|
|
state.searchTerm = e.target.value;
|
|
debouncedSearch();
|
|
});
|
|
|
|
sortSelect?.addEventListener("change", (e) => {
|
|
state.sort = e.target.value;
|
|
localStorage.setItem(PREF_SORT_KEY, state.sort);
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
filterStatusSelect?.addEventListener("change", (e) => {
|
|
state.filterStatus = e.target.value;
|
|
localStorage.setItem(PREF_FILTER_STATUS_KEY, state.filterStatus);
|
|
if (state.filterStatus === "online") {
|
|
if (showOfflineToggle) showOfflineToggle.checked = false;
|
|
} else {
|
|
lastNonOnlineStatus = state.filterStatus;
|
|
if (showOfflineToggle) showOfflineToggle.checked = true;
|
|
}
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
filterOsSelect?.addEventListener("change", (e) => {
|
|
state.filterOs = e.target.value;
|
|
localStorage.setItem(PREF_FILTER_OS_KEY, state.filterOs);
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
filterGroupSelect?.addEventListener("change", (e) => {
|
|
state.filterGroup = e.target.value;
|
|
localStorage.setItem(PREF_FILTER_GROUP_KEY, state.filterGroup);
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
import("./country-picker.js").then(({ initCountryPicker }) => {
|
|
initCountryPicker((code) => {
|
|
state.filterCountry = code;
|
|
localStorage.setItem(PREF_FILTER_COUNTRY_KEY, code);
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
}, localStorage.getItem(PREF_FILTER_COUNTRY_KEY) || "all");
|
|
});
|
|
|
|
(function restoreFilterStatus() {
|
|
const savedStatus = localStorage.getItem(PREF_FILTER_STATUS_KEY);
|
|
const validStatuses = ["all", "online", "offline"];
|
|
if (savedStatus && validStatuses.includes(savedStatus)) {
|
|
state.filterStatus = savedStatus;
|
|
if (filterStatusSelect) filterStatusSelect.value = savedStatus;
|
|
if (showOfflineToggle) showOfflineToggle.checked = savedStatus !== "online";
|
|
if (savedStatus !== "online") lastNonOnlineStatus = savedStatus;
|
|
}
|
|
|
|
const savedSort = localStorage.getItem(PREF_SORT_KEY);
|
|
const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc"];
|
|
if (savedSort && validSorts.includes(savedSort)) {
|
|
state.sort = savedSort;
|
|
if (sortSelect) sortSelect.value = savedSort;
|
|
}
|
|
|
|
const savedOs = localStorage.getItem(PREF_FILTER_OS_KEY);
|
|
if (savedOs) {
|
|
state.filterOs = savedOs;
|
|
if (filterOsSelect) filterOsSelect.value = savedOs;
|
|
}
|
|
|
|
const savedCountry = localStorage.getItem(PREF_FILTER_COUNTRY_KEY);
|
|
if (savedCountry) {
|
|
state.filterCountry = savedCountry;
|
|
}
|
|
|
|
const savedGroup = localStorage.getItem(PREF_FILTER_GROUP_KEY);
|
|
if (savedGroup) {
|
|
state.filterGroup = savedGroup;
|
|
if (filterGroupSelect) filterGroupSelect.value = savedGroup;
|
|
}
|
|
})();
|
|
|
|
showOfflineToggle?.addEventListener("change", (e) => {
|
|
if (e.target.checked) {
|
|
state.filterStatus = lastNonOnlineStatus || "all";
|
|
} else {
|
|
if (state.filterStatus !== "online") {
|
|
lastNonOnlineStatus = state.filterStatus;
|
|
}
|
|
state.filterStatus = "online";
|
|
}
|
|
localStorage.setItem(PREF_FILTER_STATUS_KEY, state.filterStatus);
|
|
if (filterStatusSelect) {
|
|
filterStatusSelect.value = state.filterStatus;
|
|
}
|
|
state.page = 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
function updateBulkToolbar() {
|
|
selectedCountSpan.textContent = selectedClients.size;
|
|
if (selectedClients.size > 0) {
|
|
bulkToolbar?.classList.remove("hidden");
|
|
} else {
|
|
bulkToolbar?.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
function toggleClientSelection(clientId) {
|
|
const checkbox = document.querySelector(
|
|
`.client-checkbox[data-id="${clientId}"]`,
|
|
);
|
|
if (!checkbox) return;
|
|
|
|
if (checkbox.checked) {
|
|
selectedClients.add(clientId);
|
|
} else {
|
|
selectedClients.delete(clientId);
|
|
}
|
|
updateBulkToolbar();
|
|
}
|
|
|
|
function syncSelectionState() {
|
|
document.querySelectorAll(".client-checkbox").forEach((cb) => {
|
|
const id = cb.dataset.id;
|
|
if (!id) return;
|
|
cb.checked = selectedClients.has(id);
|
|
});
|
|
updateBulkToolbar();
|
|
}
|
|
|
|
bulkClearBtn?.addEventListener("click", () => {
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
});
|
|
|
|
clearSelectionBtn?.addEventListener("click", () => {
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
});
|
|
|
|
selectAllBtn?.addEventListener("click", () => {
|
|
document
|
|
.querySelectorAll(".client-checkbox:not(:disabled)")
|
|
.forEach((cb) => {
|
|
cb.checked = true;
|
|
if (cb.dataset.id) {
|
|
selectedClients.add(cb.dataset.id);
|
|
}
|
|
});
|
|
updateBulkToolbar();
|
|
});
|
|
|
|
bulkScreenshotBtn?.addEventListener("click", async () => {
|
|
if (!confirm(`Take screenshot on ${selectedClients.size} client(s)?`)) return;
|
|
|
|
let success = 0;
|
|
for (const clientId of selectedClients) {
|
|
const ok = await sendCommand(clientId, "screenshot");
|
|
if (ok) success++;
|
|
}
|
|
|
|
alert(`Screenshots sent to ${success}/${selectedClients.size} clients`);
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
setTimeout(() => loadWithOptions({ force: true }), 400);
|
|
});
|
|
|
|
bulkDisconnectBtn?.addEventListener("click", async () => {
|
|
if (
|
|
!confirm(
|
|
`Disconnect ${selectedClients.size} client(s)? This will close their connections.`,
|
|
)
|
|
)
|
|
return;
|
|
|
|
let success = 0;
|
|
for (const clientId of selectedClients) {
|
|
markManualDisconnect(clientId);
|
|
const ok = await sendCommand(clientId, "disconnect");
|
|
if (ok) success++;
|
|
}
|
|
|
|
alert(`Disconnected ${success}/${selectedClients.size} clients`);
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
setTimeout(() => loadWithOptions({ force: true }), 1000);
|
|
});
|
|
|
|
bulkUninstallBtn?.addEventListener("click", async () => {
|
|
if (
|
|
!confirm(
|
|
`Uninstall agent from ${selectedClients.size} client(s)?\n\nThis will remove all persistence mechanisms and terminate the agents. This action cannot be undone.`,
|
|
)
|
|
)
|
|
return;
|
|
|
|
let success = 0;
|
|
for (const clientId of selectedClients) {
|
|
const ok = await sendCommand(clientId, "uninstall");
|
|
if (ok) success++;
|
|
}
|
|
|
|
alert(`Uninstall sent to ${success}/${selectedClients.size} clients`);
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
setTimeout(() => loadWithOptions({ force: true }), 1000);
|
|
});
|
|
|
|
bulkGroupBtn?.addEventListener("click", () => {
|
|
if (selectedClients.size === 0) return;
|
|
openBulkGroupPicker([...selectedClients]);
|
|
});
|
|
|
|
async function bulkSetMuted(muted) {
|
|
if (selectedClients.size === 0) return;
|
|
const ids = [...selectedClients];
|
|
try {
|
|
const res = await fetch("/api/clients/bulk-notifications-muted", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ clientIds: ids, muted }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to update notifications mute state");
|
|
return;
|
|
}
|
|
const data = await res.json().catch(() => ({}));
|
|
const action = muted ? "Muted" : "Unmuted";
|
|
alert(`${action} notifications for ${data.updated ?? 0}/${ids.length} clients`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to update notifications mute state");
|
|
return;
|
|
}
|
|
selectedClients.clear();
|
|
document
|
|
.querySelectorAll(".client-checkbox")
|
|
.forEach((cb) => (cb.checked = false));
|
|
updateBulkToolbar();
|
|
setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
}
|
|
|
|
bulkMuteBtn?.addEventListener("click", () => bulkSetMuted(true));
|
|
bulkUnmuteBtn?.addEventListener("click", () => bulkSetMuted(false));
|
|
|
|
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.isClientSelected = (clientId) => selectedClients.has(clientId);
|
|
window.syncClientSelection = syncSelectionState;
|
|
window.removeClientFromDashboard = async (clientId) => {
|
|
if (!clientId) return false;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}`, {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to remove client from dashboard");
|
|
return false;
|
|
}
|
|
selectedClients.delete(clientId);
|
|
return true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to remove client from dashboard");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
window.setClientNickname = async (clientId, nickname) => {
|
|
if (!clientId) return false;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/nickname`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ nickname: nickname || "" }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to update client nickname");
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to update client nickname");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
window.setClientTag = async (clientId, tag, note) => {
|
|
if (!clientId) return false;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/tag`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tag: tag || "", note: note ?? "" }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to update custom tag");
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to update custom tag");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
window.setClientNotificationsMuted = async (clientId, muted) => {
|
|
if (!clientId) return false;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/notifications-muted`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ muted: !!muted }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to update notification mute state");
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to update notification mute state");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
window.setClientGroup = async (clientId, groupId) => {
|
|
if (!clientId) return false;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/group`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ groupId }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to update client group");
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to update client group");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
async function loadGroups() {
|
|
try {
|
|
const res = await fetch("/api/groups");
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return data.groups || [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
async function refreshGroupFilter() {
|
|
const groups = await loadGroups();
|
|
if (!filterGroupSelect) return groups;
|
|
const current = filterGroupSelect.value;
|
|
filterGroupSelect.innerHTML = '<option value="all">All Groups</option><option value="none">No Group</option>';
|
|
groups.forEach((g) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = String(g.id);
|
|
opt.textContent = g.name;
|
|
opt.style.color = g.color;
|
|
filterGroupSelect.appendChild(opt);
|
|
});
|
|
filterGroupSelect.value = current;
|
|
return groups;
|
|
}
|
|
|
|
async function openGroupPicker(clientId) {
|
|
const groups = await loadGroups();
|
|
const card = getClientCard(clientId);
|
|
const currentGroupId = card?.dataset.groupId || "";
|
|
|
|
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</h3>
|
|
<div class="group-picker-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(".group-picker-list");
|
|
|
|
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 ${!currentGroupId ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800"}`;
|
|
noneBtn.innerHTML = '<i class="fa-solid fa-xmark text-slate-500"></i> No Group';
|
|
noneBtn.addEventListener("click", async () => {
|
|
const ok = await window.setClientGroup(clientId, null);
|
|
if (ok) setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
overlay.remove();
|
|
});
|
|
listEl.appendChild(noneBtn);
|
|
|
|
groups.forEach((g) => {
|
|
const btn = document.createElement("button");
|
|
const isActive = String(g.id) === currentGroupId;
|
|
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${isActive ? "bg-slate-700 text-white" : "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", async () => {
|
|
const ok = await window.setClientGroup(clientId, g.id);
|
|
if (ok) setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
overlay.remove();
|
|
});
|
|
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();
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
window.banClient = async (clientId) => {
|
|
if (!clientId) return;
|
|
if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
|
|
try {
|
|
const res = await fetch(`/api/clients/${clientId}/ban`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error || "Failed to ban client IP");
|
|
return;
|
|
}
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(`Banned IP ${data.ip || ""}`.trim());
|
|
setTimeout(() => loadWithOptions({ force: true }), 400);
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to ban client IP");
|
|
}
|
|
};
|
|
|
|
prevBtn?.addEventListener("click", () => {
|
|
if (state.page > 1) {
|
|
state.page -= 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
}
|
|
});
|
|
|
|
nextBtn?.addEventListener("click", () => {
|
|
state.page += 1;
|
|
state.lastDigest = "";
|
|
loadWithOptions({ force: true, reorder: true });
|
|
});
|
|
|
|
window.addEventListener("click", (e) => {
|
|
const target = e.target;
|
|
if (target.closest && target.closest(".command-btn")) return;
|
|
if (target.closest && target.closest(".modal")) return;
|
|
if (menu.contains(target)) return;
|
|
closeMenu(clearContext);
|
|
});
|
|
|
|
menu.addEventListener("click", async (e) => {
|
|
const target = e.target.closest("button");
|
|
if (!target || !contextCard) return;
|
|
if (target.dataset.groupToggle) return;
|
|
if (target.disabled || target.getAttribute("aria-disabled") === "true") {
|
|
return;
|
|
}
|
|
const pluginId = target.dataset.plugin;
|
|
if (pluginId) {
|
|
const savedClientId = contextCard;
|
|
try {
|
|
const res = await fetch(`/api/clients/${savedClientId}/plugins/${pluginId}/load`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (res.status === 428) {
|
|
const data = await res.json();
|
|
closeMenu(clearContext);
|
|
showPluginConfirmModal(pluginId, savedClientId, data.signature);
|
|
return;
|
|
}
|
|
|
|
if (res.status === 403) {
|
|
const data = await res.json();
|
|
alert(data.error || "Plugin load blocked — invalid signature");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
alert(`Plugin load failed: ${text}`);
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
window.open(`/plugins/${pluginId}?clientId=${savedClientId}`, "_blank", "noopener");
|
|
} catch (err) {
|
|
alert("Plugin load failed");
|
|
}
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
const unloadId = target.dataset.pluginUnload;
|
|
if (unloadId) {
|
|
await fetch(`/api/clients/${contextCard}/plugins/${unloadId}/unload`, {
|
|
method: "POST",
|
|
});
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
const scriptId = target.dataset.scriptId;
|
|
if (scriptId) {
|
|
window.open(
|
|
`/scripts?clientId=${encodeURIComponent(contextCard)}&scriptId=${encodeURIComponent(scriptId)}`,
|
|
"_blank",
|
|
"noopener",
|
|
);
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
const open = target.dataset.open;
|
|
if (open === "console") {
|
|
window.open(`/${contextCard}/console`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "remotedesktop") {
|
|
window.open(`/remotedesktop?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "webcam") {
|
|
window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "Backstage") {
|
|
window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "files") {
|
|
const platform = detectClientPlatform(contextCard);
|
|
if (platform !== "windows") {
|
|
const proceed = confirm(
|
|
"Opening File Browser may show a permission prompt to the target user on their machine. Continue?",
|
|
);
|
|
if (!proceed) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
}
|
|
window.open(`/${contextCard}/files`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "processes") {
|
|
window.open(`/${contextCard}/processes`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "keylogger") {
|
|
window.open(`/${contextCard}/keylogger`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "silent-exec") {
|
|
window.open(`/deploy?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
if (open === "winre") {
|
|
window.open(`/winre?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
const action = target.dataset.action;
|
|
|
|
if (open === "voice") {
|
|
window.open(`/voice?clientId=${contextCard}`, "_blank", "noopener");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
if (action === "uninstall") {
|
|
if (
|
|
!confirm(
|
|
`Uninstall agent from ${contextCard}?\n\nThis will remove all persistence mechanisms and terminate the agent. This action cannot be undone.`,
|
|
)
|
|
) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
} else if (action === "disconnect") {
|
|
if (
|
|
!confirm(
|
|
`Disconnect ${contextCard}?\n\nThis will terminate the agent connection.`,
|
|
)
|
|
) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
} else if (action === "remove-dashboard") {
|
|
if (
|
|
!confirm(
|
|
`Remove ${contextCard} from the dashboard list?\n\nUse this for stale clients that are already gone. If that client reconnects later, it will appear again.`,
|
|
)
|
|
) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
const removed = await window.removeClientFromDashboard(contextCard);
|
|
if (removed) {
|
|
updateBulkToolbar();
|
|
setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
}
|
|
closeMenu(clearContext);
|
|
return;
|
|
} else if (action === "set-nickname") {
|
|
const card = getClientCard(contextCard);
|
|
const currentNickname = (card?.dataset.nickname || "").trim();
|
|
const input = prompt(
|
|
`Set nickname for ${contextCard}\n\nLeave blank to clear nickname.`,
|
|
currentNickname,
|
|
);
|
|
if (input === null) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
const trimmed = input.trim();
|
|
const updated = await window.setClientNickname(contextCard, trimmed || null);
|
|
if (updated) {
|
|
setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
}
|
|
closeMenu(clearContext);
|
|
return;
|
|
} else if (action === "set-custom-tag") {
|
|
const card = getClientCard(contextCard);
|
|
const currentTag = (card?.dataset.customTag || "").trim();
|
|
const currentNote = card?._customTagNote || "";
|
|
const result = await openTagNoteEditor(contextCard, currentTag, currentNote);
|
|
if (!result) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
const updated = await window.setClientTag(
|
|
contextCard,
|
|
result.tag || "",
|
|
result.note || "",
|
|
);
|
|
if (updated) {
|
|
setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
}
|
|
closeMenu(clearContext);
|
|
return;
|
|
} else if (action === "set-group") {
|
|
const savedClientId = contextCard;
|
|
closeMenu(clearContext);
|
|
openGroupPicker(savedClientId);
|
|
return;
|
|
} else if (action === "toggle-mute") {
|
|
const card = getClientCard(contextCard);
|
|
const currentlyMuted = card?.dataset.notificationsMuted === "true";
|
|
const ok = await window.setClientNotificationsMuted(contextCard, !currentlyMuted);
|
|
if (ok) setTimeout(() => loadWithOptions({ force: true }), 200);
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
if (!isClientOnline(contextCard)) {
|
|
alert("Client is offline. This command can only be used while the client is online.");
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
|
|
if (action === "elevate") {
|
|
const isMac = detectClientPlatform(contextCard) === "mac";
|
|
let password = "";
|
|
if (isMac) {
|
|
password = prompt("Enter the user's macOS password for sudo elevation:");
|
|
if (!password) {
|
|
closeMenu(clearContext);
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
const payload = { action: "elevate" };
|
|
if (password) payload.password = password;
|
|
const res = await fetch(`/api/clients/${contextCard}/command`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (data.ok) {
|
|
alert(data.message || "Elevation successful — client will reconnect elevated.");
|
|
setTimeout(() => requestThumbnail(contextCard), 5000);
|
|
setTimeout(() => requestThumbnail(contextCard), 10000);
|
|
setTimeout(() => requestThumbnail(contextCard), 18000);
|
|
} else {
|
|
alert(data.error || data.message || "Elevation failed.");
|
|
}
|
|
} catch (err) {
|
|
alert("Elevation request failed: " + err.message);
|
|
}
|
|
closeMenu(clearContext);
|
|
setTimeout(() => loadWithOptions({ force: true }), 5000);
|
|
return;
|
|
}
|
|
|
|
if (action === "disconnect") {
|
|
markManualDisconnect(contextCard);
|
|
}
|
|
|
|
if (action === "uninstall") {
|
|
flipFallAndRemove(contextCard);
|
|
}
|
|
|
|
const ok = await sendCommand(contextCard, action);
|
|
if (ok) {
|
|
// Wait for the flip-fall to finish before refreshing the grid, otherwise
|
|
// the reload would yank the animating card from layout.
|
|
const refreshDelay = action === "uninstall" ? 1300 : 400;
|
|
setTimeout(() => loadWithOptions({ force: true }), refreshDelay);
|
|
}
|
|
closeMenu(clearContext);
|
|
});
|
|
|
|
loadCurrentUser();
|
|
|
|
// console easter egg
|
|
(function () {
|
|
const consoleimg = {
|
|
load: function (src, { size = 320, color = "transparent" } = {}) {
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", function () {
|
|
const style =
|
|
"background: url('" + reader.result + "') left top no-repeat; font-size: " +
|
|
size +
|
|
"px; background-size: contain; background-color:" +
|
|
color;
|
|
console.log("%c ", style);
|
|
}, false);
|
|
fetch(src)
|
|
.then((r) => r.blob())
|
|
.then((blob) => {
|
|
if (blob.type.indexOf("image") === 0) {
|
|
if (blob.size > 8192 && navigator.userAgent.indexOf("Firefox") > 0)
|
|
throw new Error("Image size too big to be displayed in Firefox.");
|
|
return blob;
|
|
}
|
|
throw new Error("Valid image not found.");
|
|
})
|
|
.then((blob) => reader.readAsDataURL(blob))
|
|
.catch((err) => console.warn(err.message));
|
|
},
|
|
};
|
|
consoleimg.load("/assets/console.gif", { size: 320, color: "transparent" });
|
|
})();
|