Compare commits

...

1 Commits

Author SHA1 Message Date
marvin a6060627d9 Static Administration tab bar; backup page UX improvements (v0.11.95) 2026-09-15 09:58:57 -04:00
5 changed files with 31 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "site-gateway", "name": "site-gateway",
"version": "0.11.93", "version": "0.11.95",
"private": true, "private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module", "type": "module",
+19 -3
View File
@@ -33,14 +33,30 @@ function renderAccessLists() {
document.querySelectorAll('select[name="accessListId"], #settings-access-list').forEach(select => { const value = select.value; select.innerHTML = options; select.value = value; }); document.querySelectorAll('select[name="accessListId"], #settings-access-list').forEach(select => { const value = select.value; select.innerHTML = options; select.value = value; });
} }
function backupTypeLabel(type) {
return type === "complete" ? "Complete" : type === "configuration" ? "Configuration only" : type === "encrypted" ? "Encrypted" : "Unknown";
}
function updateBackupConfigOnlyWarning() {
const form = document.querySelector("#backup-settings-form"), warning = document.querySelector("#backup-config-only-warning");
if (!form || !warning) return;
warning.classList.toggle("hidden", !(form.elements.enabled.checked && form.elements.type.value === "configuration"));
}
function renderBackups() { function renderBackups() {
if (!state.settings) return; if (!state.settings) return;
const form = document.querySelector("#backup-settings-form"), defaults = state.settings.backups || {}; const form = document.querySelector("#backup-settings-form"), defaults = state.settings.backups || {};
for (const key of ["type","frequency","hour","retention"]) if (form.elements[key] && defaults[key] !== undefined) form.elements[key].value = defaults[key]; for (const key of ["type","frequency","hour","retention"]) if (form.elements[key] && defaults[key] !== undefined) form.elements[key].value = defaults[key];
form.elements.enabled.checked = Boolean(defaults.enabled); form.elements.includeLogs.checked = Boolean(defaults.includeLogs); form.elements.encrypt.checked = Boolean(defaults.encrypt); form.elements.enabled.checked = Boolean(defaults.enabled); form.elements.includeLogs.checked = Boolean(defaults.includeLogs); form.elements.encrypt.checked = Boolean(defaults.encrypt);
updateBackupConfigOnlyWarning();
document.querySelector("#backup-path").textContent = `Backups are stored in ${state.settings.backupDirectory}. Separate storage can be mounted directly at /data/backups for disk-failure protection.`; document.querySelector("#backup-path").textContent = `Backups are stored in ${state.settings.backupDirectory}. Separate storage can be mounted directly at /data/backups for disk-failure protection.`;
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong>${extendedEscape(item.filename)}</strong><small>${formatTime(item.createdAt)}</small></div><div><strong>${extendedEscape(item.type)}</strong><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${formatBytes(item.size)}</strong><small>${item.valid ? "Verified manifest" : "Unreadable manifest"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>'; const completeBackups = state.backups.filter(item => item.type === "complete"), configBackups = state.backups.filter(item => item.type === "configuration");
const summaryEl = document.querySelector("#backup-summary");
if (summaryEl) summaryEl.textContent = state.backups.length ? `${completeBackups.length} Complete (${formatBytes(completeBackups.reduce((sum, item) => sum + item.size, 0))}), ${configBackups.length} Configuration only (${formatBytes(configBackups.reduce((sum, item) => sum + item.size, 0))}).` : "";
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong>${extendedEscape(item.filename)}</strong><small>${formatTime(item.createdAt)}</small></div><div><span class="chip type-${extendedEscape(item.type)}">${backupTypeLabel(item.type)}</span><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${formatBytes(item.size)}</strong><small>${item.valid ? "Verified manifest" : "Unreadable manifest"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>';
} }
document.querySelector("#backup-settings-form [name=\"enabled\"]")?.addEventListener("change", updateBackupConfigOnlyWarning);
document.querySelector("#backup-settings-form [name=\"type\"]")?.addEventListener("change", updateBackupConfigOnlyWarning);
function renderDefaultSettings() { function renderDefaultSettings() {
if (!state.settings) return; const form = document.querySelector("#default-site-form"), value = state.settings.defaultSite || {}; if (!state.settings) return; const form = document.querySelector("#default-site-form"), value = state.settings.defaultSite || {};
@@ -81,7 +97,7 @@ function renderHealthSettings() {
} }
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); } function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div id="audit-list" class="dashboard-list event-list"><p class="quiet-state">Open this tab to load audit records.</p></div>'; users.parentElement.insertBefore(panel, users.nextElementSibling); const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `<div class="event-row"><span class="activity-mark ${item.status === "error" ? "bad" : ""}">${item.status === "error" ? "!" : "✓"}</span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join("") : '<p class="quiet-state">No matching audit records.</p>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); } function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="audit"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="audit"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users.nextElementSibling); } if (panel.dataset.ready) return; panel.dataset.ready = "1"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div id="audit-list" class="dashboard-list event-list"><p class="quiet-state">Open this tab to load audit records.</p></div>'; const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `<div class="event-row"><span class="activity-mark ${item.status === "error" ? "bad" : ""}">${item.status === "error" ? "!" : "✓"}</span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join("") : '<p class="quiet-state">No matching audit records.</p>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); } function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); };
@@ -197,7 +213,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) {
const toggle = document.querySelector("#backup-password-toggle"); const toggle = document.querySelector("#backup-password-toggle");
toggle.addEventListener("click", () => { const visible = backupPasswordInput.type === "text"; backupPasswordInput.type = visible ? "password" : "text"; toggle.textContent = visible ? "Show" : "Hide"; toggle.setAttribute("aria-label", visible ? "Show backup encryption password" : "Hide backup encryption password"); toggle.setAttribute("aria-pressed", String(!visible)); }); toggle.addEventListener("click", () => { const visible = backupPasswordInput.type === "text"; backupPasswordInput.type = visible ? "password" : "text"; toggle.textContent = visible ? "Show" : "Hide"; toggle.setAttribute("aria-label", visible ? "Show backup encryption password" : "Hide backup encryption password"); toggle.setAttribute("aria-pressed", String(!visible)); });
} }
document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div><button value="cancel" formnovalidate class="icon-button" aria-label="Close">×</button></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="configuration">Configuration only — settings and metadata</option><option value="complete">Complete — configuration plus Hosted Site files</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form method="dialog" class="dialog-card backup-create-dialog"><div class="dialog-heading"><div><p class="eyebrow">Backup & restore</p><h2>Create a backup</h2></div><button value="cancel" formnovalidate class="icon-button" aria-label="Close">×</button></div><p class="muted">Choose what to include. Site Gateway saves a copy in <code>/data/backups</code> and downloads a copy to your computer.</p><label>Backup type<select name="type"><option value="complete">Complete (Recommended) — configuration plus Hosted Site files</option><option value="configuration">Configuration only — settings and metadata</option></select></label><small class="backup-dialog-help">Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.</small><label>Encryption password <span class="optional">Optional</span><input name="password" type="password" placeholder="Optional — enter a password" autocomplete="new-password"></label><small class="backup-dialog-help">If provided, this password is required to restore the downloaded archive.</small><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create backup</button></div></form>'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true);
function normalizeRetentionLayout() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form || form.dataset.normalized === "true") return; const actions = form.querySelector(".dialog-actions"); const fields = [...form.children].filter(child => child !== actions); const section = document.createElement("div"); section.className = "form-section form-section-wide"; const eyebrow = document.createElement("p"); eyebrow.className = "eyebrow"; eyebrow.textContent = "Automatic Log Pruning"; section.append(eyebrow); const grid = document.createElement("div"); grid.className = "form-grid"; fields.forEach(field => grid.append(field)); section.append(grid); form.prepend(section); if (actions) form.append(actions); form.dataset.normalized = "true"; } function normalizeRetentionLayout() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form || form.dataset.normalized === "true") return; const actions = form.querySelector(".dialog-actions"); const fields = [...form.children].filter(child => child !== actions); const section = document.createElement("div"); section.className = "form-section form-section-wide"; const eyebrow = document.createElement("p"); eyebrow.className = "eyebrow"; eyebrow.textContent = "Automatic Log Pruning"; section.append(eyebrow); const grid = document.createElement("div"); grid.className = "form-grid"; fields.forEach(field => grid.append(field)); section.append(grid); form.prepend(section); if (actions) form.append(actions); form.dataset.normalized = "true"; }
normalizeRetentionLayout(); normalizeRetentionLayout();
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -52,7 +52,7 @@ main{padding-top:24px}.utility-bar{display:flex;align-items:center;justify-conte
@media(max-width:600px){.icon-results{grid-template-columns:repeat(3,minmax(0,1fr))}.icon-picker .dialog-actions{flex-wrap:wrap}.icon-picker .dialog-actions button:first-child{width:100%}} @media(max-width:600px){.icon-results{grid-template-columns:repeat(3,minmax(0,1fr))}.icon-picker .dialog-actions{flex-wrap:wrap}.icon-picker .dialog-actions button:first-child{width:100%}}
/* Administration, documentation, redirects, and progressive disclosure */ /* Administration, documentation, redirects, and progressive disclosure */
.aside-utilities{margin-top:auto;padding:16px 0 14px;border-top:1px solid var(--line);display:grid;gap:4px}.aside-utilities button{width:100%;border:0;border-radius:10px;padding:11px 12px;text-align:left;background:transparent;color:var(--muted);cursor:pointer}.aside-utilities button:hover,.aside-utilities button.nav-active{background:var(--panel2);color:var(--text)}.aside-footer{margin-top:0}.admin-tabs{display:flex;gap:7px;margin-bottom:22px;padding:5px;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:auto}.admin-tabs button{width:auto;white-space:nowrap;padding:9px 12px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}.admin-tabs .tab-active{background:var(--panel2);color:var(--text)}.settings-panel{max-width:980px}.settings-panel-wide{max-width:1280px}.default-site-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,520px);gap:24px;align-items:start}.default-site-preview{margin-top:22px;padding:16px;border:1px solid var(--line);border-radius:14px;background:var(--panel);position:sticky;top:20px;height:calc(100vh - 40px);display:flex;flex-direction:column;min-height:360px}.default-site-preview .eyebrow{margin:0;flex:none}.default-site-preview small{display:block;margin-top:10px;color:var(--muted);flex:none}.default-site-preview-frame-wrap{margin-top:10px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#08101d;flex:1;min-height:220px}.default-site-preview-frame-wrap iframe{width:100%;height:100%;border:0;background:#08101d;display:block}@media(max-width:860px){.default-site-layout{grid-template-columns:1fr}.default-site-preview{position:static;height:auto}.default-site-preview-frame-wrap{aspect-ratio:4/3}}.settings-panel>h2{margin:0 0 6px}.settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error{grid-column:1/-1}.settings-form.compact-grid{grid-template-columns:repeat(4,1fr)}.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);font:inherit;resize:vertical;outline:none}textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.code-input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;min-height:150px}.callout{display:flex;gap:10px;margin:18px 0;padding:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);font-size:.78rem}.callout span{color:var(--muted)}.row-actions{display:flex!important;grid-auto-flow:column!important;justify-content:end;gap:7px}.row-actions .button{padding:8px 10px;font-size:.7rem;text-decoration:none}.backup-row{grid-template-columns:auto minmax(220px,1.5fr) 1fr .7fr auto}.padded{padding:22px}.redirect-card .site-icon{background:#163c35;color:var(--green)}.redirect-card .card-footer{gap:12px}.redirect-card .button{padding:7px 9px;font-size:.68rem}.chip{padding:5px 8px;border-radius:999px;background:var(--panel2);color:var(--muted);font-size:.68rem}details{margin-top:18px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28)}summary{padding:14px 16px;color:var(--text);font-weight:750;cursor:pointer}.details-body{padding:0 16px 16px;border-top:1px solid var(--line)}.subform{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px}.docs{display:grid;grid-template-columns:260px 1fr;gap:24px}.doc-search{position:sticky;top:20px;align-self:start;margin:0}.docs-content{display:grid;gap:14px}.docs article{padding:25px;border:1px solid var(--line);border-radius:15px;background:var(--panel)}.docs article h2{margin:0 0 10px}.docs article p,.docs article li{color:var(--muted);line-height:1.65}.docs article code{color:var(--green)}:root[data-theme="light"] textarea{background:#fff}:root[data-theme="light"] details{background:#f6f9fc} .aside-utilities{margin-top:auto;padding:16px 0 14px;border-top:1px solid var(--line);display:grid;gap:4px}.aside-utilities button{width:100%;border:0;border-radius:10px;padding:11px 12px;text-align:left;background:transparent;color:var(--muted);cursor:pointer}.aside-utilities button:hover,.aside-utilities button.nav-active{background:var(--panel2);color:var(--text)}.aside-footer{margin-top:0}.admin-tabs{display:flex;gap:7px;margin-bottom:22px;padding:5px;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:auto}.admin-tabs button{width:auto;white-space:nowrap;padding:9px 12px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}.admin-tabs .tab-active{background:var(--panel2);color:var(--text)}.settings-panel{max-width:980px}.settings-panel-wide{max-width:1280px}.default-site-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,520px);gap:24px;align-items:start}.default-site-preview{margin-top:22px;padding:16px;border:1px solid var(--line);border-radius:14px;background:var(--panel);position:sticky;top:20px;height:calc(100vh - 40px);display:flex;flex-direction:column;min-height:360px}.default-site-preview .eyebrow{margin:0;flex:none}.default-site-preview small{display:block;margin-top:10px;color:var(--muted);flex:none}.default-site-preview-frame-wrap{margin-top:10px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#08101d;flex:1;min-height:220px}.default-site-preview-frame-wrap iframe{width:100%;height:100%;border:0;background:#08101d;display:block}@media(max-width:860px){.default-site-layout{grid-template-columns:1fr}.default-site-preview{position:static;height:auto}.default-site-preview-frame-wrap{aspect-ratio:4/3}}.settings-panel>h2{margin:0 0 6px}.settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error{grid-column:1/-1}.settings-form.compact-grid{grid-template-columns:repeat(4,1fr)}.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);font:inherit;resize:vertical;outline:none}textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.code-input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;min-height:150px}.callout{display:flex;gap:10px;margin:18px 0;padding:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);font-size:.78rem}.callout span{color:var(--muted)}.row-actions{display:flex!important;grid-auto-flow:column!important;justify-content:end;gap:7px}.row-actions .button{padding:8px 10px;font-size:.7rem;text-decoration:none}.backup-row{grid-template-columns:auto minmax(220px,1.5fr) 1fr .7fr auto}.padded{padding:22px}.redirect-card .site-icon{background:#163c35;color:var(--green)}.redirect-card .card-footer{gap:12px}.redirect-card .button{padding:7px 9px;font-size:.68rem}.chip{padding:5px 8px;border-radius:999px;background:var(--panel2);color:var(--muted);font-size:.68rem}.chip.type-complete{background:rgba(98,230,167,.15);color:var(--green)}.chip.type-configuration{background:var(--panel2);color:var(--muted)}.chip.type-encrypted{background:rgba(94,158,255,.15);color:var(--blue)}.chip.type-unknown{background:rgba(255,113,133,.12);color:var(--danger)}.backup-summary{display:block;margin-top:6px}details{margin-top:18px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28)}summary{padding:14px 16px;color:var(--text);font-weight:750;cursor:pointer}.details-body{padding:0 16px 16px;border-top:1px solid var(--line)}.subform{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px}.docs{display:grid;grid-template-columns:260px 1fr;gap:24px}.doc-search{position:sticky;top:20px;align-self:start;margin:0}.docs-content{display:grid;gap:14px}.docs article{padding:25px;border:1px solid var(--line);border-radius:15px;background:var(--panel)}.docs article h2{margin:0 0 10px}.docs article p,.docs article li{color:var(--muted);line-height:1.65}.docs article code{color:var(--green)}:root[data-theme="light"] textarea{background:#fff}:root[data-theme="light"] details{background:#f6f9fc}
@media(max-width:900px){.settings-form.compact-grid{grid-template-columns:1fr 1fr}.settings-form .form-grid{grid-template-columns:1fr 1fr}.backup-row{grid-template-columns:auto 1fr}.backup-row>div{grid-column:2}.row-actions{justify-content:start}.docs{grid-template-columns:1fr}.doc-search{position:static}}@media(max-width:600px){.settings-form,.settings-form.compact-grid{grid-template-columns:1fr}.settings-form .form-grid{grid-template-columns:1fr}.settings-form>*{grid-column:1!important}.admin-tabs{margin-left:-5px;margin-right:-5px}} @media(max-width:900px){.settings-form.compact-grid{grid-template-columns:1fr 1fr}.settings-form .form-grid{grid-template-columns:1fr 1fr}.backup-row{grid-template-columns:auto 1fr}.backup-row>div{grid-column:2}.row-actions{justify-content:start}.docs{grid-template-columns:1fr}.doc-search{position:static}}@media(max-width:600px){.settings-form,.settings-form.compact-grid{grid-template-columns:1fr}.settings-form .form-grid{grid-template-columns:1fr}.settings-form>*{grid-column:1!important}.admin-tabs{margin-left:-5px;margin-right:-5px}}
#custom-certificate-fields,.custom-certificate-fields{display:none;margin-top:16px;padding:14px;border:1px solid var(--line);border-radius:12px}.custom-certificate-visible{display:block!important} #custom-certificate-fields,.custom-certificate-fields{display:none;margin-top:16px;padding:14px;border:1px solid var(--line);border-radius:12px}.custom-certificate-visible{display:block!important}
dialog{max-height:calc(100vh - 28px);overflow:auto} dialog{max-height:calc(100vh - 28px);overflow:auto}
+2 -2
View File
@@ -205,7 +205,7 @@ async function loadSites() {
groups = storage.loadCollection("groups"); groups = storage.loadCollection("groups");
const defaultSettings = { const defaultSettings = {
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: "" }, 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: "" },
backups: { enabled: false, frequency: "daily", hour: 2, retention: 7, type: "configuration", includeLogs: false, encrypt: false, lastRunAt: null, lastStatus: null }, backups: { enabled: false, frequency: "daily", hour: 2, retention: 7, type: "complete", includeLogs: false, encrypt: false, lastRunAt: null, lastStatus: null },
certificateHealth: { warningDays: 30, criticalDays: 7, staleMinutes: 10 }, certificateHealth: { warningDays: 30, criticalDays: 7, staleMinutes: 10 },
logsRetention: { accessDays: 30, activityDays: 90, auditDays: 365, certificateDays: 365, securityDays: 365, pruningEnabled: false } logsRetention: { accessDays: 30, activityDays: 90, auditDays: 365, certificateDays: 365, securityDays: 365, pruningEnabled: false }
}; };
@@ -1795,7 +1795,7 @@ app.patch("/api/settings", async (req, res, next) => {
app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } }); app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } });
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } }); app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
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.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:"configuration", 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/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); } }); 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); } });
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); 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.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });