"use strict"; (() => { const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules"); if (!form || !dialog || !list || !rules) return; const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"]; let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false }; function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; } function syncSortDirection() { const control = $("redirect-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); } function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); render(); } async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ redirect_list_sort: listPrefs.sort, redirect_list_descending: listPrefs.descending })); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } } 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; }; const defaults = () => ({ id: "", name: "", enabled: true, http2_enabled: true, http3_enabled: true, frontend: { hosts: [], listener_ids: [] }, certificate_id: null, rules: [{ id: "root", enabled: true, source_scheme: "any", match_type: "prefix", path_prefix: "/", target_type: "url", target: "https://example.com/", target_scheme: "preserve", path_mode: "append", query_mode: "preserve", status_code: 308, cache_seconds: 0 }] }); function addRule(rule = {}, index = rules.children.length) { const data = { ...defaults().rules[0], ...rule }; const box = el("fieldset", undefined, "redirect-rule"); box.dataset.redirectRule = "1"; const legend = el("legend", `${t("redirectRule")} ${index + 1}`); box.append(legend); const grid = el("div", undefined, "vh-grid"); const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; }; const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; }; const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; }; const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id; const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false; const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme; const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type; const prefix = input("redirectSourcePath", "path_prefix"); prefix.required = true; prefix.maxLength = 512; prefix.value = data.path_prefix; const targetType = select("redirectTargetType", "target_type", [["url", "redirectTargetUrl"], ["host", "redirectTargetHost"], ["path", "redirectTargetPath"]]); targetType.value = data.target_type; const target = input("redirectTarget", "target"); target.required = true; target.maxLength = 2048; target.value = data.target; const targetScheme = select("redirectTargetScheme", "target_scheme", [["preserve", "redirectSchemePreserve"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); targetScheme.value = data.target_scheme; const pathMode = select("redirectPathMode", "path_mode", [["append", "redirectPathAppend"], ["strip_prefix", "redirectPathStrip"], ["replace", "redirectPathReplace"]]); pathMode.value = data.path_mode; const queryMode = select("redirectQueryMode", "query_mode", [["preserve", "redirectQueryPreserve"], ["drop", "redirectQueryDrop"]]); queryMode.value = data.query_mode; const status = select("redirectStatus", "status_code", [["301", "redirect301"], ["302", "redirect302"], ["303", "redirect303"], ["307", "redirect307"], ["308", "redirect308"]]); status.value = String(data.status_code); const cache = input("redirectCacheSeconds", "cache_seconds", "number"); cache.min = "0"; cache.max = "31536000"; cache.step = "1"; cache.value = String(data.cache_seconds || 0); const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", () => { box.remove(); renumber(); changed(); }); const actions = el("div", undefined, "form-actions"); actions.append(remove); grid.append(actions); box.append(grid); rules.append(box); syncRule(box); targetType.addEventListener("change", () => syncRule(box)); } function syncRule(box) { const kind = box.querySelector('[data-field="target_type"]').value; const scheme = box.querySelector('[data-field="target_scheme"]').closest("label"); scheme.hidden = kind !== "host"; const target = box.querySelector('[data-field="target"]'); target.placeholder = kind === "url" ? "https://app.example.com/" : kind === "host" ? "app.example.com:8443" : "/new-path"; } function renumber() { [...rules.children].forEach((box, i) => { const legend = box.querySelector("legend"); if (legend) legend.textContent = `${t("redirectRule")} ${i + 1}`; }); } function changed() { dirty = true; } async function allowDiscard() { return !dirty || await appConfirm(t("redirectUnsaved"), { danger: true, confirmLabel: t("discardAction") }); } function listenerChoices(selected = []) { const holder = $("redirect-listeners"); holder.replaceChildren(); const control = window.rustProxyIntegrations.bindings(holder, selected, !editing); readRedirectBindings = control.read; const h2 = $("redirect-http2"), h3 = $("redirect-http3"); control.onChange(() => { const capabilities = control.capabilities(); h2.disabled = !capabilities.http2; h3.disabled = !capabilities.http3; if (!capabilities.http2) h2.checked = false; if (!capabilities.http3) h3.checked = false; }); } function certificateChoices(selected) { const select = $("redirect-certificate"); select.replaceChildren(option("", "vhCertificateDefault")); for (const cert of envelope?.config.tls?.certificates || []) { const node = el("option", cert.id); node.value = cert.id; select.append(node); } select.value = selected || ""; } function load(item = defaults()) { editing = item.id || null; revision = envelope?.revision ?? null; dirty = false; $("redirect-editing").textContent = editing ? editing : t("redirectNewLabel"); $("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; 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(); } function readRule(box) { const get = name => box.querySelector(`[data-field="${name}"]`); return { id: get("id").value.trim(), enabled: get("enabled").checked, source_scheme: get("source_scheme").value, match_type: get("match_type").value, path_prefix: get("path_prefix").value.trim(), target_type: get("target_type").value, target: get("target").value.trim(), target_scheme: get("target_scheme").value, path_mode: get("path_mode").value, query_mode: get("query_mode").value, status_code: Number(get("status_code").value), cache_seconds: Number(get("cache_seconds").value) || 0 }; } function suspendDraft() { const state = { editing, revision, label: $("redirect-editing").textContent, controls: window.rustProxyDrafts.captureControls(form), hosts: window.rustProxyDrafts.takeChildren($("redirect-hosts-editor")), listeners: window.rustProxyDrafts.takeChildren($("redirect-listeners")), rules: window.rustProxyDrafts.takeChildren(rules), readRedirectBindings, readRedirectHosts }; const title = value("redirect-id") || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state }; } 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); syncEnabledControl(); if (!dialog.open) dialog.showModal(); } function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("redirect"); if (dialog.open) dialog.close(); return true; } function collect() { const id = value("redirect-id"), name = value("redirect-name") || id, hosts = readRedirectHosts(); const listener_ids = readRedirectBindings(); if (!hosts.length) throw new Error(t("redirectHostsRequired")); if (!listener_ids.length) throw new Error(t("redirectListenersRequired")); const values = [...rules.querySelectorAll('[data-redirect-rule="1"]')].map(readRule); if (!values.length) throw new Error(t("redirectRulesRequired")); return { id, name, enabled: yes("redirect-enabled"), http2_enabled: yes("redirect-http2"), http3_enabled: yes("redirect-http3"), frontend: { hosts, listener_ids }, certificate_id: value("redirect-certificate") || null, rules: values }; } function render() { if (!envelope) return; const query = ($("redirect-search").value || "").trim().toLowerCase(); const entries = (envelope.config.redirects || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || []), ...(item.rules || []).map(rule => rule.target)].join(" ").toLowerCase().includes(query)); const sortValue = item => ({ id: String(item.id || "").toLowerCase(), name: String(item.name || item.id || "").toLowerCase(), hostname: String(item.frontend?.hosts?.[0] || "").toLowerCase(), status: item.enabled === false ? 1 : 0, target: (item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target || "").join(" ").toLowerCase(), rules: Number(item.rules?.length || 0), listeners: (item.frontend?.listener_ids || []).join(" ").toLowerCase(), certificate: String(item.certificate_id || "").toLowerCase(), }[listPrefs.sort]); entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; }); list.replaceChildren(...entries.map(item => { const card = el("article", undefined, "vhost-card redirect-card"); const 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"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head); const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))]; const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : ""); const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card; })); if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine")); } form.addEventListener("input", changed); form.addEventListener("change", changed); 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.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } }); $("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("redirect-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); }); $("redirect-close").addEventListener("click", closeEditor); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); }); window.rustProxyDrafts.register("redirect", { labelKey: "redirects", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } }); window.rustProxyRedirects = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } }; if (envelope) render(); })();