This commit is contained in:
Mateusz Gruszczyński committed 2026-10-06 11:29:34 +02:00
1 parent cf25423dde
commit 52cc137c73
50 files changed
+410 -172

No files matched your search

+8
View File
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.26.4_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.4_IMPLEMENTATION.md"
)),
),
(
"docs/V0.26.3_IMPLEMENTATION.md",
"technical",
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.3",
"version": "0.26.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+16
View File
@@ -174,6 +174,22 @@ function uptime(value) {
}
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
const label = input?.closest("label"); if (!label) return () => {};
label.classList.add("vh-enable-control", "vh-wide");
let hint = label.querySelector(".vh-enable-hint");
if (!hint) { hint = el("small", undefined, "vh-enable-hint"); label.append(hint); }
hint.dataset.i18n = hintKey; hint.textContent = t(hintKey);
let state = label.querySelector(".vh-enable-state");
if (!state) { state = el("strong", undefined, "vh-enable-state"); label.append(state); }
const sync = () => {
const key = input.checked ? "vhEnabled" : "vhDisabled";
label.classList.toggle("is-disabled", !input.checked); label.classList.toggle("is-enabled", input.checked);
state.dataset.i18n = key; state.textContent = t(key);
};
input.addEventListener("change", sync); sync(); return sync;
}
window.rustProxyEnableControl = enhanceEnabledControl;
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
function renderStatus(s) {
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
+7 -6
View File
@@ -92,15 +92,16 @@ function renderDashboardWidgetSettings() {
const current = adminPreferences || ADMIN_APPEARANCE_DEFAULTS, order = normalizeDashboardWidgets(current.dashboard_widgets), hidden = new Set(normalizeHiddenDashboardWidgets(current.dashboard_hidden_widgets));
const rows = order.map((id, index) => {
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
const visible = document.createElement("input"); visible.type = "checkbox"; visible.checked = !hidden.has(id); visible.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
const label = el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label");
const visible = !hidden.has(id);
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
const actions = el("span", undefined, "dashboard-widget-setting-actions");
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
visible.addEventListener("change", () => {
const previous = [...hidden], next = new Set(hidden); if (visible.checked) next.delete(id); else next.add(id);
toggle.addEventListener("click", () => {
const previous = [...hidden], next = new Set(hidden); if (visible) next.add(id); else next.delete(id);
if (adminPreferences) adminPreferences.dashboard_hidden_widgets = [...next]; applyDashboardLayout(order, [...next]); renderDashboardWidgetSettings();
void saveAppearancePreference({ dashboard_hidden_widgets: [...next] }, visible, () => { if (adminPreferences) adminPreferences.dashboard_hidden_widgets = previous; applyDashboardLayout(order, previous); renderDashboardWidgetSettings(); });
void saveAppearancePreference({ dashboard_hidden_widgets: [...next] }, toggle, () => { if (adminPreferences) adminPreferences.dashboard_hidden_widgets = previous; applyDashboardLayout(order, previous); renderDashboardWidgetSettings(); });
});
const move = delta => {
const previous = [...order], next = [...order], at = next.indexOf(id), target = at + delta; if (at < 0 || target < 0 || target >= next.length) return;
@@ -108,7 +109,7 @@ function renderDashboardWidgetSettings() {
applyDashboardLayout(next, [...hidden]); renderDashboardWidgetSettings();
void saveAppearancePreference({ dashboard_widgets: next }, null, () => { if (adminPreferences) adminPreferences.dashboard_widgets = previous; applyDashboardLayout(previous, [...hidden]); renderDashboardWidgetSettings(); });
};
up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(visible, label, actions); return row;
up.addEventListener("click", () => move(-1)); down.addEventListener("click", () => move(1)); actions.append(up, down); row.append(toggle, actions); return row;
});
list.replaceChildren(...rows);
}
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.3";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.4";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+20 -3
View File
@@ -3,6 +3,7 @@
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
if (!form || !dialog || !list) return;
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [];
const syncEnabledControl = window.rustProxyEnableControl($("notfound-enabled"));
const ERROR_STATUSES = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
const BUILTIN_STATUSES = [401, 403, 404, 429, 503, 504];
const STATUS_NAMES = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" };
@@ -38,11 +39,26 @@
editing = item.id || null; revision = envelope?.revision ?? null; dirty = false;
$("notfound-editing").textContent = editing || t("notfoundNewLabel");
$("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || "";
$("notfound-enabled").checked = item.enabled !== false; $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
$("notfound-enabled").checked = item.enabled !== false; syncEnabledControl(); $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false;
const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed });
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
$("notfound-mode").value = item.page?.kind || "welcome"; populateStatuses(item.page?.status ?? 404); $("notfound-html").value = item.page?.html || ""; syncMode(); dialog.showModal();
}
function suspendDraft() {
const state = {
editing, revision, label: $("notfound-editing").textContent, controls: window.rustProxyDrafts.captureControls(form),
hosts: window.rustProxyDrafts.takeChildren($("notfound-hosts-editor")), listeners: window.rustProxyDrafts.takeChildren($("notfound-listeners")),
readBindings, readHosts
};
const title = value("notfound-id") || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
}
function restoreDraft(state) {
window.rustProxyDrafts.restoreChildren($("notfound-hosts-editor"), state.hosts); window.rustProxyDrafts.restoreChildren($("notfound-listeners"), state.listeners);
readBindings = state.readBindings; readHosts = state.readHosts; editing = state.editing; revision = state.revision; dirty = true; $("notfound-editing").textContent = state.label;
window.rustProxyDrafts.restoreControls(form, state.controls); syncEnabledControl(); syncMode(); if (!dialog.open) dialog.showModal();
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("notfound"); if (dialog.open) dialog.close(); return true; }
function collect() {
const id = value("notfound-id"), name = value("notfound-name") || id, hosts = readHosts(), listener_ids = readBindings(), kind = value("notfound-mode"), status = Number(value("notfound-status")), html = $("notfound-html").value;
if (!listener_ids.length) throw new Error(t("notfoundListenersRequired"));
@@ -57,14 +73,15 @@
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head);
const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card;
}));
if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine"));
}
form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode);
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
$("notfound-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("notfound", { labelKey: "notfound", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyNotFound = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); $("notfound-search").value = ""; } };
if (envelope) render();
})();
+3 -3
View File
@@ -1877,7 +1877,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.3"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.4"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2676,7 +2676,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.3</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.4</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3387,7 +3387,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.26.3</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.4</strong></div>
</div>
<dl class="about-meta">
<div>
+3 -2
View File
@@ -3,6 +3,7 @@
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
if (!form || !dialog || !list || !rules) return;
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [];
const syncEnabledControl = window.rustProxyEnableControl($("redirect-enabled"));
const yes = id => $(id).checked;
const value = id => $(id).value.trim();
const option = (value, key) => { const node = el("option", t(key)); node.value = value; return node; };
@@ -65,7 +66,7 @@
$("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing);
$("redirect-name").value = item.name || "";
const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed });
$("redirect-enabled").checked = item.enabled !== false; $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
$("redirect-enabled").checked = item.enabled !== false; syncEnabledControl(); $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false;
listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id);
rules.replaceChildren(); for (const [i, rule] of (item.rules?.length ? item.rules : defaults().rules).entries()) addRule(rule, i);
dialog.showModal();
@@ -85,7 +86,7 @@
function restoreDraft(state) {
window.rustProxyDrafts.restoreChildren($("redirect-hosts-editor"), state.hosts); window.rustProxyDrafts.restoreChildren($("redirect-listeners"), state.listeners); window.rustProxyDrafts.restoreChildren(rules, state.rules);
readRedirectBindings = state.readRedirectBindings; readRedirectHosts = state.readRedirectHosts; editing = state.editing; revision = state.revision; dirty = true; $("redirect-editing").textContent = state.label;
window.rustProxyDrafts.restoreControls(form, state.controls); if (!dialog.open) dialog.showModal();
window.rustProxyDrafts.restoreControls(form, state.controls); syncEnabledControl(); if (!dialog.open) dialog.showModal();
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("redirect"); if (dialog.open) dialog.close(); return true; }
+2 -1
View File
@@ -56,7 +56,8 @@
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl);
note(g, "streamsHelp");
g = group("streamNetwork", "network");
+65 -16
View File
@@ -8788,43 +8788,75 @@ button.config-history-day.is-selected {
.dashboard-widget-list {
display: grid;
gap: 0;
margin-top: 4px;
border: 1px solid var(--line)
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px;
margin-top: 4px
}
.dashboard-widget-setting {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
gap: 4px;
min-width: 0;
min-height: 34px;
padding: 4px 6px;
border-bottom: 1px solid var(--line);
padding: 3px;
border: 1px solid var(--line);
background: var(--panel)
}
.dashboard-widget-setting:last-child {
border-bottom: 0;
.dashboard-widget-toggle {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
min-height: 26px;
padding: 3px 6px;
border: 0;
background: transparent;
color: var(--text);
text-align: left
}
.dashboard-widget-toggle:hover {
background: var(--panel2)
}
.dashboard-widget-toggle.is-hidden {
color: var(--muted)
}
.dashboard-widget-visibility {
flex: 0 0 8px;
width: 8px;
height: 8px;
border: 1px solid var(--green);
border-radius: 50%;
background: var(--green)
}
.dashboard-widget-toggle.is-hidden .dashboard-widget-visibility {
border-color: var(--muted);
background: transparent
}
.dashboard-widget-setting-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-size: 11px
}
.dashboard-widget-setting-actions {
display: inline-flex;
gap: 3px;
gap: 2px
}
.dashboard-widget-setting-actions button {
min-width: 28px;
min-height: 26px;
padding: 2px 6px;
border-radius: 0;
min-width: 24px;
min-height: 24px;
padding: 1px 5px;
border-radius: 0
}
.appearance-actions {
@@ -9320,4 +9352,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
stroke-width: var(--chart-line-width);
stroke-linecap: var(--chart-linecap);
stroke-linejoin: var(--chart-linejoin);
}
}
/* Toolbar filters stay aligned with their tallest action button. */
.backend-table-tools input,
.backend-table-tools select,
.vhost-list-tools input[type="search"],
.docker-toolbar input[type="search"],
.log-tools input:not([type="checkbox"]):not([type="radio"]),
.log-tools select,
.file-logs-filter input,
.file-logs-filter select,
#panel-settings .settings-filter input {
box-sizing: border-box;
height: var(--button-min-height);
min-height: var(--button-min-height);
padding-top: 2px;
padding-bottom: 2px
}
+2 -4
View File
@@ -39,10 +39,8 @@
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []);
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox"), enabledLabel = enabledControl.closest("label"), enabledState = el("strong", "", "vh-enable-state");
if (enabledLabel) { enabledLabel.classList.add("vh-enable-control", "vh-wide"); enabledLabel.append(text("small", "vhEnableHint", "vh-enable-hint"), enabledState); }
const syncEnabledState = () => { const key = enabledControl.checked ? "vhEnabled" : "vhDisabled"; enabledLabel?.classList.toggle("is-disabled", !enabledControl.checked); enabledLabel?.classList.toggle("is-enabled", enabledControl.checked); enabledState.dataset.i18n = key; enabledState.textContent = t(key); };
enabledControl.addEventListener("change", syncEnabledState); syncEnabledState();
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();