v0.31.7
This commit is contained in:
1 parent
4cc994b3bc
commit
79fd198949
48 files changed
+922
-202
No files matched your search
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||
),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.7.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.6.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.5.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
|
||||
|
||||
+81
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.31.6",
|
||||
"version": "0.31.7",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -662,6 +662,9 @@
|
||||
},
|
||||
"user_agent_blocking": {
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
},
|
||||
"rate_limit": {
|
||||
"$ref": "#/components/schemas/VhostRateLimit"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
@@ -5966,6 +5969,83 @@
|
||||
"description": "Case-insensitive User-Agent block patterns. Plain text matches a substring; * is a wildcard."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserAgentRateLimit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"pattern",
|
||||
"requests",
|
||||
"window_seconds",
|
||||
"burst"
|
||||
],
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"description": "Case-insensitive substring; * wildcard; first matching rule has precedence"
|
||||
},
|
||||
"requests": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"window_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600
|
||||
},
|
||||
"burst": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1000000
|
||||
}
|
||||
}
|
||||
},
|
||||
"VhostRateLimit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"requests": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"window_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600
|
||||
},
|
||||
"burst": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"country_access": {
|
||||
"type": "object",
|
||||
"description": "Country access policy"
|
||||
},
|
||||
"country_limits": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"user_agent_limits": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserAgentRateLimit"
|
||||
},
|
||||
"description": "First matching UA overrides the base and country rate; separate vhost/rule/IP fixed-window counters. Default empty."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -84,6 +84,7 @@ fn admin_vhosts_javascript() -> &'static str {
|
||||
"(() => {",
|
||||
include_str!("web/vhosts/core.js"),
|
||||
include_str!("web/vhosts/health.js"),
|
||||
include_str!("web/vhosts/rates.js"),
|
||||
include_str!("web/vhosts/editor.js"),
|
||||
include_str!("web/vhosts/render.js"),
|
||||
"})();",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
let configHistoryEntries = [], configHistorySelectedRevision = null;
|
||||
let configHistoryEntries = [], configHistorySelectedRevision = null, configHistorySelectedJson = null;
|
||||
function historyDate(value, options = {}) {
|
||||
const effective = { ...options };
|
||||
if (Object.hasOwn(effective, "timeStyle") || Object.hasOwn(effective, "hour")) effective.hour12 = (adminPreferences?.time_format || "24h") === "12h";
|
||||
@@ -12,8 +12,8 @@ function historyDayKey(value) {
|
||||
function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); }
|
||||
function historyValue(value) {
|
||||
if (value === undefined) return t("configHistoryMissing");
|
||||
const text = JSON.stringify(value, null, 2);
|
||||
return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text;
|
||||
const result = JSON.stringify(value);
|
||||
return result.length > 320 ? `${result.slice(0, 320)}…` : result;
|
||||
}
|
||||
function syncConfigHistoryRestoreButton() {
|
||||
const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision);
|
||||
@@ -21,14 +21,57 @@ function syncConfigHistoryRestoreButton() {
|
||||
button.hidden = configHistorySelectedRevision === null;
|
||||
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
|
||||
}
|
||||
// Compare leaf fields, not serialized object/array blobs. Object IDs are used
|
||||
// for stable array paths so reordering vhosts does not become a huge fake diff.
|
||||
function configDiff(before, after, path = "$", output = [], depth = 0) {
|
||||
if (output.length >= 200) return output;
|
||||
if (Object.is(before, after)) return output;
|
||||
const beforeObject = before && typeof before === "object" && !Array.isArray(before);
|
||||
const afterObject = after && typeof after === "object" && !Array.isArray(after);
|
||||
if (beforeObject && afterObject && depth < 10) {
|
||||
if (output.length >= 200 || Object.is(before, after)) return output;
|
||||
if (depth >= 24) {
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before: "[nested value]", after: "[nested value]" });
|
||||
return output;
|
||||
}
|
||||
const oldObj = before !== null && typeof before === "object";
|
||||
const newObj = after !== null && typeof after === "object";
|
||||
if (oldObj && newObj && !Array.isArray(before) && !Array.isArray(after)) {
|
||||
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
||||
for (const key of keys) configDiff(before[key], after[key], `${path}.${key}`, output, depth + 1);
|
||||
for (const key of keys) {
|
||||
if (output.length >= 200) break;
|
||||
configDiff(before[key], after[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
const named = items => items.every(item => item && typeof item === "object" && !Array.isArray(item) && (typeof item.id === "string" || typeof item.name === "string"));
|
||||
const itemKey = item => String(item.id ?? item.name);
|
||||
const stable = named(before) && named(after)
|
||||
&& new Set(before.map(itemKey)).size === before.length
|
||||
&& new Set(after.map(itemKey)).size === after.length;
|
||||
if (stable) {
|
||||
const oldItems = new Map(before.map(item => [itemKey(item), item]));
|
||||
const newItems = new Map(after.map(item => [itemKey(item), item]));
|
||||
for (const id of new Set([...oldItems.keys(), ...newItems.keys()])) {
|
||||
if (output.length >= 200) break;
|
||||
configDiff(oldItems.get(id), newItems.get(id), `${path}[${JSON.stringify(id)}]`, output, depth + 1);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < Math.max(before.length, after.length) && output.length < 200; i++) {
|
||||
configDiff(before[i], after[i], `${path}[${i}]`, output, depth + 1);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
// New/deleted trees are traversed to show the affected fields only.
|
||||
const tree = oldObj ? before : newObj ? after : null;
|
||||
if (tree && !Array.isArray(tree)) {
|
||||
for (const key of Object.keys(tree).sort()) {
|
||||
if (output.length >= 200) break;
|
||||
configDiff(before?.[key], after?.[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (Array.isArray(tree)) {
|
||||
for (let i = 0; i < tree.length && output.length < 200; i++) {
|
||||
configDiff(before?.[i], after?.[i], `${path}[${i}]`, output, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before, after });
|
||||
@@ -38,21 +81,22 @@ function renderConfigRevisionDetail(detail) {
|
||||
const entry = detail?.revision;
|
||||
if (!entry) return;
|
||||
configHistorySelectedRevision = Number(entry.revision);
|
||||
configHistorySelectedJson = entry.previous_config ? { before: entry.previous_config, after: entry.config } : null;
|
||||
const compareButton = $("config-history-compare-open");
|
||||
compareButton.disabled = !configHistorySelectedJson;
|
||||
$("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`;
|
||||
$("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`;
|
||||
syncConfigHistoryRestoreButton();
|
||||
document.querySelectorAll(".config-history-revision").forEach(button => button.classList.toggle("active", Number(button.dataset.revision) === Number(entry.revision)));
|
||||
const diff = configDiff(entry.previous_config, entry.config);
|
||||
const box = $("config-history-diff"); box.replaceChildren();
|
||||
if (!entry.previous_config) { box.append(el("p", t("configHistoryBaseline"), "empty")); return; }
|
||||
const diff = configDiff(entry.previous_config, entry.config);
|
||||
if (!diff.length) { box.append(el("p", t("configHistoryNoChanges"), "empty")); return; }
|
||||
for (const change of diff) {
|
||||
const card = el("article", undefined, "config-history-change");
|
||||
card.append(el("strong", change.path, "config-history-change-path"));
|
||||
const values = el("div", undefined, "config-history-change-values");
|
||||
for (const [label, value] of [[t("configHistoryBefore"), change.before], [t("configHistoryAfter"), change.after]]) {
|
||||
const side = el("div"); side.append(el("span", label, "small-label"), el("pre", historyValue(value))); values.append(side);
|
||||
}
|
||||
values.append(el("code", historyValue(change.before), "diff-before"), el("span", "→", "fine"), el("code", historyValue(change.after), "diff-after"));
|
||||
card.append(values); box.append(card);
|
||||
}
|
||||
if (diff.length >= 200) box.append(el("p", t("configHistoryDiffLimit"), "fine"));
|
||||
@@ -127,7 +171,7 @@ async function restoreConfigRevision() {
|
||||
finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); }
|
||||
}
|
||||
async function loadConfigHistory() {
|
||||
configHistorySelectedRevision = null; syncConfigHistoryRestoreButton();
|
||||
configHistorySelectedRevision = null; configHistorySelectedJson = null; $("config-history-compare-open").disabled = true; syncConfigHistoryRestoreButton();
|
||||
$("config-history-summary").textContent = t("configHistoryLoading");
|
||||
try {
|
||||
const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory();
|
||||
@@ -138,3 +182,11 @@ $("config-history-open").addEventListener("click", () => { const dialog = $("con
|
||||
$("config-history-close").addEventListener("click", () => $("config-history-dialog").close());
|
||||
$("config-history-reload").addEventListener("click", () => void loadConfigHistory());
|
||||
$("config-history-restore").addEventListener("click", () => void restoreConfigRevision());
|
||||
|
||||
$("config-history-compare-open").addEventListener("click", () => {
|
||||
if (!configHistorySelectedJson) return;
|
||||
$("config-history-json-before").textContent = JSON.stringify(configHistorySelectedJson.before, null, 2);
|
||||
$("config-history-json-after").textContent = JSON.stringify(configHistorySelectedJson.after, null, 2);
|
||||
const dialog = $("config-history-json-dialog"); if (!dialog.open) dialog.showModal();
|
||||
});
|
||||
$("config-history-json-close").addEventListener("click", () => $("config-history-json-dialog").close());
|
||||
+73
-49
@@ -1766,16 +1766,14 @@
|
||||
id="settings-tab-connections" role="tab" tabindex="-1" type="button"></button><button
|
||||
aria-controls="settings-page-docker-agents" aria-selected="false" data-i18n="dockerAgents"
|
||||
data-settings-tab="docker-agents" id="settings-tab-docker-agents" role="tab" tabindex="-1"
|
||||
type="button"></button><button aria-controls="settings-page-response" aria-selected="false"
|
||||
data-i18n="responseSettings" data-settings-tab="response" id="settings-tab-response"
|
||||
role="tab" tabindex="-1" type="button"></button><button aria-controls="settings-page-cache"
|
||||
type="button"></button><button aria-controls="settings-page-policies" aria-selected="false" data-i18n="policyRulesTitle"
|
||||
data-settings-tab="policies" id="settings-tab-policies" role="tab" tabindex="-1"
|
||||
type="button">Rules & headers</button><button aria-controls="settings-page-cache"
|
||||
aria-selected="false" data-i18n="cacheSettings" data-settings-tab="cache"
|
||||
id="settings-tab-cache" role="tab" tabindex="-1" type="button"></button><button
|
||||
aria-controls="settings-page-transform" aria-selected="false" data-i18n="transformSettings"
|
||||
data-settings-tab="transform" id="settings-tab-transform" role="tab" tabindex="-1"
|
||||
type="button"></button><button aria-controls="settings-page-headers" aria-selected="false"
|
||||
data-i18n="customHeaders" data-settings-tab="headers" id="settings-tab-headers" role="tab"
|
||||
tabindex="-1" type="button"></button><button aria-controls="settings-page-forwarding"
|
||||
type="button"></button><button aria-controls="settings-page-forwarding"
|
||||
aria-selected="false" data-i18n="forwardingSettings" data-settings-tab="forwarding"
|
||||
id="settings-tab-forwarding" role="tab" tabindex="-1" type="button"></button><button
|
||||
aria-controls="settings-page-pages" aria-selected="false" data-i18n="globalPages"
|
||||
@@ -2065,8 +2063,55 @@
|
||||
type="submit">Save and connect agents</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div aria-labelledby="settings-tab-response" class="settings-page" data-settings-page="response"
|
||||
hidden="" id="settings-page-response" role="tabpanel">
|
||||
<section aria-labelledby="settings-tab-policies" class="settings-page"
|
||||
data-settings-page="policies" hidden="" id="settings-page-policies" role="tabpanel">
|
||||
<div class="card edit-form">
|
||||
<div class="card-heading"><h2 data-i18n="policyRulesTitle">Rules & headers</h2><span class="pill live" data-i18n="hotApply">LIVE APPLY</span></div>
|
||||
<nav aria-label="Rules and headers" class="settings-tabs sub-tabs" id="policy-settings-tabs" role="tablist">
|
||||
<button aria-selected="true" data-policy-settings-tab="user-agent" data-i18n="globalUaBlockingTitle" type="button">Global User-Agent blocklist</button>
|
||||
<button aria-selected="false" data-policy-settings-tab="headers" data-i18n="customHeaders" type="button">Global custom headers</button>
|
||||
<button aria-selected="false" data-policy-settings-tab="response" data-i18n="responseSettings" type="button">Client response</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div data-policy-settings-page="user-agent">
|
||||
<article class="card security-policy-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
|
||||
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
|
||||
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="edit-form security-inline-form" id="global-ua-block-form">
|
||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
|
||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
<div data-policy-settings-page="headers" hidden="">
|
||||
<form class="card edit-form" id="headers-form">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="customHeaders">Global custom headers</h2><span class="pill live"
|
||||
data-i18n="hotApply">LIVE APPLY</span>
|
||||
</div>
|
||||
<p class="fine" data-i18n="globalHeadersHelp">Request rules go to the backend; response
|
||||
rules go to the client. Route rules run after global rules. Server policy takes
|
||||
final precedence.</p>
|
||||
<h3 class="subheading" data-i18n="responseHeaders">Response to client</h3>
|
||||
<div class="header-rules" id="response-header-rules"></div><button class="quiet"
|
||||
data-i18n="addHeader" id="add-response-header" type="button">Add header
|
||||
rule</button>
|
||||
<h3 class="subheading" data-i18n="requestHeaders">Request to backend</h3>
|
||||
<div class="header-rules" id="request-header-rules"></div><button class="quiet"
|
||||
data-i18n="addHeader" id="add-request-header" type="button">Add header rule</button>
|
||||
<p class="fine" data-i18n="headersHelp">Operations: set, append, remove. Transport, body
|
||||
encoding and client-identity headers are protected.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="saveHeaders"
|
||||
type="submit">Apply header rules</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div data-policy-settings-page="response" hidden="">
|
||||
<form class="card edit-form" id="response-form">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="responseSettings">Client response</h2><span class="pill live"
|
||||
@@ -2106,6 +2151,7 @@
|
||||
type="submit">Apply response settings</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<div aria-labelledby="settings-tab-cache" class="settings-page" data-settings-page="cache"
|
||||
hidden="" id="settings-page-cache" role="tabpanel">
|
||||
<form class="card edit-form" id="cache-form">
|
||||
@@ -2435,29 +2481,6 @@
|
||||
type="submit">Apply transformation policy</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div aria-labelledby="settings-tab-headers" class="settings-page" data-settings-page="headers"
|
||||
hidden="" id="settings-page-headers" role="tabpanel">
|
||||
<form class="card edit-form" id="headers-form">
|
||||
<div class="card-heading">
|
||||
<h2 data-i18n="customHeaders">Global custom headers</h2><span class="pill live"
|
||||
data-i18n="hotApply">LIVE APPLY</span>
|
||||
</div>
|
||||
<p class="fine" data-i18n="globalHeadersHelp">Request rules go to the backend; response
|
||||
rules go to the client. Route rules run after global rules. Server policy takes
|
||||
final precedence.</p>
|
||||
<h3 class="subheading" data-i18n="responseHeaders">Response to client</h3>
|
||||
<div class="header-rules" id="response-header-rules"></div><button class="quiet"
|
||||
data-i18n="addHeader" id="add-response-header" type="button">Add header
|
||||
rule</button>
|
||||
<h3 class="subheading" data-i18n="requestHeaders">Request to backend</h3>
|
||||
<div class="header-rules" id="request-header-rules"></div><button class="quiet"
|
||||
data-i18n="addHeader" id="add-request-header" type="button">Add header rule</button>
|
||||
<p class="fine" data-i18n="headersHelp">Operations: set, append, remove. Transport, body
|
||||
encoding and client-identity headers are protected.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="saveHeaders"
|
||||
type="submit">Apply header rules</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div aria-labelledby="settings-tab-forwarding" class="settings-page"
|
||||
data-settings-page="forwarding" hidden="" id="settings-page-forwarding" role="tabpanel">
|
||||
<form class="card edit-form" id="forwarding-form">
|
||||
@@ -2965,20 +2988,6 @@
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
<article class="card security-policy-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
|
||||
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
|
||||
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="edit-form security-inline-form" id="global-ua-block-form">
|
||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
|
||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card security-policy-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="securityAccessControl">ACCESS
|
||||
@@ -3174,10 +3183,13 @@
|
||||
<div><span class="small-label" data-i18n="configHistoryChanges">CHANGES</span>
|
||||
<h3 id="config-history-detail-title">-</h3>
|
||||
</div>
|
||||
<div class="config-history-detail-actions"><span class="fine"
|
||||
id="config-history-detail-meta"></span><button class="outline"
|
||||
data-i18n="configHistoryRestoreAction" id="config-history-restore" type="button" hidden
|
||||
disabled>Restore revision</button></div>
|
||||
<div class="config-history-detail-actions">
|
||||
<span class="fine" id="config-history-detail-meta"></span>
|
||||
<button class="outline" data-i18n="configHistoryRestoreAction"
|
||||
id="config-history-restore" type="button" hidden disabled>Restore revision</button>
|
||||
<button class="quiet" data-i18n="configHistoryFullCompare"
|
||||
id="config-history-compare-open" type="button" disabled>Full JSON comparison</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-history-revisions" id="config-history-revisions"></div>
|
||||
<div class="config-history-diff" id="config-history-diff">
|
||||
@@ -3186,6 +3198,18 @@
|
||||
</aside>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="config-json-compare-title" class="config-history-json-dialog" id="config-history-json-dialog">
|
||||
<div class="window-heading">
|
||||
<h2 data-i18n="configHistoryFullCompare" id="config-json-compare-title">Full JSON comparison</h2>
|
||||
<div class="window-tools">
|
||||
<button class="outline" data-i18n="close" id="config-history-json-close" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-history-json-columns">
|
||||
<section><h3 data-i18n="configHistoryBefore">Before</h3><pre id="config-history-json-before"></pre></section>
|
||||
<section><h3 data-i18n="configHistoryAfter">After</h3><pre id="config-history-json-after"></pre></section>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog aria-labelledby="audit-dialog-title" class="audit-dialog" id="audit-dialog">
|
||||
<div class="window-heading">
|
||||
<div><span class="small-label" data-i18n="auditRetention">SQLITE / LAST 100 / RETAINED 10,000</span>
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
const enabled = field(grid, "listener-enabled", "vhEnabled", item.enabled !== false, "checkbox");
|
||||
const tls = field(grid, "listener-tls", "vhTls", !!b.tls, "checkbox"); const h2 = field(grid, "listener-h2", "vhHttp2", b.tls && b.http2 !== false, "checkbox"); const h2c = field(grid, "listener-h2c", "listenerH2c", !b.tls && !!b.h2c, "checkbox");
|
||||
const preservedHttp2 = effectiveHttp2Settings(b);
|
||||
const h2Settings = button(row, "h2Configure", () => {
|
||||
const protocolActions = el("div", undefined, "listener-protocol-settings-actions vh-wide"); row.append(protocolActions);
|
||||
const h2Settings = button(protocolActions, "h2Configure", () => {
|
||||
window.rustProxySettings?.select("connections");
|
||||
window.rustProxySettings?.selectHttpTab?.("http2");
|
||||
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
|
||||
@@ -119,7 +120,7 @@
|
||||
h2Settings.classList.add("listener-h2-settings-link");
|
||||
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
|
||||
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
|
||||
const h3Settings = button(row, "h3Configure", () => {
|
||||
const h3Settings = button(protocolActions, "h3Configure", () => {
|
||||
window.rustProxySettings?.select("connections");
|
||||
window.rustProxySettings?.selectHttpTab?.("http3");
|
||||
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
|
||||
|
||||
@@ -394,8 +394,12 @@
|
||||
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`);
|
||||
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
|
||||
}
|
||||
if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`);
|
||||
box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details"));
|
||||
if (users.length) {
|
||||
const attached = el("details", undefined, "acl-profile-attached");
|
||||
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
|
||||
box.append(attached);
|
||||
}
|
||||
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
|
||||
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
|
||||
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
|
||||
? "logging"
|
||||
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
|
||||
const tabAliases = { http2: "connections" };
|
||||
const tabAliases = { http2: "connections", headers: "policies", response: "policies" };
|
||||
// Keep the public panel hostname implicit. Legacy configurations may have
|
||||
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
|
||||
function pinnedAdminHostname(value) {
|
||||
@@ -57,6 +57,27 @@
|
||||
});
|
||||
}
|
||||
search?.addEventListener("input", applySettingsSearch);
|
||||
const policyTabs = [...document.querySelectorAll("[data-policy-settings-tab]")].filter(button => typeof button.addEventListener === "function" && typeof button.setAttribute === "function");
|
||||
function selectPolicyTab(name, focus = false) {
|
||||
if (!policyTabs.some(button => button.dataset.policySettingsTab === name)) return;
|
||||
for (const button of policyTabs) {
|
||||
const chosen = button.dataset.policySettingsTab === name;
|
||||
button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1;
|
||||
if (chosen && focus) button.focus();
|
||||
}
|
||||
document.querySelectorAll("[data-policy-settings-page]").forEach(page => page.hidden = page.dataset.policySettingsPage !== name);
|
||||
}
|
||||
for (const button of policyTabs) {
|
||||
button.addEventListener("click", () => selectPolicyTab(button.dataset.policySettingsTab));
|
||||
button.addEventListener("keydown", event => {
|
||||
const index = policyTabs.indexOf(button); let next;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % policyTabs.length;
|
||||
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + policyTabs.length - 1) % policyTabs.length;
|
||||
else if (event.key === "Home") next = 0; else if (event.key === "End") next = policyTabs.length - 1; else return;
|
||||
event.preventDefault(); selectPolicyTab(policyTabs[next].dataset.policySettingsTab, true);
|
||||
});
|
||||
}
|
||||
selectPolicyTab(["headers", "response"].includes(requestedTab) ? requestedTab : "user-agent");
|
||||
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
|
||||
function selectCacheTab(name, focus = false) {
|
||||
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
|
||||
@@ -352,6 +373,6 @@ curl --fail --show-error --request POST \
|
||||
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
|
||||
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
select(active); render();
|
||||
})();
|
||||
+61
-6
@@ -4239,7 +4239,8 @@ dialog.vhost-dialog,
|
||||
dialog.logs-dialog,
|
||||
dialog.audit-dialog,
|
||||
dialog.certificate-dialog,
|
||||
dialog.config-history-dialog {
|
||||
dialog.config-history-dialog,
|
||||
dialog.config-history-json-dialog {
|
||||
border-radius: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
@@ -8252,18 +8253,34 @@ body[data-density="compact"] .page-footer {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-history-dialog[open] {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: min(820px, calc(100dvh - 28px));
|
||||
}
|
||||
|
||||
.config-history-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(380px, .95fr) minmax(420px, 1.05fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
max-height: calc(100dvh - 90px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-history-browser,
|
||||
.config-history-detail {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.config-history-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-history-months {
|
||||
@@ -8353,9 +8370,12 @@ button.config-history-day.is-selected {
|
||||
|
||||
.config-history-revisions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 110px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-history-revision {
|
||||
@@ -8369,7 +8389,14 @@ button.config-history-day.is-selected {
|
||||
|
||||
.config-history-diff {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.config-history-change {
|
||||
@@ -8402,14 +8429,22 @@ button.config-history-day.is-selected {
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.config-history-layout {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-history-browser {
|
||||
max-height: min(300px, 38dvh);
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-history-browser,
|
||||
.config-history-detail {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.config-history-revisions { max-height: none; }
|
||||
.config-history-diff { overflow: visible; }
|
||||
|
||||
.config-history-change-values {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -10463,3 +10498,23 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
|
||||
.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; }
|
||||
.listener-protocol-settings-actions button { margin: 0; }
|
||||
.listener-protocol-settings-actions button[hidden] { display: none; }
|
||||
|
||||
/* 0.31.7 revision preview shows only changed leaf fields. */
|
||||
.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; }
|
||||
.config-history-change-values .diff-after { color: var(--text); }
|
||||
.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; }
|
||||
.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; }
|
||||
.config-history-json-columns section { min-width: 0; }
|
||||
.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); }
|
||||
@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } }
|
||||
|
||||
.acl-profile-attached { margin-block: 6px; }
|
||||
.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
||||
.acl-profile-attached p { overflow-wrap: anywhere; }
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
@@ -137,7 +137,7 @@
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
@@ -147,7 +147,7 @@
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
@@ -446,7 +446,7 @@
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
// Per-vhost rate limit editors, shared in the bundled vhost closure.
|
||||
function userAgentRateEditor(parent, values = []) {
|
||||
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
|
||||
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
|
||||
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
|
||||
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
|
||||
}
|
||||
Reference in new issue
Block a user