This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
"use strict";
(() => {
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 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; $("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 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));
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"), () => 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", async () => { if (await allowDiscard()) load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
$("redirect-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("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 => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } };
if (envelope) render();
})();