diff --git a/package.json b/package.json index 52c7c35..42ef124 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.73", + "version": "0.11.74", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index 37cc9a9..e882174 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -93,11 +93,21 @@ document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || !state.editing) return; event.preventDefault(); event.stopImmediatePropagation(); const form = new FormData(event.target), button = resolveSubmitter(event); + const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target }); - else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; } + else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; } + const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; + if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { + const existing = state.proxies.find(item => item.id === state.editing.id); + if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; return; } + } button.disabled = true; - try { await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } + try { + await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); } + $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); + } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true); @@ -443,10 +453,9 @@ function openSettings(kind, id) { const scope = "#settings-hosted-advanced"; setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthRetries", item.healthRetries || 0); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); } - $("#settings-error").textContent = ""; if (kind === "proxy" && form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal(); + $("#settings-error").textContent = ""; if (form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal(); document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom"); } -$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } }); $("#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; @@ -573,4 +582,3 @@ document.addEventListener("click", event => { if (event.target.closest(".create- 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); }); setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500); function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `
Operations
Members of enabled groups can sign in with their Site Gateway credentials.
" + (state.groups.length ? "" : "No groups have been created yet.
"); } document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true); document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });