diff --git a/src/public/app.js b/src/public/app.js index 7a90ab7..4e70ec1 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -1,11 +1,25 @@ +// ============================================================================ +// app.js -- core client application: state, API helper, theme, dashboard, +// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/ +// edit/icon/user/account/MFA), and every event listener for those areas. The +// remaining views (Streaming, Redirects, Access Lists, Administration panels) +// live in features.js and are invoked from here via window.renderExtendedViews. +// ============================================================================ + +// --- Shared DOM shortcut and app state ---------------------------------------- const $ = selector => document.querySelector(selector); const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false }; + +// One-time DOM patches: move the Access List field into the create/settings +// forms (features.js owns the Access List data, this file owns these forms). document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain "); if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this hosted site and all of its domains.'; anchor.before(label); } } if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this route and all of its domains.'; anchor.after(label); } } const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel); const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel); document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove(); + +// --- Theme (light/dark/system) ------------------------------------------------- const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); function applyTheme(preference) { @@ -18,17 +32,22 @@ $("#theme-select").value = savedTheme; applyTheme(savedTheme); $("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); }); systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); }); +// --- API helper ------------------------------------------------------------------ + async function api(url, options = {}) { const response = await fetch(url, options); if (response.status === 401) { showLogin(); throw new Error("Please sign in again."); } if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); } return response.status === 204 ? null : response.json(); } + +// --- Login/dashboard shell, toast, and small formatting helpers ------------------ function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); } function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); } function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); } function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; } function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; } + function formatBytes(value) { if (!Number.isFinite(value)) return "Unavailable"; if (value < 1024) return `${value} B`; @@ -36,15 +55,18 @@ function formatBytes(value) { for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; } return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`; } + function formatDuration(seconds) { if (!Number.isFinite(seconds)) return "Unavailable"; const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60); if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`; } + function formatTime(value) { if (!value) return "Just now"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); } + function formatRelativeTime(value) { if (!value) return "Just now"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently"; @@ -59,19 +81,25 @@ function formatRelativeTime(value) { if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; return formatTime(value); } + function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); } function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); } function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); } + // event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of // clicking the button), which previously crashed every save handler below on `button.disabled = true` // and silently dropped the whole save. Fall back to the form's actual submit button. function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); } function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; } + // #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the // hidden site-scoped (#settings-hosted-advanced) and proxy-scoped (#settings-advanced) sections. form.elements.NAME // resolves to a RadioNodeList when a name is duplicated, and assigning .value/.checked to a RadioNodeList of // non-radio inputs silently does nothing — so every one of these fields must be read/written through its scope. function setScoped(form, scope, name, value) { const el = form.querySelector(`${scope} [name="${name}"]`); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(value); else el.value = value; } + +// advancedFormBody -- reads the scoped or unscoped "advanced options" fields off +// a create/edit form and merges them into the outgoing request body. function advancedFormBody(form, body, scoped) { // scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where // field names collide with another section, so every ambiguous field is read from its own scope instead of @@ -88,6 +116,8 @@ function advancedFormBody(form, body, scoped) { delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText; return body; } + +// --- Proxy/Hosted settings dialog: submit handler --------------------------------- // Single capture-path for monitoring settings: unchecked checkboxes must be sent as false. document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || !state.editing) return; @@ -112,6 +142,8 @@ document.addEventListener("submit", async event => { finally { button.disabled = false; } }, true); + +// --- Dashboard rendering ---------------------------------------------------------- function healthCopy(group, label) { if (!group.total) return "Nothing configured"; if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`; @@ -126,6 +158,7 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") { return service.status === "ready" ? ready : error; } + function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); } function renderDashboard() { @@ -181,8 +214,11 @@ function renderDashboard() { $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}>${escapeHtml(item.name)}${escapeHtml(item.message)}`).join("") : '
Everything looks good — no issues to review.
'; $("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `
${item.status === "error" || item.status === "warning" ? "!" : "✓"}${escapeHtml(item.message)}${escapeHtml(formatRelativeTime(item.at))}
`).join("") : '

No recent activity.

'; } + setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000); + +// --- Card rendering helpers (icons, permissions) ----------------------------------- function initials(name) { const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean); if (!words.length) return "??"; @@ -193,6 +229,8 @@ document.addEventListener('error', event => { const image = event.target; if (!( function canManage() { return ["administrator", "standard"].includes(state.user?.role); } function canAdmin() { return state.user?.role === "administrator"; } + +// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------ function hostedCard(site) { const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled"; const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`; @@ -209,6 +247,8 @@ function proxyCard(proxy) { return `
${iconMarkup(proxy)}
${menu}

${escapeHtml(proxy.name)}

${escapeHtml(proxy.target)}

${escapeHtml(publicUrl(proxy))}

${upstream}

${escapeHtml(access)}

`; } + +// --- Certificates view -------------------------------------------------------------- function renderCertificates() { const data = state.certificates; if (!data) return; $("#certificate-count").textContent = data.summary.total; @@ -219,6 +259,8 @@ function renderCertificates() { renderReadiness(); } + +// --- Domain readiness (used inside the Certificates view) --------------------------- function renderReadiness() { const routes = state.readiness?.routes || []; $("#readiness-list").innerHTML = routes.length ? routes.map(item => { @@ -232,6 +274,8 @@ function renderReadiness() { }).join("") : '

No configured domains to check.

'; } + +// --- Logs view ------------------------------------------------------------------------- function renderLogs() { const data = state.logs; if (!data) return; const selected = $("#log-host").value; $("#log-host").innerHTML = '' + data.hosts.map(host => ``).join(""); $("#log-host").value = selected; @@ -245,6 +289,9 @@ function renderLogs() { $("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `
${escapeHtml(item.message)}${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}
`; }).join("") : '
No matching gateway eventsTry a different severity or category filter.
'; } + +// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline), +// and the per-domain throughput table ----------------------------------------------- function renderPerformance() { const data = state.performance; if (!data) return; const selected = $("#performance-host").value; @@ -296,6 +343,8 @@ function renderPerformance() { if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" }); } + +// --- Administration > Users view --------------------------------------------------------- function renderUsers() { const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 }; state.users.forEach(user => { if (user.status === "active") counts[user.role] = (counts[user.role] || 0) + 1; else if (counts[user.status] !== undefined) counts[user.status] += 1; }); @@ -315,6 +364,8 @@ function renderUsers() { document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = ''; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); }); } + +// --- Account panel (profile, MFA status) -------------------------------------------------- function renderAccount() { if (!state.user) return; $("#account-display-name").textContent = state.user.displayName || "—"; @@ -328,6 +379,8 @@ function renderAccount() { $("#account-mfa-recovery").classList.toggle("hidden", !enabled); } + +// --- View routing: what data to (re)load and what to show for state.view ------------------ async function loadFeatureView() { if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); } if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); } @@ -336,6 +389,9 @@ async function loadFeatureView() { if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.(); restoreAdminTab(); } +// render() -- the main view switcher. Shows/hides each top-level section based on +// state.view, and for the Hosted/Proxy "management" view, renders the card grid, +// empty state, and summary indicator bar directly. function render() { const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view; if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`); @@ -386,6 +442,8 @@ function render() { $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; } + +// --- Data refresh helpers ------------------------------------------------------------------ async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } } async function refreshPendingProxies(ids = []) { const pending = new Set(ids.map(String)); @@ -401,6 +459,8 @@ async function refreshDashboard() { try { state.dashboard = await api("/api/dashboard"); renderDashboard(); } finally { button.disabled = false; button.classList.remove("spinning"); } } + +// --- Boot: session check, initial routing, periodic health/update checks ------------------- function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); } async function boot() { const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`); @@ -417,26 +477,38 @@ async function boot() { if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000); if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000); } + async function checkForUpdate() { if (state.updateAvailable || !state.loadedVersion) return; const config = await api("/api/config"); if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); } } + +// --- Update-available banner -------------------------------------------------------------- $("#update-banner-refresh").addEventListener("click", () => location.reload()); $("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; }); + +// --- Login, MFA login, first-run setup, and logout ----------------------------------------- $("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#login-error").textContent = error.message; } }); $("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } }); $("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#login-form").elements.password.value = ""; setTimeout(() => $("#login-form").elements.password.focus(), 0); }); $("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } }); $("#setup-dialog").addEventListener("cancel", event => event.preventDefault()); $("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); }); + +// --- Dashboard actions: run certificate check, download support report, jump to +// an attention item's view ------------------------------------------------------------- $("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } }); $("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; }); $("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } }); + +// --- Primary navigation (sidebar view switching) ------------------------------------------- function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); } document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } })); $("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); }); + +// --- Logs & Performance filter controls ----------------------------------------------------- $("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message))); $("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); $("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message))); @@ -444,6 +516,8 @@ $("#performance-range").addEventListener("change", () => loadFeatureView().catch $("#log-status").addEventListener("change", renderLogs); $("#event-severity").addEventListener("change", renderLogs); $("#event-category").addEventListener("change", renderLogs); + +// --- "Create" dialog: opens the right create form/dialog for the current view -------------- function openCreate() { if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); } if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); } @@ -453,15 +527,23 @@ function openCreate() { $("#create-form").reset(); $("#create-form").querySelectorAll("details").forEach(details => details.open = false); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal(); } $("#open-create").addEventListener("click", openCreate); + +// --- Global dialog/menu behavior (Escape to close menus, dialog close resets state) --------- document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); }); document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); }); document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); })); + +// --- Hosted Sites & Proxy Hosts: create form submit handlers -------------------------------- $("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message))); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); $("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } }); + +// --- Health-check field visibility polish for the create forms ------------------------------ function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", ''); }); } setInterval(ensureHostedHealthFields, 300); + +// --- Hosted/Proxy edit ( "Domain & TLS" / "Edit proxy host" ) settings dialog --------------- function openSettings(kind, id) { if (kind === "hosted") kind = "site"; const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset(); form.querySelectorAll("details").forEach(details => details.open = false); @@ -482,6 +564,8 @@ function openSettings(kind, id) { document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom"); } + +// --- Hosted/Proxy card actions: toggle / edit / delete / replace files / change icon -------- $("#site-grid").addEventListener("click", async event => { const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind; if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return; @@ -492,14 +576,21 @@ $("#site-grid").addEventListener("click", async event => { if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); } if (action === "icon") openIconPicker(kind, card.dataset.id); }); + +// --- Redirect card actions delegated from the site grid (menu open/close, edit/ +// icon/delete/toggle) --------------------------------------------------------------------- document.querySelector("#redirect-list")?.addEventListener("click", event => { const card = event.target.closest(".redirect-card"); if (!card) return; if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; } const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); } }); + +// --- Delete confirmation dialog and replace-files handler ------------------------------------ $("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; }); $("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; }); + +// --- Icon picker dialog: search, upload, URL, and reset-to-fallback ------------------------- function openIconPicker(kind, id) { state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '

Enter at least two characters to search.

'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0); } @@ -537,6 +628,8 @@ $("#save-icon-url").addEventListener("click", async () => { try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); } catch (error) { $("#icon-error").textContent = error.message; } }); + +// --- User management: create, edit (role/status), password reset, delete -------------------- $("#user-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = ""; try { @@ -545,6 +638,7 @@ $("#user-form").addEventListener("submit", async event => { } catch (error) { $("#user-error").textContent = error.message; } finally { button.disabled = false; } }); + function themedUserConfirm(message, title = "Confirm action") { let dialog = document.querySelector("#user-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "user-confirm-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Administration

${escapeHtml(title)}

${escapeHtml(message)}

`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); } $("#user-list").addEventListener("click", async event => { const menuCard = event.target.closest(".user-card"); @@ -571,6 +665,7 @@ $("#user-list").addEventListener("click", async event => { } catch (error) { toast(error.message); } finally { button.disabled = false; } }); + $("#password-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = ""; try { @@ -579,6 +674,8 @@ $("#password-form").addEventListener("submit", async event => { } catch (error) { $("#password-error").textContent = error.message; } finally { button.disabled = false; } }); + +// --- Hash-based routing: back/forward and deep links (#view or #administration/tab) --------- window.addEventListener("hashchange", () => { if (!state.user) return; // Not logged in yet; boot() handles initial routing. const requestedHash = location.hash.slice(1); @@ -588,8 +685,11 @@ window.addEventListener("hashchange", () => { render(); loadFeatureView().catch(error => toast(error.message)); }); + boot().catch(error => toast(error.message)); + +// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------ function syncUpstreamTlsControls(form) { if (!form || !form.elements.target) return; const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean); @@ -603,14 +703,22 @@ function syncUpstreamTlsControls(form) { const lbPolicy = form.elements.lbPolicy; if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; } } + document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); }); document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); }); document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form)); + +// --- Misc global click delegation (create triggers, generic [data-action] handlers) --------- document.addEventListener("click", event => { if (event.target.closest(".create-trigger,[data-action=edit],[data-card-action=edit]")) setTimeout(() => { syncUpstreamTlsControls(document.querySelector("#proxy-form")); syncUpstreamTlsControls(document.querySelector("#settings-form")); }, 0); }); document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); }); + +// --- Access Lists: periodic live refresh while that view is open ---------------------------- setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500); + function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } + +// --- Account: change password form ------------------------------------------------------------ $("#account-password-form").addEventListener("submit", async event => { event.preventDefault(); $("#account-password-error").textContent = ""; @@ -623,6 +731,9 @@ $("#account-password-form").addEventListener("submit", async event => { } catch (error) { $("#account-password-error").textContent = error.message; } }); + +// --- MFA: password re-confirmation dialog used before disabling MFA or +// regenerating recovery codes -------------------------------------------------------------- let mfaPasswordResolve = null; function requestMfaPassword(title, heading) { $("#mfa-password-title").textContent = title; @@ -641,6 +752,8 @@ $("#mfa-password-form").addEventListener("submit", event => { }); $("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; }); + +// --- MFA: enable / setup / confirm flow -------------------------------------------------------- $("#account-mfa-enable").addEventListener("click", async () => { try { const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); @@ -668,6 +781,8 @@ $("#mfa-setup-confirm-form").addEventListener("submit", async event => { }); $("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); }); + +// --- MFA: disable and regenerate recovery codes ------------------------------------------------- $("#account-mfa-disable").addEventListener("click", async () => { const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue"); if (!password) return; diff --git a/src/public/features.js b/src/public/features.js index 8eb787b..e57174a 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -1,8 +1,21 @@ +// ============================================================================ +// features.js -- rendering and event handling for the "extended" views that +// live alongside app.js: Streaming Hosts, Redirect Hosts, Access Lists, and the +// entire Administration area (Users, Groups, Gateway defaults, Backup & restore, +// Security & updates, Logs & Retention). app.js owns Hosted Sites/Proxy Hosts and +// the core dialogs; this file owns everything else, wired up as a set of module- +// level event listeners plus render functions called from window.renderExtendedViews. +// ============================================================================ + +// --- Small shared helpers ------------------------------------------------- function extendedEscape(value) { return escapeHtml(value); } function featureIcon(item, fallback) { return item.icon ? `` : fallback; } const backupDialogTextFix = new MutationObserver(() => { const dialog = document.querySelector("#create-backup-dialog"); if (dialog) dialog.querySelectorAll("p,small").forEach(node => { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); }); backupDialogTextFix.observe(document.body, { childList:true, subtree:true }); + +// --- Streaming Hosts view --------------------------------------------------- +// Renders the Streaming Hosts grid and its shared empty state. function renderStreams() { const list = document.querySelector("#stream-list"), empty = document.querySelector("#stream-empty"); if (!state.loaded) return; @@ -16,6 +29,10 @@ function renderStreams() { }).join(""); } + +// --- Redirect Hosts view ----------------------------------------------------- +// Renders the Redirect Hosts grid. Redirect cards render their own empty state +// inline (no separate #redirect-empty toggle needed here). function renderRedirects() { const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty"); // Keep the existing cards or empty state mounted while the shared refresh is pending. @@ -25,6 +42,11 @@ function renderRedirects() { list.innerHTML = state.redirects.map(item => `
${featureIcon(item,"RD")}

${extendedEscape(item.name)}

${extendedEscape(item.domain)}

→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}

`).join(""); } + +// --- Access Lists view -------------------------------------------------------- +// Renders the Access Lists grid (including its own empty state) and refreshes +// every "Access List"

Open this tab to load audit records.

'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `
${item.status === "error" ? "!" : "✓"}${extendedEscape(item.action)}${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}
`).join("") : '

No matching audit records.

'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); } function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `

Logs & retention

Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.

`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); } +// The single entry point app.js calls after every refresh() to re-render every +// view and admin panel owned by this file. window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '

AUTOMATIC LOG PRUNING

'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '
'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; + +// Populate the scheduled-backup "Hour" One alias per line. All source domains use this redirect destination.'; source.closest("label").after(label); } if (!document.querySelector("#redirect-form [name=accessListId]")) { const tls = document.querySelector("#redirect-form [name=tls]")?.closest("label"); if (tls) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this redirect and all of its source domains.'; tls.before(label); } } + +// --- Access List: create/edit dialog -------------------------------------------- document.querySelector("#access-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), body = { name:form.get("name"), networks:form.get("networks"), deniedNetworks:form.get("deniedNetworks") }; const editorRows = [...document.querySelectorAll("#access-credential-editor .credential-row")]; body.credentials = editorRows.map(row => ({ username: row.querySelector("[name=credentialUsername]").value.trim(), password: row.querySelector("[name=credentialPassword]").value })).filter(entry => entry.username); if (!event.target.dataset.editing) body.groups = [...document.querySelectorAll("#access-create-groups [data-create-group]:checked")].map(input => input.dataset.createGroup); document.querySelector("#access-error").textContent = ""; try { const id = event.target.dataset.editing; await api(id ? `/api/access-lists/${id}` : "/api/access-lists", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#access-dialog").close(); await refresh(); toast(`Access List ${id ? "updated" : "created"}.`); } catch (error) { document.querySelector("#access-error").textContent = error.message; } }); +// Renders the login-credential rows inside the Access List dialog. function renderCredentialEditor(credentials = []) { const editor = document.querySelector("#access-credential-editor"); editor.classList.remove("hidden"); editor.innerHTML = `
Login accounts

Add a username and password. When editing an existing user, leave the password blank to keep it unchanged.

${credentials.map(credential => `
`).join("")}`; document.querySelector("#add-access-credential").addEventListener("click", () => { const row = document.createElement("div"); row.className = "credential-row"; row.innerHTML = ''; editor.append(row); row.querySelector(".remove-credential").addEventListener("click", () => row.remove()); }); editor.querySelectorAll(".remove-credential").forEach(button => button.addEventListener("click", () => button.closest(".credential-row").remove())); } window.renderCredentialEditor = renderCredentialEditor; + +// Redirect card options menu actions: edit / change icon / delete. document.querySelector("#redirect-list").addEventListener("click", async event => { const button = event.target.closest("[data-redirect-action]"), card = button?.closest("[data-redirect-id]"); if (!button || !card) return; const item = state.redirects.find(value => value.id === card.dataset.redirectId); if (!item) return; try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); } }); + +// Themed replacement for a native confirm() dialog, used for destructive +// Access List actions. function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false, eyebrow = "Access Lists") { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

${extendedEscape(eyebrow)}

${extendedEscape(title)}

${copy}

${confirmLabel ? `` : ""}
`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } +// Access List card actions: edit / change icon / view assignments / toggle / +// delete. document.querySelector("#access-list").addEventListener("click", async event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-access-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#access-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; } const button = event.target.closest("[data-access-action]"), row = button?.closest("[data-access-id]"); if (!button || !row) return; const item = state.accessLists.find(value => value.id === row.dataset.accessId); if (!item) return; @@ -148,17 +209,26 @@ document.querySelector("#access-list").addEventListener("click", async event => }); document.querySelector("#access-list").addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); const item = state.accessLists.find(value => value.id === row?.dataset.accessId); if (!item) return; setTimeout(() => { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; summary.innerHTML = assigned.length ? `Assigned hosts (${assigned.length})
${assigned.map(host => `${extendedEscape(host.name || host.domain)}${extendedEscape(host.domain || "No domain")}`).join("")}
` : `Assigned hostsThis Access List is not assigned to a host yet.`; }, 0); }); + +// --- Administration: tab switching between admin panel sections ---------------- document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === button)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== button.dataset.adminTab)); document.querySelector("#open-create").classList.toggle("hidden", button.dataset.adminTab !== "users"); }); document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; state.adminTab = button.dataset.adminTab; history.replaceState(null, "", `${location.pathname}${location.search}#administration/${state.adminTab}`); }); if (state.adminTab && state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab}"]`)?.click(); + +// --- Gateway defaults form submit ------------------------------------------------ document.querySelector("#default-site-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), value = Object.fromEntries(form); value.preservePath = form.has("preservePath"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({defaultSite:value}) }); toast("Default Site validated and applied."); } catch (error) { document.querySelector("#default-error").textContent = error.message; } }); + +// --- Backup & restore: schedule type change, health settings save, restore +// defaults, and factory reset -------------------------------------------------- document.querySelector("#backup-settings-form [name=type]")?.addEventListener("change", event => { document.querySelector("#backup-type-help").textContent = event.target.value === "complete" ? "Complete backups include configuration, uploaded Hosted Site files, icons, default-site assets, and certificate storage. Verify the file count after creation." : "Configuration-only backups include settings and metadata, but not uploaded Hosted Site files."; }); document.querySelector("#backup-settings-form")?.insertAdjacentHTML("beforeend", '

'); document.querySelector("#backup-settings-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), backups = Object.fromEntries(form); delete backups.backupPassword; backups.enabled = form.has("enabled"); backups.includeLogs = form.has("includeLogs"); backups.encrypt = form.has("encrypt"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({backups}) }); const scheduleStatus = document.querySelector("#backup-schedule-status"); scheduleStatus.className = `inline-status ${backups.enabled ? "status-success" : "status-warning"}`; scheduleStatus.textContent = backups.enabled ? `Scheduled backups enabled · ${backups.frequency} · ${backups.type === "complete" ? "complete backups" : "configuration backups"}.` : "Scheduled backups disabled. Your saved schedule remains available if you enable it later."; } catch (error) { toast(error.message); } }); document.querySelector("#health-settings-form").addEventListener("submit", async event => { event.preventDefault(); const certificateHealth = Object.fromEntries(new FormData(event.target)); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({certificateHealth}) }); renderHealthSettings(); toast("Certificate health thresholds saved."); } catch (error) { toast(error.message); } }); const restoreDefaultsButton = document.querySelector("#restore-defaults"); restoreDefaultsButton.insertAdjacentHTML("beforebegin", '

'); const restoreUsername = document.querySelector("#restore-admin-username"), restorePassword = document.querySelector("#restore-admin-password"), restoreConfirmation = document.querySelector("#restore-confirmation"), restoreInlineError = document.querySelector("#restore-defaults-error"); restoreUsername.addEventListener("blur", async () => { if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim()})}); restoreInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { restoreInlineError.textContent = error.message; } }); restorePassword.addEventListener("blur", async () => { if (!restorePassword.value) { restoreInlineError.textContent = "Enter the administrator password."; return; } if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim(),password:restorePassword.value})}); restoreInlineError.textContent = ""; } catch (error) { restoreInlineError.textContent = "The password is incorrect for the entered administrator."; } }); restoreConfirmation.addEventListener("blur", () => { if (restoreConfirmation.value && restoreConfirmation.value.trim() !== "RESTORE DEFAULT") restoreInlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; }); restoreDefaultsButton.addEventListener("click", async event => { event.preventDefault(); const username = document.querySelector("#restore-admin-username").value.trim(), password = document.querySelector("#restore-admin-password").value, confirmation = document.querySelector("#restore-confirmation").value.trim(); const inlineError = document.querySelector("#restore-defaults-error"); inlineError.textContent = ""; if (!username) { inlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { inlineError.textContent = "That administrator username was not found."; return; } } catch (error) { inlineError.textContent = error.message; return; } if (!password) { inlineError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { inlineError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "RESTORE DEFAULT") { inlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; return; } let dialog = document.querySelector("#restore-defaults-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "restore-defaults-confirm-dialog"; dialog.innerHTML = '

Gateway preferences

Confirm restore defaults

This restores the default site behavior, backup scheduling, certificate thresholds, and interface preferences. Users, routes, hosted files, certificates, logs, backups, and Access Lists will remain unchanged.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; return; } try { state.settings = await api("/api/settings/reset-defaults", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password,confirmation})}); dialog.querySelector(".dialog-card").innerHTML = '

Gateway preferences

Defaults restored

The gateway preferences were restored successfully. Your data and routes were preserved.

'; state.settings = await api("/api/settings"); renderDefaultSettings(); renderBackups(); document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; dialog.showModal(); setTimeout(() => dialog.close(), 900); } catch (error) { dialog.querySelector("[data-restore-error]").textContent = error.message; if (!dialog.open) dialog.showModal(); } }); const factoryResetForm = document.querySelector("#factory-reset-form"), factoryUsername = factoryResetForm.elements.username, factoryPassword = factoryResetForm.elements.password, factoryConfirmation = factoryResetForm.elements.confirmation, factoryInlineError = document.querySelector("#factory-reset-error"); factoryUsername.addEventListener("blur", async () => { if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim()})}); factoryInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { factoryInlineError.textContent = error.message; } }); factoryPassword.addEventListener("blur", async () => { if (!factoryPassword.value) { factoryInlineError.textContent = "Enter the administrator password."; return; } if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim(),password:factoryPassword.value})}); factoryInlineError.textContent = ""; } catch (error) { factoryInlineError.textContent = "The password is incorrect for the entered administrator."; } }); factoryConfirmation.addEventListener("blur", () => { if (factoryConfirmation.value && factoryConfirmation.value.trim() !== "FACTORY RESET") factoryInlineError.textContent = "Type FACTORY RESET exactly to continue."; }); document.querySelector("#factory-reset-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), confirmation = String(form.get("confirmation") || ""), resetError = document.querySelector("#factory-reset-error"), username = String(form.get("username") || "").trim(), password = String(form.get("password") || ""); resetError.textContent = ""; if (!username) { resetError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { resetError.textContent = "That administrator username was not found."; return; } } catch (error) { resetError.textContent = error.message; return; } if (!password) { resetError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { resetError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "FACTORY RESET") { resetError.textContent = "Type FACTORY RESET exactly to continue."; return; } let dialog = document.querySelector("#factory-reset-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "factory-reset-confirm-dialog"; dialog.innerHTML = '

Permanent action

Confirm factory reset

This will permanently delete all Site Gateway data under /data, including hosted files, routes, users, groups, Access Lists, certificates, logs, backups, and settings. The container will restart and return to the first-install setup screen.

After restart, open the management URL again and use the original installation credentials to begin setup.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; factoryResetForm.reset(); factoryInlineError.textContent = ""; return; } try { await api("/api/factory-reset", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.fromEntries(form))}); dialog.querySelector(".dialog-card").innerHTML = '

Permanent action

Factory reset in progress

Site Gateway is deleting its data and restarting. Keep this window open. The first-install setup screen will open automatically when the container is ready.

Restarting in 10 seconds…

'; dialog.showModal(); let seconds = 10; const timer = setInterval(() => { seconds--; const counter = dialog.querySelector("[data-reset-countdown]"); if (counter) counter.textContent = String(seconds); if (seconds <= 0) { clearInterval(timer); if (counter) counter.textContent = "Opening setup…"; dialog.close(); const openSetup = () => { window.location.href = "/"; }; (async () => { for (let attempt = 0; attempt < 30; attempt++) { try { const response = await fetch("/api/session", { cache: "no-store" }); if (response.ok) { openSetup(); return; } } catch {} await new Promise(resolve => setTimeout(resolve, 1000)); } openSetup(); })(); window.setTimeout(openSetup, 5000); } }, 1000); } catch (error) { document.querySelector("#factory-reset-error").textContent = error.message; } }); + +// --- Backup import / manual create / restore flows ------------------------------- document.querySelector("#import-backup").addEventListener("click", () => document.querySelector("#backup-upload").click()); async function themedConfirm(title, message, actionLabel = "Continue") { let dialog = document.querySelector("#backup-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "backup-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Backup & restore

${title}

${message}

`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } @@ -167,18 +237,27 @@ document.querySelector("#backup-upload").addEventListener("change", async event document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } }); const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; }); + +// --- Documentation accordion (Administration > Documentation) -------------------- document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"instant", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let topResult = null, topOrder = Infinity; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query), order = topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2; article.style.order = ""; if (match && order < topOrder) { topOrder = order; topResult = article; } }); articles.forEach(article => article.classList.toggle("hidden", Boolean(query) && article !== topResult)); document.querySelector("#doc-empty").classList.toggle("hidden", Boolean(!query || topResult)); }); + +// --- Proxy dialog: show/hide the custom-certificate fields based on TLS mode ----- document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open")); document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); })); + +// --- Access List assignment editor: which hosts use this Access List ------------- function renderAssignmentEditor(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; const hosts = [...state.sites.map(host => ({ ...host, kind: "sites", label: "Hosted Site" })), ...state.proxies.map(host => ({ ...host, kind: "proxies", label: "Proxy Host" })), ...state.redirects.map(host => ({ ...host, kind: "redirects", label: "Redirect Host" }))]; summary.classList.remove("hidden"); summary.innerHTML = "Protected hosts

Select the routes this Access List should protect. Changes apply immediately.

" + (hosts.length ? hosts.map(host => "").join("") : "Create a Hosted Site, Proxy Host, or Redirect Host first.") + "
"; } document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; const kind = checkbox.dataset.assignmentKind; try { await api("/api/" + kind + "/" + checkbox.dataset.assignmentId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessListId: checkbox.checked ? document.querySelector("#access-form")?.dataset.editing || "" : "" }) }); await refresh(); const current = document.querySelector("#access-form")?.dataset.editing; if (current) renderAssignmentEditor(current); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }); document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAssignmentEditor(row.dataset.accessId), 0); }); +// Search/filter within the assignment editor. function ensureAssignmentSearch() { const editor = document.querySelector("#access-assignment-summary .assignment-editor"); if (!editor || editor.querySelector(".assignment-search")) return; const label = document.createElement("label"); label.className = "assignment-search-label"; label.textContent = "Filter hosts"; const input = document.createElement("input"); input.className = "assignment-search"; input.type = "search"; input.placeholder = "Name, type, or domain"; input.setAttribute("aria-label", "Filter hosts"); label.append(input); editor.before(label); input.addEventListener("input", () => { const query = input.value.trim().toLowerCase(); editor.querySelectorAll(".assignment-option").forEach(option => { option.hidden = query && !option.textContent.toLowerCase().includes(query); }); }); } document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(ensureAssignmentSearch, 0)); document.addEventListener("input", event => { const input = event.target.closest("#access-assignment-summary .assignment-search"); if (!input) return; const query = input.value.trim().toLowerCase(); document.querySelectorAll("#access-assignment-summary .assignment-option").forEach(option => { option.hidden = Boolean(query) && !option.textContent.toLowerCase().includes(query); }); }); document.addEventListener("change", async event => { const checkbox = event.target.closest("#access-assignment-summary [data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; try { await api("/api/access-lists/" + accessListId + "/assignments", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ kind:checkbox.dataset.assignmentKind, hostId:checkbox.dataset.assignmentId, assigned:checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; const kind = checkbox.dataset.assignmentKind; if (!accessListId) { checkbox.checked = !checkbox.checked; toast("Open an Access List before assigning hosts."); return; } try { await api("/api/access-lists/" + accessListId + "/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, hostId: checkbox.dataset.assignmentId, assigned: checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); + +// --- Groups admin panel (dynamically inserted "Groups" tab) ---------------------- function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '

Groups

Organize users for Access List permissions.

' + (state.groups.length ? '
' + state.groups.map(group => '
GR

' + extendedEscape(group.name) + '

' + (group.members?.length || 0) + ' members

').join("") + '
' : '

No groups yet. Create one to organize users.

'); } function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Edit group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } @@ -198,6 +277,8 @@ function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="gr document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true); function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","security","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); } document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); }); + +// --- Backup encryption password field: placeholder/visibility polish ------------- const backupPasswordInput = document.querySelector('#backup-settings-form [name="backupPassword"]'); if (backupPasswordInput) { backupPasswordInput.placeholder = "Optional — enter a password"; @@ -213,8 +294,12 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { const toggle = document.querySelector("#backup-password-toggle"); toggle.addEventListener("click", () => { const visible = backupPasswordInput.type === "text"; backupPasswordInput.type = visible ? "password" : "text"; toggle.textContent = visible ? "Show" : "Hide"; toggle.setAttribute("aria-label", visible ? "Show backup encryption password" : "Hide backup encryption password"); toggle.setAttribute("aria-pressed", String(!visible)); }); } + +// Manual "Create backup" button. document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); + +// --- Logs & Retention admin panel (Administration > Logs & Retention) ------------ function normalizeRetentionLayout() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form || form.dataset.normalized === "true") return; const actions = form.querySelector(".dialog-actions"); const fields = [...form.children].filter(child => child !== actions); const section = document.createElement("div"); section.className = "form-section form-section-wide"; const eyebrow = document.createElement("p"); eyebrow.className = "eyebrow"; eyebrow.textContent = "Automatic Log Pruning"; section.append(eyebrow); const grid = document.createElement("div"); grid.className = "form-grid"; fields.forEach(field => grid.append(field)); section.append(grid); form.prepend(section); if (actions) form.append(actions); form.dataset.normalized = "true"; } normalizeRetentionLayout(); function cleanRetentionLabels() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form) return; const descriptions = { 'Access logs':'High-volume request records.', 'Gateway activity':'Operational and configuration events.', 'Audit logs':'Administrative accountability records.', 'Certificate events':'Certificate issuance and health changes.', 'Security events':'Authentication and security-related events.' }; [...form.querySelectorAll('label:not(.check-control)')].forEach(field => { const text = field.firstChild; const name = text?.textContent?.trim().replace(/ \(days\)$/, ''); if (!text || !descriptions[name]) return; if (!text.textContent.includes('(days)')) text.textContent = `${name} (days)`; let help = field.querySelector('small'); if (!help) { help = document.createElement('small'); field.append(help); } help.textContent = descriptions[name]; }); } diff --git a/src/public/index.html b/src/public/index.html index 6fd9653..15f5d8f 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -9,6 +9,10 @@ + + + +
@@ -57,6 +63,10 @@
+ + + + + + + + + + + + + +

Two-factor authentication

@@ -265,11 +298,18 @@
+ + + + + + + + + +

New destination

Create a site

@@ -296,6 +342,8 @@
+ +

New route

Create a proxy host

@@ -312,10 +360,15 @@
+

New route

Create a streaming host

+

New route

Create a redirect host

+

Reusable protection

Create an Access List

+ +

Gateway settings

Edit route

@@ -333,9 +386,12 @@
+ +

Delete this site?

Its uploaded files will be permanently removed.

+

Appearance

Choose an icon

@@ -348,6 +404,7 @@
+

Administration

Create a user

@@ -359,6 +416,7 @@
+

Credentials

Reset password

@@ -367,9 +425,13 @@
+ +
+ diff --git a/src/server.js b/src/server.js index 28949fc..cc1c994 100644 --- a/src/server.js +++ b/src/server.js @@ -69,6 +69,8 @@ const probeFailures = { gateway: 0, http: 0, https: 0 }; let iconCatalog = null; let storage; + +// --- Small utility helpers (activity log, dir sizing, env parsing, passwords) ----------- function recordActivity(message, status = "ok") { const entry = { message, status, at: new Date().toISOString() }; recentActivity.unshift(entry); @@ -121,6 +123,8 @@ function activeAdministrators() { return users.filter(user => user.role === "administrator" && user.status === "active"); } + +// --- Sessions & auth cookies -------------------------------------------------------------- function slugify(value) { return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48); } @@ -151,6 +155,8 @@ const saveStreams = async () => storage.saveCollection("streams", streams); const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists); const saveSettings = async () => storage.saveSettings(settings); + +// --- Data loading (hosted sites) and shared validation helpers ----------------------------- async function clearDirectoryContents(directory) { await fsp.mkdir(directory, { recursive: true }); let lastError = null; @@ -214,6 +220,8 @@ async function loadSites() { await saveSettings(); } + +// --- Domain / target / stream-port validation ----------------------------------------------- function normalizeDomain(value) { return String(value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, ""); } @@ -263,6 +271,8 @@ function streamPortConflict(port, exceptId) { return null; } + +// --- Header / location / custom-config sanitizing for Proxy & Hosted advanced options ------- function cleanHeaders(value) { if (!Array.isArray(value)) return []; return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() })) @@ -285,6 +295,7 @@ function cleanCustomConfig(value) { return config; } + function applyAdvancedSettings(item, body) { if (body.upstreams !== undefined) { if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 }); @@ -313,6 +324,9 @@ function applyAdvancedSettings(item, body) { if (body.locations !== undefined) item.locations = cleanLocations(body.locations); } + +// --- Caddyfile generation: turns hosted sites/proxies/redirects/streams/access lists +// into the actual Caddy configuration and reloads Caddy with it ------------------------- function expectedStatusMatches(status, specification = "200-499") { return String(specification).split(",").some(part => { const value = part.trim(); @@ -383,6 +397,10 @@ function proxyBlock(target, item, indent = " ") { return output; } + +// Writes the themed default ("no route configured") static HTML page to disk. Keep +// this HTML in sync with the client-side preview in features.js's +// defaultSiteThemedHtml() -- see the comment there. async function writeDefaultSitePage() { const selected = settings.defaultSite || {}; const title = String(selected.title || (selected.mode === "welcome" ? "Gateway ready" : "Route not found")).replace(/[<>]/g, ""); @@ -393,6 +411,9 @@ async function writeDefaultSitePage() { await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html); } + +// renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state +// (sites, proxies, redirects, streams, access lists, default site settings). function renderCaddyfile() { const email = String(process.env.ACME_EMAIL || "").trim(); const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`]; @@ -425,6 +446,9 @@ function renderCaddyfile() { return `${lines.join("\n")}\n`; } + +// syncCaddy -- applies the generated config to the running Caddy instance and +// records success/failure (gatewayError, lastGatewayReload) for the dashboard. async function syncCaddy() { const nextPath = `${caddyfilePath}.next`; const previous = await fsp.readFile(caddyfilePath, "utf8").catch(() => null); @@ -457,6 +481,8 @@ async function syncCaddy() { } } + +// --- Status helpers & public (client-facing, secret-stripped) view builders ------------------ function siteStatus(site) { if (!site.enabled) return "disabled"; if (site.domain && gatewayError) return "error"; @@ -477,6 +503,8 @@ function publicStream(stream) { return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null }; } + +// --- Certificate inventory & domain readiness diagnostics -------------------------------------- async function walkFiles(directory) { const output = []; for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) { @@ -541,6 +569,7 @@ async function pruneOrphanedCertificates(candidateDomains) { if (removed.size) recordActivity(`Removed stored certificate data for ${orphaned.join(", ")} (no longer in use).`); } + async function domainReadiness() { const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); const certs = await certificateInventory(); @@ -554,6 +583,8 @@ async function domainReadiness() { })); } + +// --- Upstream (proxy target) health checks ------------------------------------------------------ async function checkProxy(proxy) { if (!proxy.enabled) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(proxy.id, result); return result; } if (proxy.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(proxy.id, result); return result; } @@ -584,6 +615,8 @@ async function checkAllProxies() { const SENSITIVE_QUERY_PARAM_PATTERNS = [/token/i, /secret/i, /password/i, /passwd/i, /auth/i, /session/i, /api[-_]?key/i, /credential/i]; + +// --- Access log ingestion (tailing Caddy's access log into SQLite) ------------------------------ function redactUri(uri) { const str = String(uri || ""); const queryIndex = str.indexOf("?"); @@ -624,6 +657,8 @@ async function importAccessLogsToSqlite() { } catch (error) { console.warn("Could not import access logs into SQLite:", error.message); } } + +// --- Raw TCP probing, used for streaming-host health checks -------------------------------------- function tcpProbe(port, timeoutMs = 1000) { return new Promise(resolve => { const socket = net.createConnection({ host: "127.0.0.1", port }); @@ -655,6 +690,8 @@ function stableProbe(name, responding) { : { status: "error", healthy: false, responding: false }; } + +// --- Icon catalog (searchable dashboard-icons list) & icon caching ------------------------------- async function loadIconCatalog() { if (iconCatalog) return iconCatalog; try { @@ -691,6 +728,9 @@ async function cacheIcon(slug) { return `/site-icons/${filename}`; } + +// --- Dashboard snapshot: aggregates health/status across every subsystem for the +// Overview page and the /api/dashboard endpoint -------------------------------------------- async function dashboardSnapshot() { const hosted = sites.map(publicSite); const proxyHosts = proxies.map(publicProxy); @@ -757,6 +797,8 @@ async function dashboardSnapshot() { }; } + +// --- Hosted site process/lifecycle control -------------------------------------------------------- async function startSite(site) { if (!site.enabled || activeServers.has(site.id)) return; const root = path.join(sitesDir, site.id); @@ -788,6 +830,8 @@ async function restartSite(site) { // Streaming hosts relay raw TCP/UDP on a specific port straight to a host:port target — no domain, no HTTP, // no Caddy involvement. This is the same pattern as startSite()/stopSite() above: a dedicated listener Site // Gateway owns directly, just for a plain socket instead of an HTTP server. + +// --- Streaming host process/lifecycle control ------------------------------------------------------- async function startStream(stream) { if (stream.enabled === false || activeStreams.has(stream.id)) return; const [targetHost, targetPortRaw] = String(stream.target || "").split(":"); @@ -869,6 +913,8 @@ async function checkStream(stream) { return result; } + +// --- Upload handling (hosted site ZIP install) ------------------------------------------------------ function validatePort(port, exceptId) { if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`; if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned."; @@ -913,6 +959,8 @@ async function installUpload(site, file) { const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "streams.json": () => streams, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings }; + +// --- Backups: create / open / list / restore, including encryption ----------------------------------- async function protectBackup(buffer, password) { if (!password) return buffer; const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12), key = await scryptAsync(password, salt, 32), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); @@ -1041,6 +1089,11 @@ const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } }); const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024, files: 1 } }); app.disable("x-powered-by"); + +// ============================================================================================ +// HTTP layer: Express app setup, auth middleware, and every /api/* route. +// Routes below are grouped by area; see the section comments for each group. +// ============================================================================================ app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.get(["/", "/index.html"], (req, res) => { @@ -1052,6 +1105,8 @@ app.get(["/", "/index.html"], (req, res) => { app.use(express.static(publicDir)); app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") })); + +// --- Session / login / MFA login / logout ------------------------------------------------------------ app.get("/api/session", (req, res) => { const user = sessionUser(req); res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null }); @@ -1129,6 +1184,8 @@ app.post("/api/logout", (req, res) => { res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.json({ ok: true }); }); + +// --- Public access-check endpoint used by Caddy's forward_auth for Access Lists ----------------------- function accessSession(req, listId) { const token = cookieMap(req.headers.cookie).site_gateway_access; if (!token) return null; const [storedList, username, expires, signature] = token.split("."); @@ -1143,6 +1200,8 @@ app.get("/api/access-check", (req, res) => { const original = String(req.headers["x-forwarded-uri"] || "/"); const safeReturn = original.startsWith("/") && !original.startsWith("//") ? original : "/"; res.redirect(302, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}`); }); + +// --- Themed login page served for Access-List-protected routes ------------------------------------------ app.get("/_site-gateway/login", (req, res) => { const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); if (!list) return res.status(404).send("Access policy not found."); const safeReturn = String(req.query.return || "/").startsWith("/") ? String(req.query.return || "/") : "/"; @@ -1157,6 +1216,8 @@ app.post("/_site-gateway/login", async (req, res, next) => { res.setHeader("Set-Cookie", `site_gateway_access=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure}`); res.redirect(303, safeReturn); } catch (error) { next(error); } }); + +// --- First-run admin setup --------------------------------------------------------------------------------- app.use("/api", (req, res, next) => { const user = sessionUser(req); if (!user) return res.status(401).json({ error: "Please sign in." }); @@ -1182,8 +1243,12 @@ app.post("/api/setup/admin", async (req, res, next) => { res.json({ ok: true }); } catch (error) { next(error); } }); + +// --- Everything below requires an authenticated session (auth middleware applied above) -------------------- app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); }); app.use("/api", (req, res, next) => { if (req.path.startsWith("/account/")) return next(); if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); }); + +// --- Account: password change & MFA setup/confirm/disable/recovery-codes ----------------------------------- app.post("/api/account/password", async (req, res, next) => { try { const currentPassword = String(req.body.currentPassword || ""); @@ -1242,6 +1307,8 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => { res.json({ ok: true, recoveryCodes: codes }); } catch (error) { next(error); } }); + +// --- Config, Users, Audit log, Groups, Access List <-> Group assignment -------------------------------------- app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } })); app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." })); @@ -1310,6 +1377,8 @@ app.post("/api/access-lists/:id/groups", async (req, res, next) => { try { if (r app.post("/api/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); if (groups.some(group => group.name.toLowerCase() === name.toLowerCase())) return res.status(409).json({ error: "That group already exists." }); const group = { id: "group-" + crypto.randomBytes(4).toString("hex"), name, enabled: true, members: [], createdAt: new Date().toISOString() }; groups.push(group); await saveGroups(); recordActivity("Group “" + name + "” created."); res.status(201).json(group); } catch (error) { next(error); } }); app.patch("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const group = groups.find(value => value.id === req.params.id); if (!group) return res.status(404).json({ error: "Group not found." }); if (req.body.name !== undefined) { const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); group.name = name; } if (req.body.enabled !== undefined) group.enabled = Boolean(req.body.enabled); if (Array.isArray(req.body.members)) group.members = [...new Set(req.body.members)].filter(id => users.some(user => user.id === id)); await saveGroups(); recordActivity("Group “" + group.name + "” updated."); res.json(group); } catch (error) { next(error); } }); app.delete("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const index = groups.findIndex(value => value.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Group not found." }); const [group] = groups.splice(index, 1); await saveGroups(); recordActivity("Group “" + group.name + "” deleted."); res.status(204).end(); } catch (error) { next(error); } }); + +// --- Settings, dashboard, certificates, health checks, domain readiness --------------------------------------- app.get("/api/settings", (req, res) => req.user.role === "administrator" ? res.json({ ...settings, backupDirectory: backupsDir }) : res.status(403).json({ error: "Administrator access is required." })); app.post("/api/settings/verify-admin", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error:"Administrator username or password is incorrect." }); res.json({ ok:true }); } catch (error) { next(error); } }); app.post("/api/settings/verify-username", (req, res) => { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); const username = String(req.body.username || "").trim().toLowerCase(); res.json({ valid: Boolean(username && username === String(req.user.username || "").toLowerCase()) }); }); @@ -1326,6 +1395,10 @@ app.post("/api/health/check", async (req, res, next) => { catch (error) { next(error); } }); app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } }); + +// GET /api/support-report -- generates the downloadable diagnostics report (gateway +// health, storage integrity, every route's config, certificate status, domain +// readiness, and recent activity) used for troubleshooting. app.get("/api/support-report", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); @@ -1335,6 +1408,8 @@ app.get("/api/support-report", async (req, res, next) => { res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2)); } catch (error) { next(error); } }); + +// --- Upstream health, Logs, and Performance (request throughput/trend) endpoints -------------------------------- app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy))); app.post("/api/upstreams/check", async (req, res, next) => { try { res.json(await checkAllProxies()); } @@ -1363,6 +1438,8 @@ app.get("/api/performance", (req, res, next) => { }); } catch (error) { next(error); } }); + +// --- Icon search and per-entity icon upload/URL/removal ----------------------------------------------------------- app.get("/api/icons/search", async (req, res, next) => { try { const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80); @@ -1422,6 +1499,8 @@ app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next } catch (error) { next(error); } finally { if (req.file?.path) await fsp.rm(req.file.path, { force: true }).catch(() => {}); } }); + +// --- Hosted Sites: create / toggle / replace files / delete / edit -------------------------------------------------- app.post("/api/sites", upload.single("files"), async (req, res, next) => { try { const name = String(req.body.name || "").trim(); @@ -1516,6 +1595,8 @@ app.patch("/api/sites/:id", async (req, res, next) => { res.json(publicSite(site)); } catch (error) { next(error); } }); + +// --- Proxy Hosts: create / edit / custom certificate upload / toggle / delete --------------------------------------- app.post("/api/proxies", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); @@ -1620,6 +1701,8 @@ app.delete("/api/proxies/:id", async (req, res, next) => { } catch (error) { next(error); } }); + +// --- Access Lists: create / edit / assignments / delete ------------------------------------------------------------ app.post("/api/access-lists", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); @@ -1685,6 +1768,8 @@ app.delete("/api/access-lists/:id", async (req, res, next) => { } catch (error) { next(error); } }); + +// --- Redirect Hosts: create / edit / delete ------------------------------------------------------------------------- app.post("/api/redirects", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const target = String(req.body.target || "").trim().replace(/\/$/, ""); @@ -1713,6 +1798,8 @@ app.delete("/api/redirects/:id", async (req, res, next) => { try { const index = redirects.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Redirect Host not found." }); const [item] = redirects.splice(index, 1); await syncCaddy(); await pruneOrphanedCertificates(normalizeDomains(item.domain, item.domains)); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); + +// --- Streaming Hosts: list / create / edit / toggle / delete ----------------------------------------------------------- app.get("/api/streams", (req, res) => res.json(streams.map(publicStream))); app.post("/api/streams", async (req, res, next) => { try { @@ -1777,6 +1864,8 @@ app.delete("/api/streams/:id", async (req, res, next) => { } catch (error) { next(error); } }); + +// --- Settings (general), log retention/pruning, log download, factory reset --------------------------------------------- app.patch("/api/settings", async (req, res, next) => { try { if (req.body.defaultSite) { @@ -1802,6 +1891,8 @@ app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } }); app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } }); app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } }); + +// --- Backups: list / create / import / download / restore / delete ----------------------------------------------------- app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } }); app.post("/api/backups", async (req, res, next) => { @@ -1825,8 +1916,11 @@ app.post("/api/backups/:filename/restore", async (req, res, next) => { app.delete("/api/backups/:filename", async (req, res, next) => { try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); } }); + function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); } const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|streams|access-lists)(\/|$)/i; + +// --- Error handling middleware & server startup ------------------------------------------------------------------------- app.use((error, req, res, next) => { console.error(error); const rawMessage = error.message || "Something went wrong."; @@ -1848,6 +1942,8 @@ app.listen(adminPort, "0.0.0.0", () => { setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref(); setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref(); + +// --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown --------------------------------- async function runScheduledBackup() { const schedule = settings.backups || {}; if (!schedule.enabled || Number(schedule.hour) !== new Date().getHours()) return; const last = schedule.lastRunAt ? new Date(schedule.lastRunAt) : null; const elapsed = last ? Date.now() - last.getTime() : Infinity; diff --git a/src/storage.js b/src/storage.js index 8316606..2a82e4d 100644 --- a/src/storage.js +++ b/src/storage.js @@ -1,3 +1,11 @@ +// ============================================================================ +// storage.js -- SQLite persistence layer for Site Gateway. +// Owns the on-disk database, one-time legacy JSON migration, and every +// read/write function server.js uses to load and save app data (sites, +// proxies, redirects, streams, access lists, users, groups, settings, +// activity/audit logs, and request performance data). +// ============================================================================ + import crypto from "node:crypto"; import fs from "node:fs"; import fsp from "node:fs/promises"; @@ -5,6 +13,10 @@ import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import AdmZip from "adm-zip"; + +// Legacy pre-SQLite storage: each entity kind used to live in its own JSON +// file under the data directory. entityTables maps the same kinds to their +// current SQLite table names. export const LOCAL_INSTANCE_ID = "local"; export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"]; const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" }; @@ -12,6 +24,11 @@ const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: function now() { return new Date().toISOString(); } +// One-time safety snapshot taken before migrating legacy JSON files into +// SQLite: zips up the JSON files plus related data directories (sites, +// icons, default-site, certificates) into a timestamped .sgbackup archive +// so the pre-migration state is always recoverable. + async function migrationSnapshot(dataDir, backupsDir, migrationsDir) { const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name))); if (!present.length) return null; @@ -31,6 +48,11 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) { manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName); manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")])); zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2))); + +// openStorage -- the single entry point server.js calls at boot. Ensures the +// data directories and SQLite database exist, runs schema setup and the +// legacy JSON migration (if needed), and returns the full set of +// read/write functions used throughout the app. const filename = `pre-sqlite-migration-${stamp}.sgbackup`; await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 }); return { filename, snapshotDir }; @@ -39,6 +61,13 @@ async function migrationSnapshot(dataDir, backupsDir, migrationsDir) { export async function openStorage(dataDir, backupsDir) { const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite"); await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]); + + // --- Schema setup ----------------------------------------------------- + // Core entity tables (hosted sites, proxy hosts, redirect hosts, stream + // hosts, access lists, users, groups) each store their record as a JSON + // payload column, plus supporting tables for access-list assignments, + // settings, audit/activity logs, and raw request (access) events used + // for performance reporting. const isNew = !fs.existsSync(databasePath); const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null; const db = new DatabaseSync(databasePath); @@ -61,18 +90,31 @@ export async function openStorage(dataDir, backupsDir) { CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id); CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id); CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id); + // Forward-compatible column add for databases created before "category" + // existed on activity_events; a no-op once the column is already there. CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id)); CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC); + + // --- Core entity read/write -------------------------------------------- + // transaction() wraps a block of statements in BEGIN IMMEDIATE/COMMIT, + // rolling back on any thrown error. CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source)); + // loadCollection -- reads every record of one entity kind (sites, proxies, + // redirects, streams, access_lists, users, groups) for an instance. CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC); + // refreshAssignments -- rebuilds the access_assignments table (which route + // is protected by which Access List) from the current hosted/proxy/ + // redirect payloads. Called after any save that could change accessListId. `); try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ } const timestamp = now(); db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp); db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp); + // saveCollection -- replaces (or, for access_lists, upserts/prunes) all + // records of one entity kind for an instance, inside a single transaction. function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); } @@ -91,17 +133,37 @@ export async function openStorage(dataDir, backupsDir) { const created = value.createdAt || now(), stored = { ...value, instanceId }; if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom")); insert.run(value.id, instanceId, JSON.stringify(stored), created, now()); + + // --- Settings ----------------------------------------------------------- } if (kind === "access_lists") { const keep = new Set(values.map(value => value.id)); + + // --- Database health ------------------------------------------------------ for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id); } + + // --- Audit & activity logs ----------------------------------------------- + // recordAudit -- administrative/security audit trail (who did what). if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId); + // recordActivity -- user-facing activity feed (what happened), auto- + // categorized into certificate/security/activity based on the message text. }); } + + // --- Access (request) events, powering Logs and Performance --------------- + // recordAccessEvents -- bulk-inserts raw request log lines tailed from + // Caddy's access log; ON IGNORE + UNIQUE(instance_id,source) makes re- + // ingesting the same log line idempotent. function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; } function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); } + // performanceLiveCount -- request count within the last windowSeconds, + // used for the "live requests" figure on the dashboard. function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); } + // performanceRoutes -- per-domain request/error/avg-response-time totals + // for the last hour and last 24 hours; backs the "Throughput by domain" + // table on the Performance page. A domain only appears here if it has + // at least one request within the last 24 hours (the dayCutoff filter). function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); } function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); } function listActivity(limit = 100, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT message,status,category,created_at AS at FROM activity_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 100, 500))); } @@ -115,6 +177,9 @@ export async function openStorage(dataDir, backupsDir) { SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests, SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors, AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs, + // performanceErrorBreakdown -- per-domain, per-status-code error counts + // over the last 24 hours; feeds the error-breakdown detail shown per row + // in the Performance table (top statuses per host). COUNT(*) AS dayRequests, SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors, AVG(duration_ms) AS dayAvgMs @@ -123,6 +188,9 @@ export async function openStorage(dataDir, backupsDir) { `).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff); } function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) { + // performanceTrend -- bucketed request counts over a configurable window + // (default 6 hours, 15-minute buckets), optionally filtered to one host; + // backs the "Requests" trend chart on the Performance page. const dayCutoff = new Date(Date.now() - 86400000).toISOString(); return db.prepare(` SELECT host, status, COUNT(*) AS count @@ -136,11 +204,29 @@ export async function openStorage(dataDir, backupsDir) { const cutoff = new Date(Date.now() - windowMs).toISOString(); const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host); const buckets = new Map(); + + // --- Log retention / pruning ---------------------------------------------- + // pruneEvents -- deletes access/activity/audit rows older than the + // configured retention policy (per category: access, activity, certificate, + // security, audit), returning how many rows were removed per category. + // Used by both the manual "prune now" action and the scheduled job. for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); } + // previewPruneEvents -- same policy/cutoffs as pruneEvents but read-only; + // used to show "this will remove N records" before the user confirms. const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs; const points = []; + + // --- Backups --------------------------------------------------------------- + // backupTo -- writes a consistent point-in-time copy of the SQLite database + // to `filename` using VACUUM INTO (safe to run against a live database). for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 }); return points; + + // --- One-time legacy JSON -> SQLite migration -------------------------------- + // Runs only when the database file didn't exist yet (isNew). Reads any + // legacy *.json files found in the data directory, inserts their records + // into the new SQLite tables inside a transaction, and rolls the whole + // database file back if anything fails partway through. } function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); } function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; } @@ -160,6 +246,11 @@ export async function openStorage(dataDir, backupsDir) { } } const settingsFile = path.join(dataDir, "settings.json"); + + // --- One-off cleanup of a previously confusing error message --------------- + // humanizeGatewayErrors -- rewrites a specific raw Caddy error string that + // used to appear verbatim in the activity/audit logs into a plain-language + // explanation. Runs at boot so existing log rows get the friendlier text too. if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp); refreshAssignments(LOCAL_INSTANCE_ID); }); } catch (error) {