This commit is contained in:
Mateusz Gruszczyński committed 2026-10-23 13:07:11 +02:00
1 parent 66ff9dfaa2
commit ee54aa6875
30 files changed
+939 -68

No files matched your search

@@ -0,0 +1,94 @@
"use strict";
(() => {
const form = $("certificate-form"), file = $("certificate-file"), source = $("certificate-file-source");
const names = $("certificate-hosts"), button = $("certificate-inspect"), status = $("certificate-inspect-status");
let timer = null, sequence = 0, namesRevision = 0, resetGeneration = 0, applying = false;
let manuallyEdited = !!names.value.trim(), pending = null, lastSuccess = "";
const identity = () => JSON.stringify([source.value, file.value.trim(), authEpoch]);
function validFile() {
const value = file.value.trim();
return source.value === "external"
? value.startsWith("/") && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value)
: value.length <= 96 && value !== "." && value !== ".." && /^[A-Za-z0-9_.-]+$/.test(value);
}
function message(key = "", detail = "") {
if (key && !detail) status.dataset.i18n = key;
else delete status.dataset.i18n;
status.textContent = key ? `${t(key)}${detail ? ` ${detail}` : ""}` : "";
status.classList.toggle("status-error", !!detail);
}
function refresh() { button.disabled = !validFile() || !!pending; }
function invalidate() {
clearTimeout(timer); timer = null; sequence++;
pending?.controller.abort(); pending = null; lastSuccess = "";
refresh();
}
function writeNames(value) {
names.value = value; applying = true;
try { names.dispatchEvent(new Event("input", { bubbles: true })); }
finally { applying = false; }
}
function reset({ preserveNames = false } = {}) {
invalidate(); manuallyEdited = preserveNames && !!names.value.trim(); namesRevision++; resetGeneration++;
message(); refresh();
}
async function read(force = false) {
clearTimeout(timer); timer = null;
if (!authenticated || !validFile()) { refresh(); return; }
const id = identity();
if (!force && pending?.id === id) return pending.promise;
if (!force && lastSuccess === id) return;
invalidate();
const current = sequence, epoch = authEpoch, revision = namesRevision, previousNames = names.value;
const certFile = file.value.trim(), controller = new AbortController();
message("certificateInspectReading");
const active = { id, controller, promise: null };
pending = active; refresh();
active.promise = (async () => {
try {
const result = await api("/api/certificates/inspect", {
method: "POST", body: JSON.stringify({ cert_file: certFile }), signal: controller.signal,
});
if (current !== sequence || epoch !== authEpoch || !authenticated || identity() !== id) return;
const hosts = Array.isArray(result.covered_hosts) ? result.covered_hosts.filter(host => typeof host === "string") : [];
lastSuccess = id;
const update = revision === namesRevision && names.value === previousNames && (force || !manuallyEdited);
if (update) { writeNames(hosts.join(", ")); manuallyEdited = false; }
message(!hosts.length ? "certificateInspectNoNames" : update ? "certificateInspectReady" : "certificateInspectKept");
} catch (error) {
if (current === sequence && epoch === authEpoch && authenticated && identity() === id && error.name !== "AbortError") {
message("certificateInspectFailed", error.message);
}
} finally {
if (pending === active) { pending = null; refresh(); }
}
})();
return active.promise;
}
function schedule() {
invalidate();
if (!manuallyEdited && names.value) writeNames("");
message();
if (validFile()) timer = setTimeout(() => { void read(); }, 400);
}
async function beforeSave() {
const generation = resetGeneration;
let id;
do { id = identity(); await read(); }
while (authenticated && generation === resetGeneration && id !== identity());
return generation === resetGeneration;
}
names.addEventListener("input", () => {
if (applying) return;
manuallyEdited = true; namesRevision++;
});
file.addEventListener("input", schedule);
file.addEventListener("change", () => { void read(); });
file.addEventListener("blur", () => { void read(); });
source.addEventListener("change", schedule);
form.addEventListener("reset", () => reset());
button.addEventListener("click", () => { void read(true); });
window.rustProxyCertificateInspection = { reset, schedule, read, beforeSave, refresh };
refresh();
})();
+164
View File
@@ -0,0 +1,164 @@
"use strict";
(() => {
// These are reviewable shell instructions only. The panel never executes them.
const views = new WeakMap();
const quote = value => `'${String(value).replaceAll("'", "'\"'\"'")}'`;
function absolutePath(value, directory) {
const raw = String(value || "");
if (/[\x00-\x1f\x7f]/.test(raw)) return null;
const path = raw.trim();
if (!path.startsWith("/") || path.length > 4096) return null;
if (path.split("/").some(part => part === "." || part === "..")) return null;
if (!directory && path.endsWith("/")) return null;
const normalized = path.replace(/\/{2,}/g, "/").replace(/\/$/, "");
return normalized && normalized !== "/" ? normalized : null;
}
function commands({ kind, paths = [], user = "rustproxy" } = {}) {
if (!["certificate", "web"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_.-]*\$?$/.test(user) || /[\x00-\x1f\x7f]/.test(user) || user.length > 64) return "";
const selected = paths.map(path => absolutePath(path, kind === "web"));
if (selected.length !== (kind === "web" ? 1 : 2) || selected.some(path => !path)) return "";
const lines = ["sudo sh <<'RUSTPROXY_ACL'", "set -eu", `rp_user=${quote(user)}`];
if (kind === "web") lines.push(`rp_root_path=${quote(selected[0])}`);
else lines.push(`rp_cert_path=${quote(selected[0])}`, `rp_key_path=${quote(selected[1])}`);
lines.push(
"command -v setfacl >/dev/null || { printf '%s\\n' 'Install the acl package (setfacl) first.' >&2; exit 1; }",
'id "$rp_user" >/dev/null',
"",
"rp_traverse() {",
' [ "$1" = / ] && return 0',
' rp_traverse "$(dirname -- "$1")" || return 1',
' rp_real_dir=$(readlink -e -- "$1") || return 1',
' if [ "$rp_real_dir" != "$1" ]; then rp_traverse "$rp_real_dir" || return 1; fi',
' if ! sudo -u "$rp_user" -- test -x "$1"; then',
' rp_access=1',
' if sudo -u "$rp_user" -- test -r "$1"; then rp_access=$((rp_access | 4)); fi',
' if sudo -u "$rp_user" -- test -w "$1"; then rp_access=$((rp_access | 2)); fi',
' rp_mode=$(stat -Lc %a -- "$1") || return 1',
' rp_mask=$(((0$rp_mode / 8 % 8) | rp_access))',
' setfacl -n -m "u:$rp_user:$rp_access,m::$rp_mask" -- "$1" || return 1',
" fi",
"}",
""
);
if (kind === "web") {
const grantWebTree = [
"set -eu",
"rp_user=$1; shift",
'for rp_path do',
' [ ! -L "$rp_path" ] || continue',
' rp_required=4',
' if [ -d "$rp_path" ]; then rp_required=5; elif [ ! -f "$rp_path" ]; then continue; fi',
' rp_mode=$(stat -Lc %a -- "$rp_path")',
' rp_old_mask=$((0$rp_mode / 8 % 8))',
' rp_mask=$((rp_old_mask | rp_required))',
' rp_entries="u:$rp_user:$rp_required,m::$rp_mask"',
' if [ "$rp_required" = 5 ]; then',
' rp_acl=$(getfacl -cp -- "$rp_path")',
' rp_default=$(printf "%s\\n" "$rp_acl" | awk -F: \'$1 == "default" && $2 == "mask" && $3 == "" {mask=substr($4,1,3)} $1 == "default" && $2 == "group" && $3 == "" {group=substr($4,1,3)} END {print (mask != "" ? mask : group)}\')',
' rp_default_mask=$rp_old_mask',
' if [ -n "$rp_default" ]; then',
' rp_default_mask=0',
' case "$rp_default" in r??) rp_default_mask=$((rp_default_mask | 4));; esac',
' case "$rp_default" in ?w?) rp_default_mask=$((rp_default_mask | 2));; esac',
' case "$rp_default" in ??x) rp_default_mask=$((rp_default_mask | 1));; esac',
' fi',
' rp_entries="$rp_entries,d:u:$rp_user:5,d:m::$((rp_default_mask | 5))"',
" fi",
' setfacl -n -m "$rp_entries" -- "$rp_path"',
"done"
].join("\n");
lines.push(
"command -v getfacl >/dev/null || { printf '%s\\n' 'Install the acl package (getfacl) first.' >&2; exit 1; }",
'rp_root=$(readlink -e -- "$rp_root_path")',
'[ "$rp_root" != / ] && [ -d "$rp_root" ] || { printf \'%s\\n\' \'Choose an existing website directory, not /.\' >&2; exit 1; }',
'rp_traverse "$(dirname -- "$rp_root_path")"',
'rp_traverse "$(dirname -- "$rp_root")"',
`find -P "$rp_root" \\( -type d -o -type f \\) -exec sh -c ${quote(grantWebTree)} rustproxy-web-acl "$rp_user" {} +`,
'sudo -u "$rp_user" -- test -r "$rp_root"',
'sudo -u "$rp_user" -- test -x "$rp_root"'
);
} else {
lines.push(
'rp_cert=$(readlink -e -- "$rp_cert_path")',
'rp_key=$(readlink -e -- "$rp_key_path")',
'[ -f "$rp_cert" ] && [ -f "$rp_key" ] || { printf \'%s\\n\' \'Certificate and key must be existing regular files.\' >&2; exit 1; }',
'chmod go-w -- "$rp_cert" "$rp_key"',
'chmod o-r -- "$rp_key"',
'rp_traverse "$(dirname -- "$rp_cert_path")"',
'rp_traverse "$(dirname -- "$rp_key_path")"',
'rp_traverse "$(dirname -- "$rp_cert")"',
'rp_traverse "$(dirname -- "$rp_key")"',
'setfacl -n -m "u:$rp_user:r--,m::r--" -- "$rp_cert" "$rp_key"',
'sudo -u "$rp_user" -- test -r "$rp_cert_path"',
'sudo -u "$rp_user" -- test -r "$rp_key_path"'
);
}
lines.push("RUSTPROXY_ACL");
return lines.join("\n");
}
function update(view) {
const { target, options, summary, help, accountLabel, account, output, empty, copy } = view;
target.hidden = options.enabled === false;
summary.textContent = t("pathPermissionsOptions");
help.textContent = t(options.kind === "web" ? "pathPermissionsWebHelp" : "pathPermissionsCertificateHelp");
accountLabel.textContent = t("pathPermissionsUser");
copy.textContent = t("pathPermissionsCopy");
output.setAttribute("aria-label", t("pathPermissionsTitle"));
output.textContent = target.hidden ? "" : commands({ ...options, user: account.value });
output.hidden = !output.textContent;
copy.disabled = !output.textContent;
empty.hidden = !!output.textContent;
empty.textContent = t(options.kind === "web" ? "pathPermissionsWebPrompt" : "pathPermissionsCertificatePrompt");
}
function render(target, options) {
if (!target) return;
let view = views.get(target);
if (!view) {
target.classList.add("path-permissions");
const actions = el("div", undefined, "form-actions path-permissions-actions");
const copy = el("button", undefined, "quiet");
copy.type = "button";
const details = el("details", undefined, "path-permissions-options");
const summary = el("summary", undefined, "fine");
const help = el("p", undefined, "fine");
const label = el("label", undefined, "path-permissions-account"), accountLabel = el("span"), account = el("input");
account.type = "text"; account.defaultValue = "rustproxy"; account.value = "rustproxy"; account.maxLength = 64;
account.autocomplete = "off"; account.spellcheck = false;
label.append(accountLabel, account);
const output = el("pre", undefined, "metrics-example path-permissions-commands");
output.tabIndex = 0;
const empty = el("p", undefined, "fine");
details.append(summary, label, help);
actions.append(copy, details);
target.replaceChildren(output, empty, actions);
view = { target, options, summary, help, accountLabel, account, output, empty, copy };
views.set(target, view);
account.addEventListener("input", () => update(view));
const selectOutput = () => {
output.focus();
const selection = window.getSelection?.();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(output);
selection.removeAllRanges(); selection.addRange(range);
};
copy.addEventListener("click", async () => {
try {
if (!output.textContent) return;
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(output.textContent);
else {
selectOutput();
if (!document.execCommand("copy")) throw new Error("clipboard unavailable");
}
toast(t("pathPermissionsCopied"));
} catch {
selectOutput();
toast(t("pathPermissionsCopyFailed"), true);
}
});
}
view.options = options;
update(view);
}
window.rustProxyPermissions = { commands, render };
})();
+54 -5
View File
@@ -7,6 +7,8 @@ function verifyAdminModules() {
["rustProxyTrafficPanel", "refresh"],
["rustProxyPreferences", "load"],
["rustProxyRefresh", "start"],
["rustProxyPermissions", "render"],
["rustProxyCertificateInspection", "read"],
];
const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function");
if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`);
@@ -34,6 +36,12 @@ else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true
function certificateUsesExternalFiles(cert) {
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
}
function syncCertificatePermissions() {
window.rustProxyPermissions?.render($("certificate-external-acl-example"), {
kind: "certificate", paths: [$("certificate-file").value, $("certificate-key").value],
enabled: $("certificate-file-source").value === "external",
});
}
function syncCertificateFileSource() {
const external = $("certificate-file-source").value === "external";
const cert = $("certificate-file"), key = $("certificate-key");
@@ -47,9 +55,14 @@ function syncCertificateFileSource() {
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey);
$("certificate-external-acl-example").hidden = !external;
syncCertificatePermissions();
window.rustProxyCertificateInspection?.refresh();
}
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
for (const id of ["certificate-file", "certificate-key"]) {
$(id).addEventListener("input", syncCertificatePermissions);
$(id).addEventListener("change", syncCertificatePermissions);
}
function renderCertificates() {
if (!envelope) return;
const settings = envelope.config.tls || {};
@@ -148,7 +161,15 @@ function renderCertificates() {
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); } finally { busy = false; }
}));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed"; syncCertificateFileSource(); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
buttons.append(action(t("edit"), () => {
window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true;
$("certificate-id").value = cert.id; $("certificate-hosts").value = (cert.hosts || []).join(", ");
$("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed";
$("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file;
$("certificate-default").checked = settings.default_certificate === cert.id;
window.rustProxyCertificateInspection?.reset({ preserveNames: true });
syncCertificateFileSource(); window.rustProxyCertificateInspection?.schedule();
}));
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
@@ -157,8 +178,38 @@ function renderCertificates() {
});
$("certificate-list").replaceChildren(...entries);
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
syncCertificatePermissions();
}
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("certificate-form").addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget;
if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const epoch = authEpoch;
if (await window.rustProxyCertificateInspection?.beforeSave() === false) return;
if (!authenticated || epoch !== authEpoch || !envelope || busy) return;
const next = structuredClone(envelope.config);
next.tls ||= {}; next.tls.certificates ||= [];
const cert = {
id: $("certificate-id").value.trim(),
hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean),
cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim(),
};
const index = next.tls.certificates.findIndex(v => v.id === cert.id);
if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly"));
if (index < 0) next.tls.certificates.push(cert);
else {
const old = next.tls.certificates[index], sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file;
next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null };
}
if ($("certificate-default").checked) next.tls.default_certificate = cert.id;
else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate;
if (await saveSection("tls", next.tls)) {
form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close();
}
});
} catch (error) { toast(error.message, true); }
});
$("reload-certificates").addEventListener("click", async () => {
if (!envelope || busy) return;
try {
@@ -331,8 +382,6 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
}
}
// Cumulative process counters, not rate-sampler deltas. Never invent OTHER
// or force a 100% mix if independently read totals are inconsistent.
function cacheOutcomeMix(totals) {
if (!Array.isArray(totals) || totals.length !== 4 ||
totals.some(value => !Number.isSafeInteger(value) || value < 0)) return null;
+27
View File
@@ -1478,3 +1478,30 @@ html[data-table-density=ultra] :is(table th, table td) {
min-height: var(--button-min-height);
align-items: center;
}
/* Keep generated ACL instructions in the original metrics-example code style. */
.path-permissions {
min-width: 0;
}
.path-permissions-commands {
max-height: 180px;
}
.path-permissions-actions {
margin-top: 0;
}
.path-permissions-options>summary {
cursor: pointer;
}
.path-permissions-options[open] {
flex-basis: 100%;
}
.path-permissions-account {
display: grid;
gap: 6px;
max-width: 260px;
}
+3 -2
View File
@@ -553,7 +553,8 @@ p,
.cache-operation-dialog .card,
.file-log-search-help-popover p,
.file-logs-table-toolbar .fine,
.cache-maintenance-help .subheading {
.cache-maintenance-help .subheading,
.path-permissions p {
margin: 0;
}
@@ -1941,4 +1942,4 @@ html.app-loading,
.web-service-preset-toggles>.check {
margin: 0 !important;
min-width: 0;
}
}
+1 -1
View File
@@ -1183,4 +1183,4 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
.refresh-settings-grid select {
width: 100%;
min-width: 0;
}
}
+6 -3
View File
@@ -1093,14 +1093,16 @@
</select>
<p class="fine" id="certificate-file-source-help"
data-i18n="certificateSourceManagedHelp"></p>
<pre class="metrics-example" data-i18n="certificateExternalAclExample"
hidden id="certificate-external-acl-example"></pre>
<label data-i18n="certificateFile" for="certificate-file"></label><input
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.pem" required="" />
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.key" required="" />
<div hidden id="certificate-external-acl-example"></div>
<div class="form-actions"><button type="button" class="quiet"
id="certificate-inspect" data-i18n="certificateReadNames"></button></div>
<p class="fine" id="certificate-inspect-status" role="status" aria-live="polite"></p>
<label class="check"><input id="certificate-default" type="checkbox" /><span
data-i18n="defaultCertificate"></span></label>
<div class="form-actions"><button class="primary"
@@ -3609,6 +3611,7 @@
required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<div class="vh-wide" hidden id="web-service-permissions"></div>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea
id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small
class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be
@@ -4294,4 +4297,4 @@ log_errors = On</code></pre>
</dialog>
</body>
</html>
</html>
+10 -3
View File
@@ -2,6 +2,9 @@
(() => {
let editing = null, dirty = false;
const form = $("web-service-form"), dialog = $("web-service-dialog");
function syncRootPermissions() {
window.rustProxyPermissions?.render($("web-service-permissions"), { kind: "web", paths: [$("web-service-root").value] });
}
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
const mimeLines = value => Object.entries(value || {}).map(([extension, mime]) => `${extension} = ${mime}`).join("\n");
@@ -276,6 +279,7 @@
syncCompression();
syncPrecompressed();
syncHtaccess();
syncRootPermissions();
}
function updatePresetSummary() {
@@ -335,22 +339,23 @@
dialog.showModal();
}
function clearEditor() { editing = null; dirty = false; form.reset(); if (dialog.open) dialog.close(); }
function clearEditor() { editing = null; dirty = false; form.reset(); syncRootPermissions(); if (dialog.open) dialog.close(); }
function suspendDraft() {
const activeTab = $("web-service-tabs").querySelector("button.active")?.dataset.tab || "general";
const state = { editing, activeTab, label: $("web-service-editing").textContent, controls: window.rustProxyDrafts.captureControls(form) };
const title = $("web-service-id").value.trim() || $("web-service-name").value.trim() || editing || t("draftUntitled");
editing = null; dirty = false; form.reset(); dialog.close(); return { title, state };
editing = null; dirty = false; form.reset(); syncRootPermissions(); dialog.close(); return { title, state };
}
function restoreDraft(state) {
const saved = state.editing ? (envelope?.config.web_services || []).find(item => item.id === state.editing) : null;
open(saved ? structuredClone(saved) : (state.editing ? { id: state.editing } : null));
window.rustProxyDrafts.restoreControls(form, state.controls); editing = state.editing; $("web-service-editing").textContent = state.label;
syncPhp(); syncPrecompressed(); syncHtaccess(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
syncPhp(); syncPrecompressed(); syncHtaccess(); syncRootPermissions(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; }
function render() {
syncRootPermissions();
if (!envelope) return;
const query = ($("web-service-search")?.value || "").trim().toLowerCase();
const services = (envelope.config.web_services || []).filter(service => !query || [service.id, service.name, service.document_root].join(" ").toLowerCase().includes(query));
@@ -530,6 +535,8 @@
initTabs();
form.addEventListener("input", () => { dirty = true; });
form.addEventListener("change", () => { dirty = true; });
$("web-service-root").addEventListener("input", syncRootPermissions);
$("web-service-root").addEventListener("change", syncRootPermissions);
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-php-profile").addEventListener("change", syncPhp);
$("web-service-php-profiles").addEventListener("click", openProfiles);