Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb1bdc6aca |
@@ -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 = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; 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 = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; 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 = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
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)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
|
||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||
}
|
||||
|
||||
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 `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
|
||||
|
||||
// --- 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("") : '<p class="quiet-state">No configured domains to check.</p>';
|
||||
}
|
||||
|
||||
|
||||
// --- Logs view -------------------------------------------------------------------------
|
||||
function renderLogs() {
|
||||
const data = state.logs; if (!data) return;
|
||||
const selected = $("#log-host").value; $("#log-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).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 `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>';
|
||||
}
|
||||
|
||||
|
||||
// --- 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 = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; 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", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); }
|
||||
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 = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#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 = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>${escapeHtml(title)}</h2></div></div><p class="muted">${escapeHtml(message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Confirm</button></div></form>`; 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 = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
|
||||
|
||||
// --- 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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user