"use strict"; (() => { let editing = null, dirty = false; const form = $("web-service-form"), dialog = $("web-service-dialog"); 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"); const mapLines = value => Object.entries(value || {}).map(([name, item]) => `${name} = ${item}`).join("\n"); const cacheLines = value => (value || []).map(rule => `${rule.matcher} = ${rule.cache_control}`).join("\n"); const rewriteLines = value => (value || []).map(rule => `${rule.pattern} => ${rule.status ? `${rule.status}:` : ""}${rule.replacement}`).join("\n"); const staticCacheRules = () => [ { matcher: "ext:html", cache_control: "no-cache" }, { matcher: "ext:htm", cache_control: "no-cache" }, { matcher: "ext:css", cache_control: "public, max-age=604800" }, { matcher: "ext:js", cache_control: "public, max-age=604800" }, { matcher: "ext:mjs", cache_control: "public, max-age=604800" }, { matcher: "mime:image/", cache_control: "public, max-age=2592000" }, { matcher: "mime:font/", cache_control: "public, max-age=2592000" }, ]; const phpFrontController = () => ["$uri", "$uri/", "/index.php?$query_string"]; const presetHints = { static: "webServicePresetHintStatic", spa: "webServicePresetHintSpa", php_generic: "webServicePresetHintPhpGeneric", wordpress: "webServicePresetHintWordpress", drupal: "webServicePresetHintDrupal", laravel: "webServicePresetHintLaravel", symfony: "webServicePresetHintSymfony", joomla: "webServicePresetHintJoomla", prestashop: "webServicePresetHintPrestashop", downloads: "webServicePresetHintDownloads" }; function parseMimeTypes(value) { const result = {}; for (const line of strictLines(value)) { const match = line.match(/^\.?([A-Za-z0-9_-]{1,32})\s*=\s*(.+)$/); if (!match) throw new Error(t("webServiceMimeInvalid")); const extension = match[1].toLowerCase(), mime = match[2].trim(); if (!mime || Object.hasOwn(result, extension)) throw new Error(t("webServiceMimeInvalid")); result[extension] = mime; } return result; } function parseKeyValueLines(value, errorKey, normalizeKey = key => key) { const result = {}; for (const line of strictLines(value)) { const split = line.indexOf("="); if (split <= 0) throw new Error(t(errorKey)); const key = normalizeKey(line.slice(0, split).trim()), item = line.slice(split + 1).trim(); if (!key || !item || Object.hasOwn(result, key)) throw new Error(t(errorKey)); result[key] = item; } return result; } const reservedFastCgiParams = new Set([ "REQUEST_METHOD", "SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_URI", "DOCUMENT_ROOT", "SERVER_PROTOCOL", "SERVER_NAME", "SERVER_PORT", "REMOTE_ADDR", "REMOTE_PORT", "HTTPS", "GATEWAY_INTERFACE", "SERVER_SOFTWARE", "REDIRECT_STATUS", "CONTENT_TYPE", "CONTENT_LENGTH", "PATH_INFO", "PATH_TRANSLATED", "PHP_VALUE", "PHP_ADMIN_VALUE" ]); function parseFastCgiParams(value) { const result = parseKeyValueLines(value, "webServicePhpCustomParamsInvalid", key => key.toUpperCase()); const entries = Object.entries(result); if (entries.length > 64) throw new Error(t("webServicePhpCustomParamsInvalid")); for (const [name, item] of entries) { if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name) || name.startsWith("HTTP_") || reservedFastCgiParams.has(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) { throw new Error(t("webServicePhpCustomParamsInvalid")); } } return result; } function parsePhpValues(value) { const result = parseKeyValueLines(value, "webServicePhpValuesInvalid", key => key.toLowerCase()); const entries = Object.entries(result); if (entries.length > 64) throw new Error(t("webServicePhpValuesInvalid")); for (const [name, item] of entries) { if (!/^[a-z0-9_.-]{1,64}$/.test(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) { throw new Error(t("webServicePhpValuesInvalid")); } } return result; } function validatePhpCustomData(customParams, phpValues, phpAdminValues = {}) { if (Object.keys(phpValues).some(name => Object.hasOwn(phpAdminValues, name))) throw new Error(t("webServicePhpValuesInvalid")); const size = [...Object.entries(customParams), ...Object.entries(phpValues), ...Object.entries(phpAdminValues)] .reduce((total, [name, value]) => total + name.length + value.length + 2, 0); if (size > 128 * 1024) throw new Error(t("webServicePhpCustomDataTooLarge")); } function parseCacheRules(value) { const result = []; for (const line of strictLines(value)) { const split = line.indexOf("="); if (split <= 0) throw new Error(t("webServiceCacheRulesInvalid")); const matcher = line.slice(0, split).trim().toLowerCase(), cache_control = line.slice(split + 1).trim(); if (!/^(?:ext:[A-Za-z0-9_-]{1,32}|mime:.+)$/.test(matcher) || !cache_control) throw new Error(t("webServiceCacheRulesInvalid")); result.push({ matcher, cache_control }); } return result; } function parseRewrites(value) { const result = []; for (const line of strictLines(value)) { const split = line.indexOf("=>"); if (split <= 0) throw new Error(t("webServiceRewritesInvalid")); const pattern = line.slice(0, split).trim(); let replacement = line.slice(split + 2).trim(), status = null; const redirect = replacement.match(/^(301|302|307|308):(\/.*)$/); if (redirect) { status = Number(redirect[1]); replacement = redirect[2]; } if (!pattern || !replacement.startsWith("/")) throw new Error(t("webServiceRewritesInvalid")); result.push({ pattern, replacement, status }); } return result; } const tabDefinitions = [ ["general", "webServiceTabGeneral"], ["routing", "webServiceTabRouting"], ["htaccess", "webServiceTabHtaccess"], ["mime", "webServiceTabMime"], ["cache", "webServiceTabCacheHeaders"], ["delivery", "webServiceTabDelivery"], ["php", "webServiceTabPhp"], ["security", "webServiceTabSecurity"], ]; function activateTab(name) { document.querySelectorAll("#web-service-tabs button").forEach(button => { const active = button.dataset.tab === name; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); button.setAttribute("aria-selected", String(active)); button.tabIndex = active ? 0 : -1; }); document.querySelectorAll("[data-web-service-page]").forEach(page => { page.hidden = page.dataset.webServicePage !== name; }); } function initTabs() { const tabs = $("web-service-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of tabDefinitions) { const button = el("button", t(key), "quiet"); button.type = "button"; button.dataset.tab = name; button.dataset.i18n = key; button.setAttribute("role", "tab"); button.addEventListener("click", () => activateTab(name)); tabs.append(button); } activateTab("general"); } const { renderProfiles, openProfiles, refreshProfileSelect } = window.rustProxyPhpProfiles({ defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData }); function syncPhp() { const attached = !!$("web-service-php-profile").value; const enabled = $("web-service-php-enabled").checked; $("web-service-php-enabled").disabled = attached; for (const id of [ "web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout", "web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization", "web-service-php-path-info", "web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit", "web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values" ]) $(id).disabled = !enabled || attached; } function syncCompression() { const disabled = $("web-service-compression").value === "off"; $("web-service-compression-options").hidden = disabled; for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).disabled = disabled; } function syncPrecompressed() { const enabled = $("web-service-precompressed").checked; $("web-service-precompressed-br").disabled = !enabled; $("web-service-precompressed-gzip").disabled = !enabled; } function syncHtaccess() { const enabled = $("web-service-htaccess-enabled").checked; for (const id of ["web-service-htaccess-filename", "web-service-htaccess-recursive", "web-service-htaccess-strict"]) $(id).disabled = !enabled; } const sourceId = source => source?.type === "web_server" ? source.web_service_id : null; function usage(id) { const uses = []; for (const vhost of envelope?.config.vhosts || []) { if (sourceId(vhost.source) === id) uses.push(vhost.id); for (const route of vhost.routes || []) if (!route.response && sourceId(route.source) === id) uses.push(`${vhost.id}/${route.id}`); } return uses; } function defaults() { return { id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false, mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {}, try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [], symlink_policy: "deny", php_profile_id: null, php: { enabled: false, path_info: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000, request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0, max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}, php_admin_values: {} }, htaccess: { enabled: false, filename: ".htaccess", scan_subdirectories: true, strict: false }, hardening: { block_common_attacks: false, strip_unnecessary_headers: false } }; } function presetConfig(name) { const item = defaults(); item.precompressed.enabled = true; item.cache_rules = staticCacheRules(); if (name === "static") return item; if (name === "spa") { item.index = ["index.html"]; item.try_files = ["$uri", "/index.html"]; return item; } if (name === "downloads") { item.index = []; item.directory_listing = true; item.cache_control = "public, max-age=3600"; item.cache_rules = []; item.precompressed.enabled = false; item.download_extensions = ["zip", "7z", "tar", "gz", "tgz", "bz2", "xz", "pdf", "iso"]; return item; } item.index = ["index.php", "index.html", "index.htm"]; item.php.enabled = true; item.php.extensions = ["php"]; if (name === "php_generic") { item.try_files = ["$uri", "$uri/", "=404"]; return item; } if (["wordpress", "drupal", "laravel", "symfony", "joomla", "prestashop"].includes(name)) { item.try_files = phpFrontController(); return item; } return null; } function populateSettings(item) { $("web-service-index").value = (item.index || []).join("\n"); $("web-service-listing").checked = item.directory_listing === true; $("web-service-try-files").value = (item.try_files || []).join("\n"); $("web-service-rewrites").value = rewriteLines(item.rewrites); $("web-service-mime-types").value = mimeLines(item.mime_types); $("web-service-default-mime").value = item.default_mime_type || "application/octet-stream"; $("web-service-cache-control").value = item.cache_control || ""; $("web-service-cache-rules").value = cacheLines(item.cache_rules); $("web-service-response-headers").value = mapLines(item.response_headers); $("web-service-compression").value = item.compression?.enabled === true ? "on" : item.compression?.enabled === false ? "off" : ""; for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : ""; $("web-service-precompressed").checked = item.precompressed?.enabled === true; $("web-service-precompressed-br").checked = item.precompressed?.brotli !== false; $("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false; $("web-service-download-extensions").value = (item.download_extensions || []).join("\n"); refreshProfileSelect(item.php_profile_id || ""); $("web-service-php-enabled").checked = item.php?.enabled === true; $("web-service-php-path-info").checked = item.php?.path_info === true; $("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock"; $("web-service-php-extensions").value = (item.php?.extensions || ["php"]).join(", "); $("web-service-php-connect-timeout").value = item.php?.connect_timeout_ms ?? 1000; $("web-service-php-request-timeout").value = item.php?.request_timeout_ms ?? 30000; $("web-service-php-concurrency").value = item.php?.max_concurrent_requests ?? 64; $("web-service-php-pass-authorization").checked = item.php?.pass_authorization !== false; $("web-service-php-body-limit").value = item.php?.max_request_body_bytes ?? 0; $("web-service-php-header-limit").value = item.php?.max_response_header_bytes ?? 65536; $("web-service-php-stderr-limit").value = item.php?.stderr_log_bytes ?? 4096; $("web-service-php-custom-params").value = mapLines(item.php?.custom_params); $("web-service-php-values").value = mapLines(item.php?.php_values); $("web-service-php-admin-values").value = mapLines(item.php?.php_admin_values); $("web-service-symlink-policy").value = item.symlink_policy || "deny"; $("web-service-htaccess-enabled").checked = item.htaccess?.enabled === true; $("web-service-htaccess-filename").value = item.htaccess?.filename || ".htaccess"; $("web-service-htaccess-recursive").checked = item.htaccess?.scan_subdirectories !== false; $("web-service-htaccess-strict").checked = item.htaccess?.strict === true; $("web-service-hardening-attacks").checked = item.hardening?.block_common_attacks === true; $("web-service-hardening-headers").checked = item.hardening?.strip_unnecessary_headers === true; syncPhp(); syncCompression(); syncPrecompressed(); syncHtaccess(); } function updatePresetSummary() { const key = presetHints[$("web-service-preset").value]; $("web-service-preset-summary").textContent = key ? t(key) : t("webServicePresetHelp"); } function applyPreset() { try { const preset = presetConfig($("web-service-preset").value); if (!preset) { toast(t("webServicePresetChoose"), true); return; } // Keep deployment-specific FastCGI endpoint/tuning while replacing application policy. preset.php.endpoint = $("web-service-php-endpoint").value.trim() || preset.php.endpoint; preset.php.connect_timeout_ms = Number($("web-service-php-connect-timeout").value || preset.php.connect_timeout_ms); preset.php.request_timeout_ms = Number($("web-service-php-request-timeout").value || preset.php.request_timeout_ms); preset.php.max_concurrent_requests = Number($("web-service-php-concurrency").value || preset.php.max_concurrent_requests); preset.php.pass_authorization = $("web-service-php-pass-authorization").checked; preset.php.path_info = $("web-service-php-path-info").checked; preset.php.max_request_body_bytes = Number($("web-service-php-body-limit").value || 0); preset.php.max_response_header_bytes = Number($("web-service-php-header-limit").value || preset.php.max_response_header_bytes); preset.php.stderr_log_bytes = Number($("web-service-php-stderr-limit").value || 0); preset.php.custom_params = parseFastCgiParams($("web-service-php-custom-params").value); preset.php.php_values = parsePhpValues($("web-service-php-values").value); preset.php.php_admin_values = parsePhpValues($("web-service-php-admin-values").value); validatePhpCustomData(preset.php.custom_params, preset.php.php_values, preset.php.php_admin_values); preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked; preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked; populateSettings(preset); updatePresetSummary(); dirty = true; toast(t("webServicePresetApplied")); } catch (error) { toast(error.message, true); } } function presetChanged() { updatePresetSummary(); // Presets are one-shot form generators. Selecting one must immediately // materialize its normal Web Service fields; otherwise a user can select a // CMS, save the form and unknowingly persist the previous/custom values. if ($("web-service-preset").value !== "custom") applyPreset(); } function open(value = null) { const item = Object.assign(defaults(), structuredClone(value || {})); item.compression = Object.assign(defaults().compression, item.compression || {}); item.precompressed = Object.assign(defaults().precompressed, item.precompressed || {}); item.php = Object.assign(defaults().php, item.php || {}); item.htaccess = Object.assign(defaults().htaccess, item.htaccess || {}); item.hardening = Object.assign(defaults().hardening, item.hardening || {}); editing = value?.id || null; $("web-service-id").value = item.id || ""; $("web-service-id").readOnly = !!editing; $("web-service-name").value = item.name || ""; $("web-service-root").value = item.document_root || ""; populateSettings(item); $("web-service-preset").value = "custom"; updatePresetSummary(); $("web-service-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel"); activateTab("general"); dirty = false; dialog.showModal(); } function clearEditor() { editing = null; dirty = false; form.reset(); 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 }; } 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; } function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; } function render() { 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)); const cards = services.map(service => { const box = el("article", undefined, "vhost-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"); const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", service.name || service.id, "entity-title"), el("span", t("webServer"), "pill live")); identity.append(title, el("div", service.document_root, "vhost-hosts")); const actions = el("div", undefined, "entity-actions"); const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(structuredClone(service)); }); const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", async () => { const used = usage(service.id); if (used.length) { toast(`${t("webServiceInUse")}: ${used.join(", ")}`, true); return; } if (!(await appConfirm(t("webServiceDelete"), { danger: true }))) return; const next = (envelope.config.web_services || []).filter(item => item.id !== service.id); if (await saveSection("web-services", next)) render(); }); actions.append(edit, remove); head.append(identity, actions); box.append(head); const details = el("div", undefined, "vhost-detail-strip"); const detailItems = [ ["webServiceId", service.id], ["webServiceIndex", (service.index || []).join(", ") || t("webServiceNoIndex")], ["webServiceDirectoryListing", t(service.directory_listing ? "enabled" : "notEnabled")], ["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"], ["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")], ["webServicePhpEnabled", service.php_profile_id ? `${service.php_profile_id} (${t("phpProfileShared")})` : t(service.php?.enabled ? "enabled" : "notEnabled")], ["webServiceCompression", service.compression?.enabled === true ? t("enabled") : service.compression?.enabled === false ? t("notEnabled") : t("inherit")], ["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")], ["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")], ["webServiceCacheControl", service.cache_control || t("inherit")], ["webServiceUsedBy", usage(service.id).length ? usage(service.id).join(", ") : t("webServiceUnused")], ]; for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(el("span", t(key), "small-label"), el("b", value)); details.append(item); } const live = el("details", undefined, "www-service-panel"); const summary = el("summary", t("webLiveMetrics")); live.append(summary); const panel = el("div", undefined, "www-service-live"); panel.dataset.webLiveId = service.id; const figures = el("div", undefined, "www-service-values"); for (const [field, key] of [ ["requests", "webLiveRequests"], ["rate", "webLiveRate"], ["errors", "webLiveErrors"], ["latency", "webLiveLatency"], ["php", "webLivePhpCapacity"], ["bytes", "webLiveBytes"]]) { const item = el("div"); const name = el("small", t(key)); name.dataset.i18n = key; const value = el("strong", "-"); value.dataset.webLiveField = field; item.append(name, value); figures.append(item); } const chart = document.createElementNS("http://www.w3.org/2000/svg", "svg"); chart.setAttribute("viewBox", "0 0 300 60"); chart.setAttribute("role", "img"); chart.setAttribute("aria-label", t("webLiveRate")); const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); line.setAttribute("points", "0,55 300,55"); chart.append(line); const split = el("p", "-", "www-service-split"); split.dataset.webLiveField = "split"; panel.append(figures, chart, split); live.append(panel); live.addEventListener("toggle", () => { if (live.open && latestStatus) renderLive(latestStatus, false); }); box.append(details, live); return box; }); $("web-service-list").replaceChildren(...(cards.length ? cards : [el("p", t(query ? "webServiceSearchEmpty" : "webServiceEmpty"), "fine")])); } const serviceHistory = new Map(); const serviceLast = new Map(); function renderLive(status, record = true) { if (currentPanel !== "web-server") return; const services = status?.web_server_metrics || {}; for (const panel of document.querySelectorAll("#web-service-list [data-web-live-id]")) { if (!panel.closest("details")?.open) continue; const id = panel.dataset.webLiveId, values = services[id], web = values?.web; const lookup = key => panel.querySelector(`[data-web-live-field="${key}"]`); const set = (key, value) => { const node = lookup(key); if (node) node.textContent = value; }; if (!web) { for (const key of ["requests", "rate", "errors", "latency", "php", "bytes", "split"]) set(key, "-"); continue; } const n = value => value == null || !Number.isFinite(Number(value)) ? 0 : Number(value); const total = n(web.requests), php = values.php, last = serviceLast.get(id); const sample = rustProxyRateSampling.difference(record ? last : null, [total, n(web.response_headers_us), n(web.status_5xx)], performance.now(), rustProxyRateSampling.cadence("overview_seconds")); if (record) serviceLast.set(id, sample.next); const deltas = record ? sample.deltas : null; const rate = deltas ? deltas[0] / sample.seconds : null; const previous = serviceHistory.get(id) || []; if (record) { previous.push(rate); window.rustProxyOverviewSamples.trim(previous); serviceHistory.set(id, previous); } const peak = Math.max(1, ...previous.filter(x => x != null)); const points = previous.map((value, i) => `${300 * i / Math.max(1, previous.length - 1)},${55 - (value || 0) / peak * 50}`).join(" "); panel.querySelector("polyline")?.setAttribute("points", points || "0,55 300,55"); set("requests", fmt(total)); set("rate", rate == null ? "-" : `${fmt(rate, 1)}/s`); set("errors", fmt(n(web.status_5xx))); set("latency", deltas?.[0] > 0 ? `${fmt(deltas[1] / deltas[0] / 1000, 1)} ms` : "-"); set("php", php ? `${fmt(n(php.capacity_used))} / ${fmt(n(php.capacity_limit))}` : t("webLiveNoPhp")); set("bytes", fmtBytes(n(web.response_bytes))); set("split", `${t("webLiveKinds")}: ${fmt(n(web.static_requests))} / ${fmt(n(web.php_requests))} / ${fmt(n(web.other_requests))}`); } } window.rustProxyOverviewSamples.onChange(() => { for (const history of serviceHistory.values()) window.rustProxyOverviewSamples.trim(history); if (latestStatus && currentPanel === "web-server") renderLive(latestStatus, false); }); form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest("[data-web-service-page]")?.dataset.webServicePage || "general"); invalid.reportValidity(); return; } try { const phpEnabled = $("web-service-php-enabled").checked; const item = { id: $("web-service-id").value.trim(), name: $("web-service-name").value.trim(), document_root: $("web-service-root").value.trim(), index: lines($("web-service-index").value), directory_listing: $("web-service-listing").checked, mime_types: parseMimeTypes($("web-service-mime-types").value), default_mime_type: $("web-service-default-mime").value.trim() || "application/octet-stream", cache_control: $("web-service-cache-control").value.trim() || null, cache_rules: parseCacheRules($("web-service-cache-rules").value), response_headers: parseKeyValueLines($("web-service-response-headers").value, "webServiceResponseHeadersInvalid", key => key.toLowerCase()), try_files: strictLines($("web-service-try-files").value), rewrites: parseRewrites($("web-service-rewrites").value), compression: { enabled: $("web-service-compression").value === "" ? null : $("web-service-compression").value === "on", zstd: $("web-service-compression-zstd").value === "" ? null : $("web-service-compression-zstd").value === "on", br: $("web-service-compression-br").value === "" ? null : $("web-service-compression-br").value === "on", gzip: $("web-service-compression-gzip").value === "" ? null : $("web-service-compression-gzip").value === "on", deflate: $("web-service-compression-deflate").value === "" ? null : $("web-service-compression-deflate").value === "on" }, precompressed: { enabled: $("web-service-precompressed").checked, brotli: $("web-service-precompressed-br").checked, gzip: $("web-service-precompressed-gzip").checked }, download_extensions: lines($("web-service-download-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()), symlink_policy: $("web-service-symlink-policy").value, htaccess: { enabled: $("web-service-htaccess-enabled").checked, filename: $("web-service-htaccess-filename").value.trim() || ".htaccess", scan_subdirectories: $("web-service-htaccess-recursive").checked, strict: $("web-service-htaccess-strict").checked }, hardening: { block_common_attacks: $("web-service-hardening-attacks").checked, strip_unnecessary_headers: $("web-service-hardening-headers").checked }, php_profile_id: $("web-service-php-profile").value || null, php: { enabled: phpEnabled, path_info: $("web-service-php-path-info").checked, extensions: lines($("web-service-php-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()), endpoint: $("web-service-php-endpoint").value.trim() || "unix:/run/php/php-fpm.sock", connect_timeout_ms: Number($("web-service-php-connect-timeout").value || 1000), request_timeout_ms: Number($("web-service-php-request-timeout").value || 30000), max_concurrent_requests: Number($("web-service-php-concurrency").value || 64), pass_authorization: $("web-service-php-pass-authorization").checked, max_request_body_bytes: Number($("web-service-php-body-limit").value || 0), max_response_header_bytes: Number($("web-service-php-header-limit").value || 65536), stderr_log_bytes: Number($("web-service-php-stderr-limit").value || 0), custom_params: parseFastCgiParams($("web-service-php-custom-params").value), php_values: parsePhpValues($("web-service-php-values").value), php_admin_values: parsePhpValues($("web-service-php-admin-values").value) } }; validatePhpCustomData(item.php.custom_params, item.php.php_values, item.php.php_admin_values); if ((phpEnabled || item.php_profile_id && (envelope.config.php_fpm_profiles || []).find(profile => profile.id === item.php_profile_id)?.php?.enabled) && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict")); const next = structuredClone(envelope.config.web_services || []); const duplicate = next.find(service => service.id === item.id && service.id !== editing); if (duplicate) { toast(t("webServiceDuplicate"), true); return; } const position = next.findIndex(service => service.id === editing); if (position >= 0) next[position] = item; else next.push(item); if (await saveSection("web-services", next)) { dirty = false; clearEditor(); render(); } } catch (error) { toast(error.message, true); } }); initTabs(); form.addEventListener("input", () => { dirty = true; }); form.addEventListener("change", () => { dirty = true; }); $("web-service-php-enabled").addEventListener("change", syncPhp); $("web-service-php-profile").addEventListener("change", syncPhp); $("web-service-php-profiles").addEventListener("click", openProfiles); $("web-service-php-profiles-open").addEventListener("click", openProfiles); $("php-fpm-profiles-close").addEventListener("click", () => $("php-fpm-profiles-dialog").close()); $("php-fpm-profile-add").addEventListener("click", () => renderProfiles(true)); $("php-fpm-profiles-help").addEventListener("click", () => $("php-help-dialog").showModal()); $("web-service-compression").addEventListener("change", syncCompression); $("web-service-precompressed").addEventListener("change", syncPrecompressed); $("web-service-htaccess-enabled").addEventListener("change", syncHtaccess); $("web-service-preset").addEventListener("change", presetChanged); $("web-service-preset-apply").addEventListener("click", applyPreset); $("web-service-htaccess-help").addEventListener("click", () => { const help = $("htaccess-help-dialog"); if (!help.open) help.showModal(); }); $("htaccess-help-close").addEventListener("click", () => $("htaccess-help-dialog").close()); $("web-service-php-help").addEventListener("click", () => { const help = $("php-help-dialog"); if (!help.open) help.showModal(); }); $("php-help-close").addEventListener("click", () => $("php-help-dialog").close()); $("web-service-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); open(); }); $("web-service-close").addEventListener("click", closeEditor); $("web-service-reset").addEventListener("click", async () => { if (!dirty || await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") })) clearEditor(); }); $("web-service-search").addEventListener("input", render); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); }); window.rustProxyDrafts.register("web-server", { labelKey: "webServer", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: clearEditor }); window.rustProxyWebServer = { render, renderLive, reset() { clearEditor(); serviceHistory.clear(); serviceLast.clear(); } }; if (envelope) render(); })();