Files
RustProxy/src/admin/web/web-server.js
T
2026-10-15 09:37:47 +02:00

556 lines
34 KiB
JavaScript

"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();
})();