add registration
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
});
|
||||
@@ -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 = '<tr><td colspan="6" class="px-3 py-4 text-center text-slate-500">No keys generated yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = keys.map(k => {
|
||||
const status = k.used_by ? '<span class="text-amber-400">Used</span>'
|
||||
: (k.expires_at && k.expires_at < Date.now()) ? '<span class="text-slate-500">Expired</span>'
|
||||
: '<span class="text-emerald-400">Available</span>';
|
||||
const created = new Date(k.created_at).toLocaleDateString();
|
||||
const expires = k.expires_at ? new Date(k.expires_at).toLocaleDateString() : "Never";
|
||||
return `<tr class="border-b border-slate-800">
|
||||
<td class="px-3 py-2 font-mono text-xs">${escapeHtml(k.key.substring(0, 12))}...</td>
|
||||
<td class="px-3 py-2">${escapeHtml(k.label || "—")}</td>
|
||||
<td class="px-3 py-2">${status}</td>
|
||||
<td class="px-3 py-2">${created}</td>
|
||||
<td class="px-3 py-2">${expires}</td>
|
||||
<td class="px-3 py-2">${!k.used_by ? `<button data-delete-key="${k.id}" class="text-rose-400 hover:text-rose-300 text-xs"><i class="fa-solid fa-trash"></i></button>` : ""}</td>
|
||||
</tr>`;
|
||||
}).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 `<tr class="border-b border-slate-800">
|
||||
<td class="px-3 py-2">${escapeHtml(p.username)}</td>
|
||||
<td class="px-3 py-2">${requested}</td>
|
||||
<td class="px-3 py-2"><span class="text-amber-400">Pending</span></td>
|
||||
<td class="px-3 py-2 space-x-2">
|
||||
<button data-approve-pending="${p.id}" class="text-emerald-400 hover:text-emerald-300 text-xs"><i class="fa-solid fa-check"></i> Approve</button>
|
||||
<button data-deny-pending="${p.id}" class="text-rose-400 hover:text-rose-300 text-xs"><i class="fa-solid fa-xmark"></i> Deny</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).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);
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@
|
||||
</button>
|
||||
<div id="error" class="error" aria-live="polite"></div>
|
||||
</form>
|
||||
<div id="register-link" style="text-align: center; margin-top: 1rem; display: none">
|
||||
<a href="/register.html" class="muted" style="font-size: 0.85rem; text-decoration: underline"
|
||||
>Don't have an account? Register</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/assets/login.js"></script>
|
||||
<script src="/assets/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overlord - Register</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/600.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/700.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script src="/vendor/animejs/anime.min.js"></script>
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-particles">
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-crown-wrapper">
|
||||
<i class="fa-solid fa-crown login-crown"></i>
|
||||
</div>
|
||||
<div class="brand login-brand">Overlord</div>
|
||||
<h2 class="login-title">Create Account</h2>
|
||||
<p class="muted login-subtitle">Register for access</p>
|
||||
<form id="register-form">
|
||||
<div class="form-group">
|
||||
<label class="label">Username</label>
|
||||
<div class="input-row input-animated">
|
||||
<i class="fa-solid fa-user"></i
|
||||
><input
|
||||
id="reg-user"
|
||||
name="user"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
pattern="[a-zA-Z0-9_\-]+"
|
||||
placeholder="Choose a username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Password</label>
|
||||
<div class="input-row input-animated">
|
||||
<i class="fa-solid fa-lock"></i
|
||||
><input
|
||||
id="reg-pass"
|
||||
name="pass"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="6"
|
||||
placeholder="Choose a password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Confirm Password</label>
|
||||
<div class="input-row input-animated">
|
||||
<i class="fa-solid fa-lock"></i
|
||||
><input
|
||||
id="reg-pass-confirm"
|
||||
name="pass-confirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="key-group" style="display: none">
|
||||
<label class="label">Registration Key</label>
|
||||
<div class="input-row input-animated">
|
||||
<i class="fa-solid fa-key"></i
|
||||
><input
|
||||
id="reg-key"
|
||||
name="key"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter your registration key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button primary full login-btn">
|
||||
<span class="btn-text">Create Account</span>
|
||||
<i class="fa-solid fa-user-plus btn-icon"></i>
|
||||
</button>
|
||||
<div id="error" class="error" aria-live="polite"></div>
|
||||
<div id="success" class="success" aria-live="polite" style="display: none"></div>
|
||||
</form>
|
||||
<div style="text-align: center; margin-top: 1rem">
|
||||
<a href="/" class="muted" style="font-size: 0.85rem; text-decoration: underline"
|
||||
>Already have an account? Sign in</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/register.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -664,6 +664,141 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Registration Settings (admin only) -->
|
||||
<section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||
<i class="fa-solid fa-user-plus text-emerald-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Registration</h2>
|
||||
<p class="text-sm text-slate-400">Admin-only. Control how new users can register.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="registration-form" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Registration Mode</span>
|
||||
<select id="reg-mode" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500">
|
||||
<option value="off">Off</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="key">Key Required</option>
|
||||
<option value="approval">Approval Required</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Default Role</span>
|
||||
<select id="reg-default-role" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500">
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Max Users (0 = unlimited)</span>
|
||||
<input id="reg-max-users" type="number" min="0" max="10000" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-floppy-disk"></i>Save Registration Settings
|
||||
</button>
|
||||
</form>
|
||||
<p id="reg-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
|
||||
<!-- Key Management (visible when mode=key) -->
|
||||
<div id="reg-key-section" class="hidden space-y-3 border-t border-slate-700 pt-4">
|
||||
<h3 class="text-md font-semibold text-slate-200">Registration Keys</h3>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Count</span>
|
||||
<input id="reg-key-count" type="number" value="1" min="1" max="100" class="mt-1 w-24 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Label (optional)</span>
|
||||
<input id="reg-key-label" type="text" maxlength="64" placeholder="e.g. Batch 1" class="mt-1 w-40 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Expires (hours, 0=never)</span>
|
||||
<input id="reg-key-expires" type="number" value="0" min="0" max="8760" class="mt-1 w-28 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
<button id="reg-key-generate-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-plus"></i>Generate
|
||||
</button>
|
||||
<a id="reg-key-export-btn" href="/api/registration/keys/export" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-file-export"></i>Export
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-slate-300">
|
||||
<thead class="text-xs uppercase text-slate-500 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Key</th>
|
||||
<th class="px-3 py-2">Label</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Created</th>
|
||||
<th class="px-3 py-2">Expires</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reg-keys-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Registrations (visible when mode=approval) -->
|
||||
<div id="reg-pending-section" class="hidden space-y-3 border-t border-slate-700 pt-4">
|
||||
<h3 class="text-md font-semibold text-slate-200">Pending Registrations</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-slate-300">
|
||||
<thead class="text-xs uppercase text-slate-500 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Username</th>
|
||||
<th class="px-3 py-2">Requested</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reg-pending-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="reg-pending-empty" class="text-sm text-slate-500">No pending registrations.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Build Rate Limit (admin only) -->
|
||||
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
||||
<i class="fa-solid fa-gauge-high text-orange-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Build Rate Limit</h2>
|
||||
<p class="text-sm text-slate-400">Admin-only. Prevent abuse by limiting build frequency.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="build-rate-limit-form" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Max Builds per Hour (per user)</span>
|
||||
<input id="brl-max-per-hour" type="number" min="1" max="100" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Max Concurrent (per user)</span>
|
||||
<input id="brl-max-concurrent-user" type="number" min="1" max="10" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Global Max Concurrent</span>
|
||||
<input id="brl-global-concurrent" type="number" min="1" max="50" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-orange-700 hover:bg-orange-600 transition-colors text-white text-sm font-medium">
|
||||
<i class="fa-solid fa-floppy-disk"></i>Save Rate Limit Settings
|
||||
</button>
|
||||
</form>
|
||||
<p id="brl-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
</section>
|
||||
|
||||
<footer class="text-center text-sm text-slate-500 py-4 border-t border-slate-800 mt-4">
|
||||
<p>Created by KDot — <a href="https://t.me/kunta" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/kunta</a></p>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { logger } from "./logger";
|
||||
import { getConfig } from "./config";
|
||||
|
||||
interface UserBuildEntry {
|
||||
buildTimestamps: number[];
|
||||
activeBuildCount: number;
|
||||
}
|
||||
|
||||
const buildStore = new Map<number, UserBuildEntry>();
|
||||
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);
|
||||
@@ -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<Config["registration"]>,
|
||||
): Promise<Config["registration"]> {
|
||||
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<Config["buildRateLimit"]>,
|
||||
): Promise<Config["buildRateLimit"]> {
|
||||
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<string, unknown> {
|
||||
const config = getConfig();
|
||||
return {
|
||||
@@ -715,6 +831,8 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
||||
appearance: config.appearance,
|
||||
plugins: config.plugins,
|
||||
chat: config.chat,
|
||||
registration: config.registration,
|
||||
buildRateLimit: config.buildRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -778,6 +896,16 @@ export async function importFullConfig(data: Record<string, any>): 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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,9 +33,11 @@ type HttpDispatchDeps<
|
||||
TPage,
|
||||
TClient,
|
||||
TWsUpgrade,
|
||||
TRegistration
|
||||
> = {
|
||||
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
handleRegistrationRoutes: RouteHandlerWithDeps<TRegistration>;
|
||||
handleAuthRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
|
||||
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<Response> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, RegRateLimitEntry>();
|
||||
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<Response | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user