From d12dc04709586fd79a689ae20b696c1ccace6f5b Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 19 Apr 2026 18:24:50 -0500 Subject: [PATCH] add registration --- Overlord-Server/public/assets/login.js | 11 + Overlord-Server/public/assets/register.js | 123 ++++++ Overlord-Server/public/assets/settings.js | 282 ++++++++++++ Overlord-Server/public/login.html | 7 +- Overlord-Server/public/register.html | 106 +++++ Overlord-Server/public/settings.html | 135 ++++++ Overlord-Server/src/build-rate-limit.ts | 133 ++++++ Overlord-Server/src/config.ts | 128 ++++++ Overlord-Server/src/main-server.ts | 3 + Overlord-Server/src/server/http-dispatch.ts | 12 +- .../src/server/routes/build-routes.ts | 22 + .../src/server/routes/misc-routes.ts | 72 +++- .../src/server/routes/page-routes.ts | 5 + .../src/server/routes/registration-routes.ts | 404 ++++++++++++++++++ Overlord-Server/src/users.ts | 297 ++++++++++++- 15 files changed, 1735 insertions(+), 5 deletions(-) create mode 100644 Overlord-Server/public/assets/register.js create mode 100644 Overlord-Server/public/register.html create mode 100644 Overlord-Server/src/build-rate-limit.ts create mode 100644 Overlord-Server/src/server/routes/registration-routes.ts diff --git a/Overlord-Server/public/assets/login.js b/Overlord-Server/public/assets/login.js index 74f7a27..8f8a557 100644 --- a/Overlord-Server/public/assets/login.js +++ b/Overlord-Server/public/assets/login.js @@ -3,6 +3,17 @@ const user = document.getElementById("user"); const pass = document.getElementById("pass"); const errEl = document.getElementById("error"); +(async () => { + try { + const res = await fetch("/api/registration/status"); + const data = await res.json(); + if (data.enabled) { + const el = document.getElementById("register-link"); + if (el) el.style.display = ""; + } + } catch {} +})(); + if (typeof anime !== "undefined") { const particles = document.querySelectorAll(".particle"); particles.forEach((particle, i) => { diff --git a/Overlord-Server/public/assets/register.js b/Overlord-Server/public/assets/register.js new file mode 100644 index 0000000..9e525b3 --- /dev/null +++ b/Overlord-Server/public/assets/register.js @@ -0,0 +1,123 @@ +const form = document.getElementById("register-form"); +const errorEl = document.getElementById("error"); +const successEl = document.getElementById("success"); +const keyGroup = document.getElementById("key-group"); +const keyInput = document.getElementById("reg-key"); + +async function checkStatus() { + try { + const res = await fetch("/api/registration/status"); + const data = await res.json(); + if (!data.enabled) { + form.style.display = "none"; + errorEl.textContent = "Registration is currently disabled."; + return; + } + if (data.mode === "key") { + keyGroup.style.display = ""; + keyInput.required = true; + } + } catch { + errorEl.textContent = "Unable to check registration status."; + } +} +checkStatus(); + +if (window.anime) { + document.querySelectorAll(".particle").forEach((p, i) => { + anime({ + targets: p, + translateX: () => anime.random(-300, 300), + translateY: () => anime.random(-300, 300), + scale: () => anime.random(5, 15) / 10, + opacity: [{ value: 0.15, duration: 600 }, { value: 0, duration: 600 }], + easing: "easeInOutQuad", + duration: 3000, + delay: i * 400, + loop: true, + direction: "alternate", + }); + }); + + anime.timeline() + .add({ + targets: ".login-card", + opacity: [0, 1], + translateY: [30, 0], + duration: 800, + easing: "easeOutExpo", + }) + .add({ + targets: ".login-crown-wrapper", + scale: [0, 1], + rotate: [180, 0], + duration: 600, + easing: "easeOutBack", + }, "-=400") + .add({ + targets: ".login-brand, .login-title, .login-subtitle", + opacity: [0, 1], + translateY: [10, 0], + duration: 500, + delay: anime.stagger(100), + easing: "easeOutQuad", + }, "-=300") + .add({ + targets: ".form-group", + opacity: [0, 1], + translateY: [15, 0], + duration: 500, + delay: anime.stagger(100), + easing: "easeOutQuad", + }, "-=300") + .add({ + targets: ".login-btn", + opacity: [0, 1], + scale: [0.95, 1], + duration: 400, + easing: "easeOutQuad", + }, "-=200"); +} else { + document.querySelector(".login-card").style.opacity = "1"; +} + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorEl.textContent = ""; + successEl.style.display = "none"; + + const username = document.getElementById("reg-user").value.trim(); + const password = document.getElementById("reg-pass").value; + const confirmPassword = document.getElementById("reg-pass-confirm").value; + const key = keyInput.value.trim(); + + if (password !== confirmPassword) { + errorEl.textContent = "Passwords do not match."; + return; + } + if (password.length < 6) { + errorEl.textContent = "Password must be at least 6 characters."; + return; + } + + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, key: key || undefined }), + }); + const data = await res.json(); + if (!res.ok) { + errorEl.textContent = data.error || "Registration failed."; + return; + } + form.style.display = "none"; + successEl.style.display = ""; + successEl.textContent = data.message || "Account created!"; + if (!data.pending) { + setTimeout(() => { window.location.href = "/"; }, 2000); + } + } catch { + errorEl.textContent = "Network error. Please try again."; + } +}); diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index e7438b2..bf73127 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -1057,6 +1057,276 @@ async function handleRemoveInactiveSessions() { } } +// ── Registration Settings ────────────────────────────────────────────────── + +async function loadRegistrationSettings() { + try { + const res = await fetch("/api/settings/registration", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const reg = data.registration || {}; + const modeEl = document.getElementById("reg-mode"); + const roleEl = document.getElementById("reg-default-role"); + const maxEl = document.getElementById("reg-max-users"); + if (modeEl) modeEl.value = reg.mode || "off"; + if (roleEl) roleEl.value = reg.defaultRole || "operator"; + if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0; + updateRegSubsections(reg.mode || "off"); + if (reg.mode === "key") loadRegistrationKeys(); + if (reg.mode === "approval") loadPendingRegistrations(); + } catch (e) { + console.error("Failed to load registration settings", e); + } +} + +function updateRegSubsections(mode) { + const keySection = document.getElementById("reg-key-section"); + const pendingSection = document.getElementById("reg-pending-section"); + if (keySection) keySection.classList.toggle("hidden", mode !== "key"); + if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval"); +} + +function showRegMsg(text, type) { + const el = document.getElementById("reg-settings-msg"); + if (!el) return; + el.textContent = text; + el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 5000); +} + +async function saveRegistrationSettings(e) { + e.preventDefault(); + const mode = document.getElementById("reg-mode")?.value; + const defaultRole = document.getElementById("reg-default-role")?.value; + const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0; + + try { + const res = await fetch("/api/settings/registration", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, defaultRole, maxUsersTotal }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showRegMsg(data.error || "Failed to save", "error"); + return; + } + showRegMsg("Registration settings saved.", "success"); + updateRegSubsections(mode); + if (mode === "key") loadRegistrationKeys(); + if (mode === "approval") loadPendingRegistrations(); + } catch { + showRegMsg("Network error.", "error"); + } +} + +async function loadRegistrationKeys() { + const tbody = document.getElementById("reg-keys-tbody"); + if (!tbody) return; + try { + const res = await fetch("/api/registration/keys", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const keys = data.keys || []; + if (keys.length === 0) { + tbody.innerHTML = 'No keys generated yet.'; + return; + } + tbody.innerHTML = keys.map(k => { + const status = k.used_by ? 'Used' + : (k.expires_at && k.expires_at < Date.now()) ? 'Expired' + : 'Available'; + const created = new Date(k.created_at).toLocaleDateString(); + const expires = k.expires_at ? new Date(k.expires_at).toLocaleDateString() : "Never"; + return ` + ${escapeHtml(k.key.substring(0, 12))}... + ${escapeHtml(k.label || "—")} + ${status} + ${created} + ${expires} + ${!k.used_by ? `` : ""} + `; + }).join(""); + } catch (e) { + console.error("Failed to load keys", e); + } +} + +async function generateRegistrationKeys() { + const count = Number(document.getElementById("reg-key-count")?.value) || 1; + const label = document.getElementById("reg-key-label")?.value || undefined; + const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined; + + try { + const res = await fetch("/api/registration/keys", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ count, label, expiresInHours }), + }); + if (!res.ok) { + showRegMsg("Failed to generate keys", "error"); + return; + } + showRegMsg(`Generated ${count} key(s).`, "success"); + loadRegistrationKeys(); + } catch { + showRegMsg("Network error.", "error"); + } +} + +async function deleteRegistrationKey(keyId) { + try { + const res = await fetch(`/api/registration/keys/${keyId}`, { + method: "DELETE", + credentials: "include", + }); + if (res.ok) loadRegistrationKeys(); + } catch {} +} + +async function loadPendingRegistrations() { + const tbody = document.getElementById("reg-pending-tbody"); + const emptyEl = document.getElementById("reg-pending-empty"); + if (!tbody) return; + try { + const res = await fetch("/api/registration/pending", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const pending = (data.pending || []).filter(p => p.status === "pending"); + if (pending.length === 0) { + tbody.innerHTML = ""; + if (emptyEl) emptyEl.classList.remove("hidden"); + return; + } + if (emptyEl) emptyEl.classList.add("hidden"); + tbody.innerHTML = pending.map(p => { + const requested = new Date(p.requested_at).toLocaleString(); + return ` + ${escapeHtml(p.username)} + ${requested} + Pending + + + + + `; + }).join(""); + } catch (e) { + console.error("Failed to load pending registrations", e); + } +} + +async function handlePendingAction(id, action) { + try { + const res = await fetch(`/api/registration/pending/${id}/${action}`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showRegMsg(data.error || `Failed to ${action}`, "error"); + return; + } + showRegMsg(`Registration ${action}d.`, "success"); + loadPendingRegistrations(); + } catch { + showRegMsg("Network error.", "error"); + } +} + +function initRegistrationHandlers() { + const regForm = document.getElementById("registration-form"); + if (regForm) regForm.addEventListener("submit", saveRegistrationSettings); + + const modeEl = document.getElementById("reg-mode"); + if (modeEl) modeEl.addEventListener("change", () => { + updateRegSubsections(modeEl.value); + if (modeEl.value === "key") loadRegistrationKeys(); + if (modeEl.value === "approval") loadPendingRegistrations(); + }); + + const genBtn = document.getElementById("reg-key-generate-btn"); + if (genBtn) genBtn.addEventListener("click", generateRegistrationKeys); + + // Delegate key delete and pending approve/deny + document.addEventListener("click", (e) => { + const deleteBtn = e.target.closest("[data-delete-key]"); + if (deleteBtn) { + deleteRegistrationKey(Number(deleteBtn.dataset.deleteKey)); + return; + } + const approveBtn = e.target.closest("[data-approve-pending]"); + if (approveBtn) { + handlePendingAction(Number(approveBtn.dataset.approvePending), "approve"); + return; + } + const denyBtn = e.target.closest("[data-deny-pending]"); + if (denyBtn) { + handlePendingAction(Number(denyBtn.dataset.denyPending), "deny"); + } + }); +} + +// ── Build Rate Limit Settings ────────────────────────────────────────────── + +async function loadBuildRateLimitSettings() { + try { + const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const brl = data.buildRateLimit || {}; + const perHourEl = document.getElementById("brl-max-per-hour"); + const concUserEl = document.getElementById("brl-max-concurrent-user"); + const globalConcEl = document.getElementById("brl-global-concurrent"); + if (perHourEl) perHourEl.value = brl.maxBuildsPerHour ?? 5; + if (concUserEl) concUserEl.value = brl.maxConcurrentPerUser ?? 1; + if (globalConcEl) globalConcEl.value = brl.globalMaxConcurrent ?? 3; + } catch (e) { + console.error("Failed to load build rate limit settings", e); + } +} + +function showBrlMsg(text, type) { + const el = document.getElementById("brl-settings-msg"); + if (!el) return; + el.textContent = text; + el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 5000); +} + +async function saveBuildRateLimitSettings(e) { + e.preventDefault(); + const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5; + const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1; + const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3; + + try { + const res = await fetch("/api/settings/build-rate-limit", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxBuildsPerHour, maxConcurrentPerUser, globalMaxConcurrent }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showBrlMsg(data.error || "Failed to save", "error"); + return; + } + showBrlMsg("Build rate limit settings saved.", "success"); + } catch { + showBrlMsg("Network error.", "error"); + } +} + +function initBuildRateLimitHandlers() { + const brlForm = document.getElementById("build-rate-limit-form"); + if (brlForm) brlForm.addEventListener("submit", saveBuildRateLimitSettings); +} + async function init() { try { await loadCurrentUser(); @@ -1076,6 +1346,18 @@ async function init() { await loadChatSettings(); await loadBannedIps(); + // Registration + Build Rate Limit (admin only) + if (isAdmin(currentUser?.role)) { + const regSection = document.getElementById("registration-section"); + const brlSection = document.getElementById("build-rate-limit-section"); + if (regSection) regSection.classList.remove("hidden"); + if (brlSection) brlSection.classList.remove("hidden"); + await loadRegistrationSettings(); + await loadBuildRateLimitSettings(); + initRegistrationHandlers(); + initBuildRateLimitHandlers(); + } + passwordForm.addEventListener("submit", updatePassword); prefsForm.addEventListener("submit", savePrefs); diff --git a/Overlord-Server/public/login.html b/Overlord-Server/public/login.html index e8f25c5..ee68051 100644 --- a/Overlord-Server/public/login.html +++ b/Overlord-Server/public/login.html @@ -65,7 +65,12 @@
+ - + diff --git a/Overlord-Server/public/register.html b/Overlord-Server/public/register.html new file mode 100644 index 0000000..6ae8682 --- /dev/null +++ b/Overlord-Server/public/register.html @@ -0,0 +1,106 @@ + + + + + + Overlord - Register + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+ +
+
+ Already have an account? Sign in +
+
+ + + diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 3b94f7d..530fcf9 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -664,6 +664,141 @@ + + + + + + diff --git a/Overlord-Server/src/build-rate-limit.ts b/Overlord-Server/src/build-rate-limit.ts new file mode 100644 index 0000000..3cfd422 --- /dev/null +++ b/Overlord-Server/src/build-rate-limit.ts @@ -0,0 +1,133 @@ +import { logger } from "./logger"; +import { getConfig } from "./config"; + +interface UserBuildEntry { + buildTimestamps: number[]; + activeBuildCount: number; +} + +const buildStore = new Map(); +let globalActiveBuildCount = 0; + +const CLEANUP_INTERVAL = 60 * 1000; + +function getPolicy() { + const cfg = getConfig().buildRateLimit; + return { + maxBuildsPerHour: Math.min(100, Math.max(1, Number(cfg.maxBuildsPerHour) || 5)), + maxConcurrentPerUser: Math.min(10, Math.max(1, Number(cfg.maxConcurrentPerUser) || 1)), + globalMaxConcurrent: Math.min(50, Math.max(1, Number(cfg.globalMaxConcurrent) || 3)), + }; +} + +export function canUserBuild(userId: number): { + allowed: boolean; + reason?: string; + retryAfter?: number; +} { + const policy = getPolicy(); + const now = Date.now(); + const hourAgo = now - 60 * 60 * 1000; + + if (globalActiveBuildCount >= policy.globalMaxConcurrent) { + return { + allowed: false, + reason: `Server is at maximum concurrent builds (${policy.globalMaxConcurrent}). Please wait for a build to finish.`, + }; + } + + const entry = buildStore.get(userId); + if (!entry) { + return { allowed: true }; + } + + if (entry.activeBuildCount >= policy.maxConcurrentPerUser) { + return { + allowed: false, + reason: `You already have ${entry.activeBuildCount} build(s) running. Max concurrent per user: ${policy.maxConcurrentPerUser}.`, + }; + } + + const recentBuilds = entry.buildTimestamps.filter((ts) => ts > hourAgo); + if (recentBuilds.length >= policy.maxBuildsPerHour) { + const oldestRecent = Math.min(...recentBuilds); + const retryAfter = Math.ceil((oldestRecent + 60 * 60 * 1000 - now) / 1000); + return { + allowed: false, + reason: `Rate limit exceeded: ${policy.maxBuildsPerHour} builds per hour. Try again in ${retryAfter}s.`, + retryAfter, + }; + } + + return { allowed: true }; +} + +export function recordBuildStart(userId: number): void { + const now = Date.now(); + const entry = buildStore.get(userId); + + if (entry) { + entry.buildTimestamps.push(now); + entry.activeBuildCount++; + } else { + buildStore.set(userId, { + buildTimestamps: [now], + activeBuildCount: 1, + }); + } + + globalActiveBuildCount++; + logger.debug( + `[build-rate-limit] User ${userId} started build (active: ${buildStore.get(userId)!.activeBuildCount}, global: ${globalActiveBuildCount})`, + ); +} + +export function recordBuildEnd(userId: number): void { + const entry = buildStore.get(userId); + if (entry && entry.activeBuildCount > 0) { + entry.activeBuildCount--; + } + if (globalActiveBuildCount > 0) { + globalActiveBuildCount--; + } + logger.debug( + `[build-rate-limit] User ${userId} build ended (active: ${entry?.activeBuildCount ?? 0}, global: ${globalActiveBuildCount})`, + ); +} + +export function getBuildRateLimitStats(): { + globalActive: number; + perUser: Array<{ userId: number; active: number; recentCount: number }>; +} { + const hourAgo = Date.now() - 60 * 60 * 1000; + const perUser: Array<{ userId: number; active: number; recentCount: number }> = []; + + for (const [userId, entry] of buildStore.entries()) { + const recentCount = entry.buildTimestamps.filter((ts) => ts > hourAgo).length; + if (entry.activeBuildCount > 0 || recentCount > 0) { + perUser.push({ userId, active: entry.activeBuildCount, recentCount }); + } + } + + return { globalActive: globalActiveBuildCount, perUser }; +} + +function cleanupExpired(): void { + const hourAgo = Date.now() - 60 * 60 * 1000; + let cleaned = 0; + + for (const [userId, entry] of buildStore.entries()) { + entry.buildTimestamps = entry.buildTimestamps.filter((ts) => ts > hourAgo); + + if (entry.buildTimestamps.length === 0 && entry.activeBuildCount <= 0) { + buildStore.delete(userId); + cleaned++; + } + } + + if (cleaned > 0) { + logger.debug(`[build-rate-limit] Cleaned up ${cleaned} expired entries`); + } +} + +setInterval(cleanupExpired, CLEANUP_INTERVAL); diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index 69f2381..ccb88c8 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -64,6 +64,16 @@ export interface Config { chat: { retentionDays: number; }; + registration: { + mode: "off" | "open" | "key" | "approval"; + defaultRole: "operator" | "viewer"; + maxUsersTotal: number; + }; + buildRateLimit: { + maxBuildsPerHour: number; + maxConcurrentPerUser: number; + globalMaxConcurrent: number; + }; } const DEFAULT_CONFIG: Config = { @@ -126,6 +136,16 @@ const DEFAULT_CONFIG: Config = { chat: { retentionDays: 30, }, + registration: { + mode: "off" as const, + defaultRole: "operator" as const, + maxUsersTotal: 0, + }, + buildRateLimit: { + maxBuildsPerHour: 5, + maxConcurrentPerUser: 1, + globalMaxConcurrent: 3, + }, }; type SaveSecrets = { @@ -459,6 +479,40 @@ export function loadConfig(): Config { fileConfig.chat?.retentionDays || DEFAULT_CONFIG.chat.retentionDays, }, + registration: { + mode: (() => { + const envMode = process.env.OVERLORD_REGISTRATION_MODE; + if (envMode && ["off", "open", "key", "approval"].includes(envMode)) return envMode as Config["registration"]["mode"]; + const fileMode = fileConfig.registration?.mode; + if (fileMode && ["off", "open", "key", "approval"].includes(fileMode)) return fileMode as Config["registration"]["mode"]; + return DEFAULT_CONFIG.registration.mode; + })(), + defaultRole: (() => { + const envRole = process.env.OVERLORD_REGISTRATION_DEFAULT_ROLE; + if (envRole && ["operator", "viewer"].includes(envRole)) return envRole as Config["registration"]["defaultRole"]; + const fileRole = fileConfig.registration?.defaultRole; + if (fileRole && ["operator", "viewer"].includes(fileRole)) return fileRole as Config["registration"]["defaultRole"]; + return DEFAULT_CONFIG.registration.defaultRole; + })(), + maxUsersTotal: + Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) || + fileConfig.registration?.maxUsersTotal || + DEFAULT_CONFIG.registration.maxUsersTotal, + }, + buildRateLimit: { + maxBuildsPerHour: + Number(process.env.OVERLORD_BUILD_MAX_PER_HOUR) || + fileConfig.buildRateLimit?.maxBuildsPerHour || + DEFAULT_CONFIG.buildRateLimit.maxBuildsPerHour, + maxConcurrentPerUser: + Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_USER) || + fileConfig.buildRateLimit?.maxConcurrentPerUser || + DEFAULT_CONFIG.buildRateLimit.maxConcurrentPerUser, + globalMaxConcurrent: + Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_GLOBAL) || + fileConfig.buildRateLimit?.globalMaxConcurrent || + DEFAULT_CONFIG.buildRateLimit.globalMaxConcurrent, + }, }; if (saveChanged) { @@ -697,6 +751,68 @@ export async function updateChatConfig( return next; } +export async function updateRegistrationConfig( + updates: Partial, +): Promise { + const current = getConfig(); + + const validModes = ["off", "open", "key", "approval"] as const; + const validRoles = ["operator", "viewer"] as const; + + const next: Config["registration"] = { + ...current.registration, + ...updates, + }; + + if (!validModes.includes(next.mode as any)) { + next.mode = current.registration.mode; + } + if (!validRoles.includes(next.defaultRole as any)) { + next.defaultRole = current.registration.defaultRole; + } + next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0)); + + configCache = { + ...current, + registration: next, + }; + + const fileConfig = readFileConfigForUpdate(); + fileConfig.registration = next; + await writePersistentFileConfig(fileConfig); + return next; +} + +export async function updateBuildRateLimitConfig( + updates: Partial, +): Promise { + const current = getConfig(); + + const toNumberOr = (value: unknown, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const next: Config["buildRateLimit"] = { + ...current.buildRateLimit, + ...updates, + }; + + next.maxBuildsPerHour = Math.min(100, Math.max(1, toNumberOr(next.maxBuildsPerHour, 5))); + next.maxConcurrentPerUser = Math.min(10, Math.max(1, toNumberOr(next.maxConcurrentPerUser, 1))); + next.globalMaxConcurrent = Math.min(50, Math.max(1, toNumberOr(next.globalMaxConcurrent, 3))); + + configCache = { + ...current, + buildRateLimit: next, + }; + + const fileConfig = readFileConfigForUpdate(); + fileConfig.buildRateLimit = next; + await writePersistentFileConfig(fileConfig); + return next; +} + export function getExportableConfig(serverVersion: string): Record { const config = getConfig(); return { @@ -715,6 +831,8 @@ export function getExportableConfig(serverVersion: string): Record): Promise<{ app applied.push("chat"); } + if (data.registration && typeof data.registration === "object") { + await updateRegistrationConfig(data.registration); + applied.push("registration"); + } + + if (data.buildRateLimit && typeof data.buildRateLimit === "object") { + await updateBuildRateLimitConfig(data.buildRateLimit); + applied.push("buildRateLimit"); + } + if (data.auth && typeof data.auth === "object") { const dataDir = ensureDataDir(); const savePath = resolve(dataDir, "save.json"); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 849627f..6fc51ee 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -45,6 +45,7 @@ import { sanitizePluginId } from "./server/plugin-utils"; import { dispatchAutoScriptsForConnection } from "./server/auto-script-dispatch"; import { dispatchAutoDeploysForConnection } from "./server/auto-deploy-dispatch"; import { handleAutoDeployRoutes } from "./server/routes/auto-deploy-routes"; +import { handleRegistrationRoutes } from "./server/routes/registration-routes"; import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer"; import { startBuildProcess as runBuildProcess } from "./server/build-process"; import { createHttpFetchHandler } from "./server/http-dispatch"; @@ -477,6 +478,7 @@ async function startServer() { wsUpgrade: { isAuthorizedAgentRequest: isAuthorizedAgent, }, + registration: {}, }; function handleBuildTagConnection(clientId: string, buildTagValue: string) { @@ -658,6 +660,7 @@ async function startServer() { fetch: createHttpFetchHandler({ metrics, CORS_HEADERS, + handleRegistrationRoutes, handleAuthRoutes, handleNotificationsConfigRoutes, handleAutoScriptsRoutes, diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index 1ab076b..d64f903 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -33,9 +33,11 @@ type HttpDispatchDeps< TPage, TClient, TWsUpgrade, + TRegistration > = { metrics: { withHttpMetrics: (fn: () => Promise) => Promise }; CORS_HEADERS: Record; + handleRegistrationRoutes: RouteHandlerWithDeps; handleAuthRoutes: RouteHandlerWithServer; handleNotificationsConfigRoutes: RouteHandlerWithServerDeps; handleAutoScriptsRoutes: RouteHandler; @@ -69,6 +71,7 @@ type HttpDispatchDeps< page: TPage; client: TClient; wsUpgrade: TWsUpgrade; + registration: TRegistration; }; }; @@ -87,6 +90,7 @@ export function createHttpFetchHandler< TPage, TClient, TWsUpgrade, + TRegistration >( deps: HttpDispatchDeps< TServer, @@ -102,8 +106,9 @@ export function createHttpFetchHandler< TAssets, TPage, TClient, - TWsUpgrade - >, + TWsUpgrade, + TRegistration + > ) { return async function fetchHandler(req: Request, server: unknown): Promise { return deps.metrics.withHttpMetrics(async () => { @@ -114,6 +119,9 @@ export function createHttpFetchHandler< return new Response("", { headers: deps.CORS_HEADERS }); } + const registrationResponse = await deps.handleRegistrationRoutes(req, url, deps.routeDeps.registration); + if (registrationResponse) return registrationResponse; + const authRouteResponse = await deps.handleAuthRoutes(req, url, routeServer); if (authRouteResponse) return authRouteResponse; diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index 1cdc153..305be5f 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -10,6 +10,8 @@ import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; import { logger } from "../../logger"; import { normalizeClientOs } from "../deploy-utils"; +import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit"; +import { getConfig } from "../../config"; import path from "path"; import fs from "fs"; import { resolveRuntimeRoot } from "../runtime-paths"; @@ -44,6 +46,21 @@ export async function handleBuildRoutes( if (req.method === "POST" && url.pathname === "/api/build/start") { requirePermission(user, "clients:build"); + if (getConfig().registration.mode !== "off") { + const rateLimitResult = canUserBuild(user.userId); + if (!rateLimitResult.allowed) { + return Response.json( + { error: rateLimitResult.reason || "Build rate limit exceeded" }, + { + status: 429, + headers: rateLimitResult.retryAfter + ? { "Retry-After": String(rateLimitResult.retryAfter) } + : {}, + }, + ); + } + } + const body = await req.json(); const { platforms, @@ -307,6 +324,9 @@ export async function handleBuildRoutes( safeBoundFiles = validated; } + const rateLimitActive = getConfig().registration.mode !== "off"; + if (rateLimitActive) recordBuildStart(user.userId); + deps.startBuildProcess(buildId, { platforms: allowedPlatforms, serverUrl: safeServerUrl, @@ -343,6 +363,8 @@ export async function handleBuildRoutes( sleepSeconds: safeSleepSeconds, boundFiles: safeBoundFiles, iosBundleId: typeof iosBundleId === "string" && /^[a-zA-Z0-9.-]{1,128}$/.test(iosBundleId.trim()) ? iosBundleId.trim() : undefined, + }).finally(() => { + if (rateLimitActive) recordBuildEnd(user.userId); }); return Response.json({ buildId }); diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index aa657fa..90fc156 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -1,6 +1,6 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; -import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig } from "../../config"; +import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig } from "../../config"; import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; @@ -592,5 +592,75 @@ export async function handleMiscRoutes( return new Response("Certificate not available", { status: 404 }); } + // ── Registration settings (admin only) ────────────────────────────── + if (url.pathname === "/api/settings/registration") { + 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 }); + + if (req.method === "GET") { + return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS }); + } + + if (req.method === "PUT") { + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const updated = await updateRegistrationConfig({ + mode: body?.mode, + defaultRole: body?.defaultRole, + maxUsersTotal: body?.maxUsersTotal !== undefined ? Number(body.maxUsersTotal) : undefined, + }); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: `Updated registration settings (mode: ${updated.mode})`, + success: true, + }); + + return Response.json({ ok: true, registration: updated }, { headers: deps.CORS_HEADERS }); + } + } + + // ── Build rate limit settings (admin only) ────────────────────────── + if (url.pathname === "/api/settings/build-rate-limit") { + 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 }); + + if (req.method === "GET") { + return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS }); + } + + if (req.method === "PUT") { + let body: any = {}; + try { body = await req.json(); } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const updated = await updateBuildRateLimitConfig({ + maxBuildsPerHour: body?.maxBuildsPerHour !== undefined ? Number(body.maxBuildsPerHour) : undefined, + maxConcurrentPerUser: body?.maxConcurrentPerUser !== undefined ? Number(body.maxConcurrentPerUser) : undefined, + globalMaxConcurrent: body?.globalMaxConcurrent !== undefined ? Number(body.globalMaxConcurrent) : undefined, + }); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: "Updated build rate limit settings", + success: true, + }); + + return Response.json({ ok: true, buildRateLimit: updated }, { headers: deps.CORS_HEADERS }); + } + } + return null; } diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index d1795bc..46fa861 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -150,6 +150,11 @@ export async function handlePageRoutes( return serveFile(deps, "change-password.html"); } + // ---- Registration page (always accessible, no auth needed) ---- + if (url.pathname === "/register.html") { + return serveFile(deps, "register.html"); + } + // ---- Static pages (table-driven) ---- for (const page of STATIC_PAGES) { if (url.pathname !== page.path) continue; diff --git a/Overlord-Server/src/server/routes/registration-routes.ts b/Overlord-Server/src/server/routes/registration-routes.ts new file mode 100644 index 0000000..d19f6b0 --- /dev/null +++ b/Overlord-Server/src/server/routes/registration-routes.ts @@ -0,0 +1,404 @@ +import { authenticateRequest } from "../../auth"; +import { AuditAction, logAudit } from "../../auditLog"; +import { getConfig } from "../../config"; +import { logger } from "../../logger"; +import { + registerUser, + deleteUser, + createRegistrationKeys, + listRegistrationKeys, + claimRegistrationKey, + deleteRegistrationKey, + createPendingRegistration, + listPendingRegistrations, + approvePendingRegistration, + denyPendingRegistration, + getUserByUsername, + getTotalUserCount, + validatePasswordPolicy, + type UserRole, +} from "../../users"; + + +interface RegRateLimitEntry { + attempts: number; + firstAttempt: number; +} + +const regRateLimitStore = new Map(); +const REG_MAX_ATTEMPTS = 5; +const REG_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +function isRegRateLimited(ip: string): boolean { + const entry = regRateLimitStore.get(ip); + if (!entry) return false; + const now = Date.now(); + if (now - entry.firstAttempt > REG_WINDOW_MS) { + regRateLimitStore.delete(ip); + return false; + } + return entry.attempts >= REG_MAX_ATTEMPTS; +} + +function recordRegAttempt(ip: string): void { + const now = Date.now(); + const entry = regRateLimitStore.get(ip); + if (!entry || now - entry.firstAttempt > REG_WINDOW_MS) { + regRateLimitStore.set(ip, { attempts: 1, firstAttempt: now }); + } else { + entry.attempts++; + } +} + +setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of regRateLimitStore.entries()) { + if (now - entry.firstAttempt > REG_WINDOW_MS) { + regRateLimitStore.delete(ip); + } + } +}, 60_000); + + +type RegistrationRouteDeps = { + requestIP?: (req: Request) => { address?: string } | null | undefined; +}; + +function getClientIp(req: Request, deps: RegistrationRouteDeps): string { + const fromDeps = deps.requestIP?.(req)?.address; + if (fromDeps) return fromDeps; + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || req.headers.get("x-real-ip") + || "unknown"; +} + +export async function handleRegistrationRoutes( + req: Request, + url: URL, + deps: RegistrationRouteDeps, +): Promise { + if (req.method === "GET" && url.pathname === "/api/registration/status") { + const config = getConfig(); + const mode = config.registration.mode; + return Response.json({ + enabled: mode !== "off", + mode: mode === "off" ? "off" : mode, + }); + } + + if (req.method === "POST" && url.pathname === "/api/register") { + const config = getConfig(); + const regConfig = config.registration; + const ip = getClientIp(req, deps); + + if (regConfig.mode === "off") { + return Response.json({ error: "Registration is disabled" }, { status: 403 }); + } + + if (isRegRateLimited(ip)) { + return Response.json( + { error: "Too many registration attempts. Try again later." }, + { status: 429 }, + ); + } + recordRegAttempt(ip); + + let body: any; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const username = typeof body?.username === "string" ? body.username.trim() : ""; + const password = typeof body?.password === "string" ? body.password : ""; + const keyValue = typeof body?.key === "string" ? body.key.trim() : ""; + + if (!username || !password) { + return Response.json({ error: "Username and password are required" }, { status: 400 }); + } + + if (regConfig.maxUsersTotal > 0) { + const totalUsers = getTotalUserCount(); + if (totalUsers >= regConfig.maxUsersTotal) { + return Response.json( + { error: "Maximum number of registered users has been reached" }, + { status: 403 }, + ); + } + } + + const existing = getUserByUsername(username); + if (existing) { + return Response.json({ error: "Username already exists" }, { status: 409 }); + } + + const defaultRole = regConfig.defaultRole as UserRole; + + if (regConfig.mode === "key") { + if (!keyValue) { + return Response.json({ error: "A registration key is required" }, { status: 400 }); + } + + const result = await registerUser(username, password, "key", defaultRole); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + const claimResult = claimRegistrationKey(keyValue, result.userId!); + if (!claimResult.success) { + deleteUser(result.userId!); + return Response.json({ error: claimResult.error }, { status: 400 }); + } + + logAudit({ + timestamp: Date.now(), + username, + ip, + action: AuditAction.COMMAND, + details: `User registered via key (key: ${claimResult.key.key.substring(0, 8)}...)`, + success: true, + }); + + logger.info(`[registration] User '${username}' registered via key from ${ip}`); + return Response.json({ ok: true, message: "Account created successfully. You can now sign in." }); + } + + if (regConfig.mode === "approval") { + if (username.length < 3 || username.length > 32) { + return Response.json({ error: "Username must be between 3 and 32 characters" }, { status: 400 }); + } + if (!/^[a-zA-Z0-9_\-]+$/.test(username)) { + return Response.json( + { error: "Username can only contain letters, numbers, hyphens, and underscores" }, + { status: 400 }, + ); + } + + const policyError = validatePasswordPolicy(password); + if (policyError) { + return Response.json({ error: policyError }, { status: 400 }); + } + + const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 }); + + const result = createPendingRegistration(username, passwordHash); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + logAudit({ + timestamp: Date.now(), + username, + ip, + action: AuditAction.COMMAND, + details: `User registration pending approval`, + success: true, + }); + + logger.info(`[registration] Pending registration for '${username}' from ${ip}`); + return Response.json({ + ok: true, + pending: true, + message: "Your registration is pending admin approval. You will be able to sign in once approved.", + }); + } + + if (regConfig.mode === "open") { + const result = await registerUser(username, password, "open", defaultRole); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + logAudit({ + timestamp: Date.now(), + username, + ip, + action: AuditAction.COMMAND, + details: `User registered via open registration`, + success: true, + }); + + logger.info(`[registration] User '${username}' registered via open registration from ${ip}`); + return Response.json({ ok: true, message: "Account created successfully. You can now sign in." }); + } + + return Response.json({ error: "Registration is disabled" }, { status: 403 }); + } + + if (req.method === "GET" && url.pathname === "/api/registration/keys") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const keys = listRegistrationKeys(); + return Response.json({ keys }); + } + + if (req.method === "POST" && url.pathname === "/api/registration/keys") { + 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 }); + } + + const count = Math.min(100, Math.max(1, Number(body?.count) || 1)); + const label = typeof body?.label === "string" ? body.label : undefined; + const expiresInHours = typeof body?.expiresInHours === "number" && body.expiresInHours > 0 + ? body.expiresInHours + : undefined; + + const keys = createRegistrationKeys(count, user.userId, label, expiresInHours); + const ip = getClientIp(req, deps); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + details: `Generated ${count} registration key(s)${label ? ` (label: ${label})` : ""}`, + success: true, + }); + + return Response.json({ ok: true, keys }); + } + + if (req.method === "DELETE" && url.pathname.match(/^\/api\/registration\/keys\/\d+$/)) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const keyId = Number(url.pathname.split("/").pop()); + if (!keyId || !Number.isFinite(keyId)) { + return Response.json({ error: "Invalid key ID" }, { status: 400 }); + } + + const deleted = deleteRegistrationKey(keyId); + if (!deleted) { + return Response.json({ error: "Key not found" }, { status: 404 }); + } + + const ip = getClientIp(req, deps); + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + details: `Deleted registration key ${keyId}`, + success: true, + }); + + return Response.json({ ok: true }); + } + + // ── Admin: GET /api/registration/keys/export ────────────────────────── + if (req.method === "GET" && url.pathname === "/api/registration/keys/export") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const keys = listRegistrationKeys(); + const exportData = keys.map((k) => ({ + key: k.key, + label: k.label || "", + status: k.used_by ? "used" : k.expires_at && k.expires_at < Date.now() ? "expired" : "available", + created_at: new Date(k.created_at).toISOString(), + expires_at: k.expires_at ? new Date(k.expires_at).toISOString() : "never", + })); + + return new Response(JSON.stringify(exportData, null, 2), { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="registration-keys-${Date.now()}.json"`, + }, + }); + } + + // ── Admin: GET /api/registration/pending ────────────────────────────── + if (req.method === "GET" && url.pathname === "/api/registration/pending") { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const pending = listPendingRegistrations(); + return Response.json({ + pending: pending.map((p) => ({ + id: p.id, + username: p.username, + requested_at: p.requested_at, + status: p.status, + })), + }); + } + + // ── Admin: POST /api/registration/pending/:id/approve ───────────────── + if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/approve$/)) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const pendingId = Number(url.pathname.split("/")[4]); + if (!pendingId || !Number.isFinite(pendingId)) { + return Response.json({ error: "Invalid pending ID" }, { status: 400 }); + } + + const config = getConfig(); + const result = await approvePendingRegistration( + pendingId, + user.userId, + config.registration.defaultRole as UserRole, + ); + + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + const ip = getClientIp(req, deps); + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + details: `Approved pending registration #${pendingId}`, + success: true, + }); + + return Response.json({ ok: true, userId: result.userId }); + } + + // ── Admin: POST /api/registration/pending/:id/deny ──────────────────── + if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/deny$/)) { + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); + + const pendingId = Number(url.pathname.split("/")[4]); + if (!pendingId || !Number.isFinite(pendingId)) { + return Response.json({ error: "Invalid pending ID" }, { status: 400 }); + } + + const result = denyPendingRegistration(pendingId, user.userId); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + const ip = getClientIp(req, deps); + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.COMMAND, + details: `Denied pending registration #${pendingId}`, + success: true, + }); + + return Response.json({ ok: true }); + } + + return null; +} diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index f88995a..5d6c9b6 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -211,6 +211,47 @@ try { } } +try { + db.exec(`ALTER TABLE users ADD COLUMN registered_via TEXT DEFAULT NULL`); + logger.info("[users] Added registered_via column to existing database"); +} catch (err: any) { + if (!err.message?.includes("duplicate column name")) { + logger.error("[users] Migration error:", err); + } +} + +db.exec(` + CREATE TABLE IF NOT EXISTS registration_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + label TEXT, + created_by INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + used_by INTEGER, + used_at INTEGER, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE + ) +`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_registration_keys_key ON registration_keys("key")`); + +db.exec(` + CREATE TABLE IF NOT EXISTS pending_registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + requested_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'denied')), + reviewed_by INTEGER, + reviewed_at INTEGER, + key_used INTEGER, + FOREIGN KEY (key_used) REFERENCES registration_keys(id) + ) +`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_pending_registrations_status ON pending_registrations(status)`); +db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_registrations_username_pending ON pending_registrations(username) WHERE status = 'pending'`); + + const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number; }; @@ -546,7 +587,7 @@ export function resetUserFeaturePermissions( } } -function validatePasswordPolicy(password: string): string | null { +export function validatePasswordPolicy(password: string): string | null { const security = getConfig().security; const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6)); @@ -977,3 +1018,257 @@ export function getUsersForNotificationDeliveryByClient(clientId: string): UserD canUserAccessClient(user.id, user.role, clientId), ); } + + +export interface RegistrationKey { + id: number; + key: string; + label: string | null; + created_by: number; + created_at: number; + expires_at: number | null; + used_by: number | null; + used_at: number | null; +} + +export interface PendingRegistration { + id: number; + username: string; + password_hash: string; + requested_at: number; + status: "pending" | "approved" | "denied"; + reviewed_by: number | null; + reviewed_at: number | null; + key_used: number | null; +} + +export function generateRegistrationKey(): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const segments = 4; + const segmentLen = 5; + const parts: string[] = []; + for (let s = 0; s < segments; s++) { + let seg = ""; + const bytes = new Uint8Array(segmentLen); + crypto.getRandomValues(bytes); + for (let i = 0; i < segmentLen; i++) { + seg += chars[bytes[i] % chars.length]; + } + parts.push(seg); + } + return parts.join("-"); +} + +export function createRegistrationKeys( + count: number, + createdBy: number, + label?: string, + expiresInHours?: number, +): RegistrationKey[] { + const safeCount = Math.min(100, Math.max(1, count)); + const now = Date.now(); + const expiresAt = expiresInHours && expiresInHours > 0 + ? now + expiresInHours * 60 * 60 * 1000 + : null; + const safeLabel = label ? String(label).slice(0, 128).trim() : null; + + const stmt = db.prepare( + `INSERT INTO registration_keys ("key", label, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`, + ); + + const keys: RegistrationKey[] = []; + const tx = db.transaction(() => { + for (let i = 0; i < safeCount; i++) { + const key = generateRegistrationKey(); + const result = stmt.run(key, safeLabel, createdBy, now, expiresAt); + keys.push({ + id: result.lastInsertRowid as number, + key, + label: safeLabel, + created_by: createdBy, + created_at: now, + expires_at: expiresAt, + used_by: null, + used_at: null, + }); + } + }); + tx(); + return keys; +} + +export function listRegistrationKeys(): RegistrationKey[] { + return db.prepare( + `SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys ORDER BY created_at DESC`, + ).all() as RegistrationKey[]; +} + +export function getRegistrationKeyByValue(keyValue: string): RegistrationKey | null { + const row = db.prepare( + `SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?`, + ).get(keyValue) as RegistrationKey | undefined; + return row || null; +} + +export function markRegistrationKeyUsed(keyId: number, usedByUserId: number): void { + db.prepare(`UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ?`).run( + usedByUserId, Date.now(), keyId, + ); +} + + +export function claimRegistrationKey( + keyValue: string, + usedByUserId: number, +): { success: true; key: RegistrationKey } | { success: false; error: string } { + const tx = db.transaction(() => { + const row = db.prepare( + `SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?`, + ).get(keyValue) as RegistrationKey | undefined; + + if (!row) return { success: false as const, error: "Invalid registration key" }; + if (row.used_by !== null) return { success: false as const, error: "This registration key has already been used" }; + if (row.expires_at && row.expires_at < Date.now()) return { success: false as const, error: "This registration key has expired" }; + + db.prepare(`UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ? AND used_by IS NULL`).run( + usedByUserId, Date.now(), row.id, + ); + + return { success: true as const, key: row }; + }); + return tx(); +} + +export function deleteRegistrationKey(keyId: number): boolean { + const result = db.prepare(`DELETE FROM registration_keys WHERE id = ?`).run(keyId); + return (result.changes as number) > 0; +} + +export function createPendingRegistration( + username: string, + passwordHash: string, + keyUsed?: number, +): { success: boolean; id?: number; error?: string } { + try { + const result = db.prepare( + `INSERT INTO pending_registrations (username, password_hash, requested_at, status, key_used) VALUES (?, ?, ?, 'pending', ?)`, + ).run(username, passwordHash, Date.now(), keyUsed || null); + return { success: true, id: result.lastInsertRowid as number }; + } catch (err: any) { + if (err.message?.includes("UNIQUE constraint")) { + return { success: false, error: "A registration with this username is already pending" }; + } + return { success: false, error: err.message || "Failed to create pending registration" }; + } +} + +export function listPendingRegistrations(): PendingRegistration[] { + return db.prepare( + `SELECT id, username, requested_at, status, reviewed_by, reviewed_at, key_used FROM pending_registrations WHERE status = 'pending' ORDER BY requested_at ASC`, + ).all() as PendingRegistration[]; +} + +export function getPendingRegistration(id: number): PendingRegistration | null { + const row = db.prepare( + `SELECT * FROM pending_registrations WHERE id = ?`, + ).get(id) as PendingRegistration | undefined; + return row || null; +} + +export async function approvePendingRegistration( + pendingId: number, + reviewedBy: number, + defaultRole: UserRole, +): Promise<{ success: boolean; userId?: number; error?: string }> { + const pending = getPendingRegistration(pendingId); + if (!pending) return { success: false, error: "Pending registration not found" }; + if (pending.status !== "pending") return { success: false, error: "Registration already reviewed" }; + + const existing = getUserByUsername(pending.username); + if (existing) return { success: false, error: "Username already exists" }; + + try { + const role = defaultRole === "viewer" ? "viewer" : "operator"; + const result = db.prepare( + `INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + pending.username, pending.password_hash, role, Date.now(), "registration", + "allowlist", role === "operator" ? 1 : 0, 0, "approval", + ); + + db.prepare( + `UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?`, + ).run(reviewedBy, Date.now(), pendingId); + + invalidateNotificationDeliveryCache(); + return { success: true, userId: result.lastInsertRowid as number }; + } catch (err: any) { + logger.error("[users] approvePendingRegistration error:", err); + return { success: false, error: err.message || "Failed to approve registration" }; + } +} + +export function denyPendingRegistration( + pendingId: number, + reviewedBy: number, +): { success: boolean; error?: string } { + const pending = getPendingRegistration(pendingId); + if (!pending) return { success: false, error: "Pending registration not found" }; + if (pending.status !== "pending") return { success: false, error: "Registration already reviewed" }; + + db.prepare( + `UPDATE pending_registrations SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?`, + ).run(reviewedBy, Date.now(), pendingId); + + return { success: true }; +} + +export async function registerUser( + username: string, + password: string, + registeredVia: "open" | "key", + defaultRole: UserRole, +): Promise<{ success: boolean; error?: string; userId?: number }> { + if (!username || username.length < 3 || username.length > 32) { + return { success: false, error: "Username must be between 3 and 32 characters" }; + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return { success: false, error: "Username can only contain letters, numbers, hyphens, and underscores" }; + } + + const policyError = validatePasswordPolicy(password); + if (policyError) return { success: false, error: policyError }; + + const existing = getUserByUsername(username); + if (existing) return { success: false, error: "Username already exists" }; + + const pendingExisting = db.prepare( + `SELECT id FROM pending_registrations WHERE username = ? AND status = 'pending'`, + ).get(username); + if (pendingExisting) return { success: false, error: "A registration with this username is already pending" }; + + try { + const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 }); + const role = defaultRole === "viewer" ? "viewer" : "operator"; + const result = db.prepare( + `INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + username, passwordHash, role, Date.now(), "registration", + "allowlist", role === "operator" ? 1 : 0, 0, registeredVia, + ); + + invalidateNotificationDeliveryCache(); + return { success: true, userId: result.lastInsertRowid as number }; + } catch (err: any) { + if (err.message?.includes("UNIQUE constraint")) { + return { success: false, error: "Username already exists" }; + } + logger.error("[users] registerUser error:", err); + return { success: false, error: err.message || "Failed to register user" }; + } +} + +export function getTotalUserCount(): number { + const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number }; + return row.count; +}