diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js index cf7c59f..c872b3e 100644 --- a/Overlord-Server/public/assets/purgatory-ui.js +++ b/Overlord-Server/public/assets/purgatory-ui.js @@ -25,6 +25,12 @@ const banModalBody = document.getElementById("ban-modal-body"); const banModalConfirm = document.getElementById("ban-modal-confirm"); const banModalCancel = document.getElementById("ban-modal-cancel"); const banModalBackdrop = document.getElementById("ban-modal-backdrop"); +const autoAcceptToggle = document.getElementById("auto-accept-toggle"); +const autoAcceptModal = document.getElementById("auto-accept-modal"); +const autoAcceptModalConfirm = document.getElementById("auto-accept-modal-confirm"); +const autoAcceptModalCancel = document.getElementById("auto-accept-modal-cancel"); +const autoAcceptModalBackdrop = document.getElementById("auto-accept-modal-backdrop"); +const approveAllBtn = document.getElementById("approve-all-btn"); let currentFilter = "pending"; let searchQuery = ""; @@ -38,6 +44,13 @@ async function api(path, opts = {}) { return res.json(); } +async function loadSettings() { + try { + const s = await api("/api/enrollment/settings"); + autoAcceptToggle.checked = !s.requireApproval; + } catch {} +} + async function loadStats() { try { const s = await api("/api/enrollment/stats"); @@ -90,6 +103,9 @@ async function loadClients() { loadingRow.classList.add("hidden"); + const hasPending = currentFilter === "pending" && filtered.some((c) => (c.enrollmentStatus || "pending") === "pending"); + approveAllBtn.classList.toggle("hidden", !hasPending); + if (filtered.length === 0) { emptyEl.classList.remove("hidden"); return; @@ -251,6 +267,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => { if (clientsTable) clientsTable.classList.add("hidden"); emptyEl.classList.add("hidden"); bannedIpsSection.classList.remove("hidden"); + approveAllBtn.classList.add("hidden"); loadBannedIps(); } else { bannedIpsSection.classList.add("hidden"); @@ -446,7 +463,70 @@ confirmBanBtn.addEventListener("click", async () => { await Promise.all([loadBannedIps(), loadStats()]); }); +// ── Always Allow toggle ──────────────────────────────────────────── +autoAcceptToggle.addEventListener("change", async () => { + const wantsAlwaysAllow = autoAcceptToggle.checked; + if (wantsAlwaysAllow) { + autoAcceptToggle.checked = false; // revert until confirmed + autoAcceptModal.classList.remove("hidden"); + } else { + try { + await api("/api/enrollment/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireApproval: true }), + }); + if (window.showToast) window.showToast("Approval required — purgatory is active", "success"); + } catch (e) { + autoAcceptToggle.checked = true; // revert on error + if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); + } + } +}); + +function closeAutoAcceptModal() { + autoAcceptModal.classList.add("hidden"); +} + +autoAcceptModalCancel.addEventListener("click", closeAutoAcceptModal); +autoAcceptModalBackdrop.addEventListener("click", closeAutoAcceptModal); + +autoAcceptModalConfirm.addEventListener("click", async () => { + closeAutoAcceptModal(); + try { + await api("/api/enrollment/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireApproval: false }), + }); + autoAcceptToggle.checked = true; + if (window.showToast) window.showToast("Always Allow enabled — agents auto-approved on connect", "success"); + } catch (e) { + if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); + } +}); + +// ── Approve All Pending ──────────────────────────────────────────── +approveAllBtn.addEventListener("click", async () => { + const pendingIds = clients + .filter((c) => (c.enrollmentStatus || "pending") === "pending") + .map((c) => c.id); + if (pendingIds.length === 0) return; + try { + await api("/api/enrollment/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: pendingIds, action: "approve" }), + }); + if (window.showToast) window.showToast(`Approved ${pendingIds.length} client${pendingIds.length !== 1 ? "s" : ""}`, "success"); + } catch (e) { + if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); + } + await Promise.all([loadClients(), loadStats()]); +}); + // ── Init ─────────────────────────────────────────────────────────── +loadSettings(); loadStats(); loadClients(); diff --git a/Overlord-Server/public/purgatory.html b/Overlord-Server/public/purgatory.html index 8d63db1..0b2cfd1 100644 --- a/Overlord-Server/public/purgatory.html +++ b/Overlord-Server/public/purgatory.html @@ -31,13 +31,22 @@ Approve or deny clients before they can connect to the server.

-
+
0 pending 0 approved 0 denied 0 banned IPs
+
+ + Always Allow + +
@@ -60,6 +69,10 @@ Banned IPs
+
+ + + diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts index 0888267..ea30fc2 100644 --- a/Overlord-Server/src/server/routes/enrollment-routes.ts +++ b/Overlord-Server/src/server/routes/enrollment-routes.ts @@ -1,5 +1,5 @@ import { authenticateRequest } from "../../auth"; -import { getConfig } from "../../config"; +import { getConfig, updateEnrollmentConfig } from "../../config"; import { getPendingClients, getEnrollmentStats, @@ -114,6 +114,33 @@ export async function handleEnrollmentRoutes( }); } + // POST /api/enrollment/settings + if (req.method === "POST" && url.pathname === "/api/enrollment/settings") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + let body: any; + try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } + + if (typeof body?.requireApproval !== "boolean") { + return Response.json({ error: "requireApproval must be a boolean" }, { status: 400 }); + } + + const updated = await updateEnrollmentConfig({ requireApproval: body.requireApproval }); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: "server", + action: AuditAction.ENROLLMENT_SETTINGS, + success: true, + details: JSON.stringify({ requireApproval: body.requireApproval }), + }); + + return Response.json({ ok: true, requireApproval: updated.requireApproval }); + } + // POST /api/enrollment/:id/approve const approveMatch = url.pathname.match(/^\/api\/enrollment\/([^/]+)\/approve$/); if (req.method === "POST" && approveMatch) { diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts index 2d27b99..5518320 100644 --- a/Overlord-Server/src/thumbnails.ts +++ b/Overlord-Server/src/thumbnails.ts @@ -14,17 +14,41 @@ async function getSharp(): Promise { } } -const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920)); -const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080)); +const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 400)); +const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 225)); const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88))); const MAX_THUMBNAIL_SOURCE_BYTES = Math.max( 256 * 1024, - Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024), + Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 8 * 1024 * 1024), ); const THUMBNAIL_CACHE_MAX = Math.max( 64, Number(process.env.OVERLORD_THUMBNAIL_CACHE_MAX || 2000), ); +const MAX_CONCURRENT_THUMBNAIL_GEN = Math.max( + 1, + Number(process.env.OVERLORD_THUMBNAIL_CONCURRENCY || 4), +); +let _activeThumbnailGen = 0; +const _thumbnailGenQueue: Array<() => void> = []; + +function withThumbnailSlot(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + const run = () => { + _activeThumbnailGen++; + fn().then(resolve, reject).finally(() => { + _activeThumbnailGen--; + const next = _thumbnailGenQueue.shift(); + if (next) next(); + }); + }; + if (_activeThumbnailGen < MAX_CONCURRENT_THUMBNAIL_GEN) { + run(); + } else { + _thumbnailGenQueue.push(run); + } + }); +} type ThumbnailRecord = { bytes: Uint8Array; @@ -157,12 +181,14 @@ export async function requestThumbnailRegen(id: string): Promise { state.inFlight = true; let didGenerate = false; try { - while (true) { - state.pending = false; - const ok = await generateThumbnail(id); - if (ok) didGenerate = true; - if (!state.pending) break; - } + await withThumbnailSlot(async () => { + while (true) { + state!.pending = false; + const ok = await generateThumbnail(id); + if (ok) didGenerate = true; + if (!state!.pending) break; + } + }); } finally { state.inFlight = false; if (thumbnailGenState.get(id) === state && !state.pending) {