2102 lines
55 KiB
JavaScript
2102 lines
55 KiB
JavaScript
"use strict";
|
|
"require form";
|
|
"require baseclass";
|
|
"require fs";
|
|
"require ui";
|
|
"require tools.widgets as widgets";
|
|
"require uci";
|
|
"require view.podkop_plus.main as main";
|
|
|
|
const UCI_PACKAGE = main.PODKOP_UCI_PACKAGE;
|
|
|
|
function valuesToText(values) {
|
|
if (!values) {
|
|
return "";
|
|
}
|
|
|
|
if (Array.isArray(values)) {
|
|
return values.filter(Boolean).join("\n");
|
|
}
|
|
|
|
return values ? `${values}` : "";
|
|
}
|
|
|
|
function normalizeOptionValues(value) {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter(Boolean)
|
|
.map((item) => `${item}`.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
return `${value}`
|
|
.split(/\s+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const ZAPRET_LEGACY_DEFAULT_NFQWS_OPT =
|
|
"--filter-tcp=80 <HOSTLIST> --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --hostlist=/opt/zapret/ipset/zapret-hosts-google.txt --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin --new --filter-udp=443 <HOSTLIST_NOAUTO> --dpi-desync=fake --dpi-desync-repeats=11 --new --filter-tcp=443 <HOSTLIST> --dpi-desync=multidisorder --dpi-desync-split-pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1";
|
|
|
|
const ZAPRET_DEFAULT_NFQWS_OPT =
|
|
"--filter-tcp=80 --dpi-desync=fake,fakedsplit --dpi-desync-autottl=2 --dpi-desync-fooling=badsum --new --filter-tcp=443 --dpi-desync=fake,multidisorder --dpi-desync-split-pos=1,midsld --dpi-desync-repeats=11 --dpi-desync-fooling=badsum --dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com --new --filter-udp=443 --dpi-desync=fake --dpi-desync-repeats=11 --dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin";
|
|
|
|
const ANNOTATED_TEXTAREA_STYLE_ID = "pdk-annotated-textarea-styles";
|
|
const NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS = 500;
|
|
const NFQWS_VALIDATION_COMMAND = "/usr/bin/podkop-plus";
|
|
const nfqwsRemoteValidationCache = new Map();
|
|
const nfqwsRemoteValidationInflight = new Map();
|
|
const NFQWS_OPTIONAL_ARG_OPTIONS = new Set([
|
|
"--comment",
|
|
"--ctrack-disable",
|
|
"--debug",
|
|
"--dpi-desync-any-protocol",
|
|
"--dpi-desync-autottl",
|
|
"--dpi-desync-autottl6",
|
|
"--dpi-desync-skip-nosni",
|
|
"--dpi-desync-tcp-flags-set",
|
|
"--dpi-desync-tcp-flags-unset",
|
|
"--dup-autottl",
|
|
"--dup-autottl6",
|
|
"--dup-replace",
|
|
"--dup-tcp-flags-set",
|
|
"--dup-tcp-flags-unset",
|
|
"--ipcache-hostname",
|
|
"--orig-autottl",
|
|
"--orig-autottl6",
|
|
"--orig-tcp-flags-set",
|
|
"--orig-tcp-flags-unset",
|
|
"--synack-split",
|
|
]);
|
|
const NFQWS_NO_ARG_OPTIONS = new Set([
|
|
"--bind-fix4",
|
|
"--bind-fix6",
|
|
"--daemon",
|
|
"--domcase",
|
|
"--dry-run",
|
|
"--hostcase",
|
|
"--hostnospace",
|
|
"--methodeol",
|
|
"--new",
|
|
"--skip",
|
|
"--version",
|
|
]);
|
|
const NFQWS_REQUIRED_ARG_OPTIONS = new Set([
|
|
"--ctrack-timeouts",
|
|
"--dpi-desync",
|
|
"--dpi-desync-badack-increment",
|
|
"--dpi-desync-badseq-increment",
|
|
"--dpi-desync-cutoff",
|
|
"--dpi-desync-fake-dht",
|
|
"--dpi-desync-fake-discord",
|
|
"--dpi-desync-fake-http",
|
|
"--dpi-desync-fake-quic",
|
|
"--dpi-desync-fake-stun",
|
|
"--dpi-desync-fake-syndata",
|
|
"--dpi-desync-fake-tcp-mod",
|
|
"--dpi-desync-fake-tls",
|
|
"--dpi-desync-fake-tls-mod",
|
|
"--dpi-desync-fake-unknown",
|
|
"--dpi-desync-fake-unknown-udp",
|
|
"--dpi-desync-fake-wireguard",
|
|
"--dpi-desync-fakedsplit-mod",
|
|
"--dpi-desync-fakedsplit-pattern",
|
|
"--dpi-desync-fooling",
|
|
"--dpi-desync-fwmark",
|
|
"--dpi-desync-hostfakesplit-midhost",
|
|
"--dpi-desync-hostfakesplit-mod",
|
|
"--dpi-desync-ipfrag-pos-tcp",
|
|
"--dpi-desync-ipfrag-pos-udp",
|
|
"--dpi-desync-repeats",
|
|
"--dpi-desync-split-http-req",
|
|
"--dpi-desync-split-pos",
|
|
"--dpi-desync-split-seqovl",
|
|
"--dpi-desync-split-seqovl-pattern",
|
|
"--dpi-desync-split-tls",
|
|
"--dpi-desync-start",
|
|
"--dpi-desync-ts-increment",
|
|
"--dpi-desync-ttl",
|
|
"--dpi-desync-ttl6",
|
|
"--dpi-desync-udplen-increment",
|
|
"--dpi-desync-udplen-pattern",
|
|
"--dup",
|
|
"--dup-badack-increment",
|
|
"--dup-badseq-increment",
|
|
"--dup-cutoff",
|
|
"--dup-fooling",
|
|
"--dup-ip-id",
|
|
"--dup-start",
|
|
"--dup-ts-increment",
|
|
"--dup-ttl",
|
|
"--dup-ttl6",
|
|
"--filter-l3",
|
|
"--filter-l7",
|
|
"--filter-tcp",
|
|
"--filter-udp",
|
|
"--hostlist",
|
|
"--hostlist-auto",
|
|
"--hostlist-auto-debug",
|
|
"--hostlist-auto-fail-threshold",
|
|
"--hostlist-auto-fail-time",
|
|
"--hostlist-auto-retrans-threshold",
|
|
"--hostlist-domains",
|
|
"--hostlist-exclude",
|
|
"--hostlist-exclude-domains",
|
|
"--hostspell",
|
|
"--ip-id",
|
|
"--ipcache-lifetime",
|
|
"--ipset",
|
|
"--ipset-exclude",
|
|
"--ipset-exclude-ip",
|
|
"--ipset-ip",
|
|
"--orig-mod-cutoff",
|
|
"--orig-mod-start",
|
|
"--orig-ttl",
|
|
"--orig-ttl6",
|
|
"--pidfile",
|
|
"--qnum",
|
|
"--uid",
|
|
"--user",
|
|
"--wsize",
|
|
"--wssize",
|
|
"--wssize-cutoff",
|
|
"--wssize-forced-cutoff",
|
|
]);
|
|
const zapretAvailabilityState = {
|
|
loaded: false,
|
|
installed: true,
|
|
};
|
|
let zapretAvailabilityPromise = null;
|
|
|
|
function ensureZapretAvailabilityLoaded() {
|
|
if (zapretAvailabilityState.loaded) {
|
|
return Promise.resolve(zapretAvailabilityState);
|
|
}
|
|
|
|
if (zapretAvailabilityPromise) {
|
|
return zapretAvailabilityPromise;
|
|
}
|
|
|
|
zapretAvailabilityPromise = main.PodkopShellMethods.getZapretStatus()
|
|
.then((result) => {
|
|
zapretAvailabilityState.loaded = true;
|
|
zapretAvailabilityState.installed = Boolean(
|
|
result && result.success && result.data && result.data.installed,
|
|
);
|
|
return zapretAvailabilityState;
|
|
})
|
|
.catch(() => {
|
|
zapretAvailabilityState.loaded = true;
|
|
zapretAvailabilityState.installed = true;
|
|
return zapretAvailabilityState;
|
|
})
|
|
.finally(() => {
|
|
zapretAvailabilityPromise = null;
|
|
});
|
|
|
|
return zapretAvailabilityPromise;
|
|
}
|
|
|
|
function isZapretInstalledForUi() {
|
|
return zapretAvailabilityState.installed;
|
|
}
|
|
|
|
function getRuleResolvedAction(section_id) {
|
|
const action = uci.get(UCI_PACKAGE, section_id, "action");
|
|
if (action) {
|
|
return `${action}`;
|
|
}
|
|
|
|
const connectionType = uci.get(UCI_PACKAGE, section_id, "connection_type");
|
|
switch (connectionType) {
|
|
case "proxy":
|
|
case "vpn":
|
|
return "proxy";
|
|
case "block":
|
|
return "block";
|
|
case "exclusion":
|
|
return "direct";
|
|
default:
|
|
return "proxy";
|
|
}
|
|
}
|
|
|
|
function getActionOptionLabel(action) {
|
|
switch (`${action}`) {
|
|
case "block":
|
|
return "Block";
|
|
case "direct":
|
|
return "Direct";
|
|
case "zapret":
|
|
return isZapretInstalledForUi()
|
|
? "Zapret"
|
|
: _("Zapret (package not installed)");
|
|
case "proxy":
|
|
default:
|
|
return "Proxy";
|
|
}
|
|
}
|
|
|
|
function getRuleActionDisplayValue(section_id) {
|
|
const action = getRuleResolvedAction(section_id);
|
|
|
|
if (action === "zapret") {
|
|
return "Zapret";
|
|
}
|
|
|
|
return getActionOptionLabel(action);
|
|
}
|
|
|
|
function getRuleActionDisplayMarkup(section_id) {
|
|
return getRuleActionDisplayValue(section_id);
|
|
}
|
|
|
|
function populateActionOptionValues(option) {
|
|
delete option.keylist;
|
|
delete option.vallist;
|
|
|
|
option.value("proxy", "Proxy");
|
|
option.value("direct", "Direct");
|
|
option.value("block", "Block");
|
|
option.value("zapret", getActionOptionLabel("zapret"));
|
|
}
|
|
|
|
function disableUnavailableZapretOption(node) {
|
|
if (!node || isZapretInstalledForUi()) {
|
|
return;
|
|
}
|
|
|
|
const select =
|
|
typeof node.querySelector === "function"
|
|
? node.querySelector("select")
|
|
: null;
|
|
if (!select || !select.options) {
|
|
return;
|
|
}
|
|
|
|
Array.from(select.options).forEach((option) => {
|
|
if (option.value === "zapret") {
|
|
option.disabled = true;
|
|
option.textContent = _("Zapret (package not installed)");
|
|
}
|
|
});
|
|
}
|
|
|
|
function getConfigListValues(section_id, key) {
|
|
return normalizeOptionValues(uci.get(UCI_PACKAGE, section_id, key));
|
|
}
|
|
|
|
function writeListOption(section_id, key, values) {
|
|
const normalized = normalizeOptionValues(values);
|
|
|
|
if (normalized.length) {
|
|
uci.set(UCI_PACKAGE, section_id, key, normalized);
|
|
} else {
|
|
uci.unset(UCI_PACKAGE, section_id, key);
|
|
}
|
|
}
|
|
|
|
function validateRegex(_section_id, value) {
|
|
if (!value || !value.length) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
new RegExp(value);
|
|
return true;
|
|
} catch (_error) {
|
|
return _("Invalid regular expression");
|
|
}
|
|
}
|
|
|
|
function validateKeyword(_section_id, value) {
|
|
if (!value || !value.length) {
|
|
return true;
|
|
}
|
|
|
|
if (/\s/.test(value)) {
|
|
return _("Keyword must not contain spaces");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getDuplicateTextListErrors(values, normalizeValue, duplicateMessage) {
|
|
const seen = new Set();
|
|
const duplicates = [];
|
|
|
|
values.forEach((item) => {
|
|
const normalized = normalizeValue ? normalizeValue(item) : item;
|
|
|
|
if (seen.has(normalized)) {
|
|
if (!duplicates.includes(item)) {
|
|
duplicates.push(item);
|
|
}
|
|
return;
|
|
}
|
|
|
|
seen.add(normalized);
|
|
});
|
|
|
|
return duplicates.map((item) => `${item}: ${duplicateMessage}`);
|
|
}
|
|
|
|
function validateTextList(
|
|
_section_id,
|
|
value,
|
|
validateItem,
|
|
emptyMessage,
|
|
options = {},
|
|
) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const values = main.parseValueList(value);
|
|
|
|
if (!values.length) {
|
|
return emptyMessage;
|
|
}
|
|
|
|
if (!validateItem) {
|
|
const duplicateErrors = getDuplicateTextListErrors(
|
|
values,
|
|
options.normalizeDuplicateValue,
|
|
options.duplicateMessage || _("Duplicate value"),
|
|
);
|
|
|
|
if (!duplicateErrors.length) {
|
|
return true;
|
|
}
|
|
|
|
return [_("Validation errors:"), ...duplicateErrors].join("\n");
|
|
}
|
|
|
|
const { valid, results } = main.bulkValidate(values, validateItem);
|
|
|
|
const duplicateErrors = getDuplicateTextListErrors(
|
|
values,
|
|
options.normalizeDuplicateValue,
|
|
options.duplicateMessage || _("Duplicate value"),
|
|
);
|
|
|
|
if (valid && !duplicateErrors.length) {
|
|
return true;
|
|
}
|
|
|
|
const errors = results
|
|
.filter((item) => !item.valid)
|
|
.map((item) => `${item.value}: ${item.message}`)
|
|
.concat(duplicateErrors);
|
|
|
|
return [_("Validation errors:"), ...errors].join("\n");
|
|
}
|
|
|
|
function getValidationHeaderText() {
|
|
return _("Validation errors:");
|
|
}
|
|
|
|
function getDuplicateValueText() {
|
|
return _("Duplicate value");
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return `${value}`
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function ensureAnnotatedTextareaStyles() {
|
|
if (
|
|
typeof document === "undefined" ||
|
|
!document.head ||
|
|
document.getElementById(ANNOTATED_TEXTAREA_STYLE_ID)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
document.head.insertAdjacentHTML(
|
|
"beforeend",
|
|
`<style id="${ANNOTATED_TEXTAREA_STYLE_ID}">
|
|
.pdk-annotated-textarea {
|
|
position: relative;
|
|
}
|
|
|
|
.pdk-annotated-textarea > textarea {
|
|
position: relative;
|
|
z-index: 1;
|
|
background: transparent !important;
|
|
}
|
|
|
|
.pdk-annotated-textarea__overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
color: transparent;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.pdk-annotated-textarea__invalid {
|
|
color: transparent;
|
|
text-decoration-line: underline;
|
|
text-decoration-style: wavy;
|
|
text-decoration-color: var(--error-color-medium, #d44);
|
|
text-decoration-thickness: 1.5px;
|
|
text-underline-offset: 2px;
|
|
text-decoration-skip-ink: none;
|
|
}
|
|
</style>`,
|
|
);
|
|
}
|
|
|
|
function applyTextareaInputAttributes(textarea) {
|
|
textarea.setAttribute("spellcheck", "false");
|
|
textarea.setAttribute("autocomplete", "off");
|
|
textarea.setAttribute("autocorrect", "off");
|
|
textarea.setAttribute("autocapitalize", "off");
|
|
textarea.setAttribute("data-gramm", "false");
|
|
textarea.setAttribute("data-gramm_editor", "false");
|
|
textarea.setAttribute("data-enable-grammarly", "false");
|
|
textarea.style.resize = "vertical";
|
|
textarea.style.maxWidth = "100%";
|
|
|
|
const applyMinHeight = () => {
|
|
const storedMinHeight = Number.parseFloat(
|
|
textarea.getAttribute("data-pdk-default-min-height") || "0",
|
|
);
|
|
const nextMinHeight =
|
|
storedMinHeight > 0 ? storedMinHeight : textarea.offsetHeight;
|
|
|
|
if (nextMinHeight > 0) {
|
|
if (storedMinHeight <= 0) {
|
|
textarea.setAttribute(
|
|
"data-pdk-default-min-height",
|
|
`${nextMinHeight}`,
|
|
);
|
|
}
|
|
|
|
textarea.style.minHeight = `${nextMinHeight}px`;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
if (
|
|
!applyMinHeight() &&
|
|
typeof window !== "undefined" &&
|
|
typeof window.requestAnimationFrame === "function"
|
|
) {
|
|
window.requestAnimationFrame(() => {
|
|
applyMinHeight();
|
|
});
|
|
}
|
|
}
|
|
|
|
function syncAnnotatedTextareaOverlay(textarea, wrapper, overlay) {
|
|
if (
|
|
typeof window === "undefined" ||
|
|
!textarea ||
|
|
!wrapper ||
|
|
!overlay ||
|
|
typeof window.getComputedStyle !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const style = window.getComputedStyle(textarea);
|
|
|
|
wrapper.style.backgroundColor = style.backgroundColor;
|
|
wrapper.style.borderRadius = style.borderRadius;
|
|
|
|
overlay.style.font = style.font;
|
|
overlay.style.lineHeight = style.lineHeight;
|
|
overlay.style.letterSpacing = style.letterSpacing;
|
|
overlay.style.paddingTop = style.paddingTop;
|
|
overlay.style.paddingRight = style.paddingRight;
|
|
overlay.style.paddingBottom = style.paddingBottom;
|
|
overlay.style.paddingLeft = style.paddingLeft;
|
|
overlay.style.borderTopWidth = style.borderTopWidth;
|
|
overlay.style.borderRightWidth = style.borderRightWidth;
|
|
overlay.style.borderBottomWidth = style.borderBottomWidth;
|
|
overlay.style.borderLeftWidth = style.borderLeftWidth;
|
|
overlay.style.borderStyle = "solid";
|
|
overlay.style.borderColor = "transparent";
|
|
overlay.style.textAlign = style.textAlign;
|
|
overlay.style.direction = style.direction;
|
|
overlay.style.tabSize = style.tabSize;
|
|
overlay.style.textIndent = style.textIndent;
|
|
overlay.style.textTransform = style.textTransform;
|
|
overlay.style.boxSizing = style.boxSizing;
|
|
overlay.style.scrollPaddingTop = style.scrollPaddingTop;
|
|
|
|
overlay.scrollTop = textarea.scrollTop;
|
|
overlay.scrollLeft = textarea.scrollLeft;
|
|
}
|
|
|
|
function createAnnotationKey(annotation) {
|
|
return `${annotation.start}:${annotation.end}`;
|
|
}
|
|
|
|
function addAnnotationIssue(annotationMap, annotation, message) {
|
|
const key = createAnnotationKey(annotation);
|
|
const existing = annotationMap.get(key);
|
|
if (existing) {
|
|
if (!existing.messages.includes(message)) {
|
|
existing.messages.push(message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
annotationMap.set(key, {
|
|
start: annotation.start,
|
|
end: annotation.end,
|
|
messages: [message],
|
|
});
|
|
}
|
|
|
|
function finalizeAnnotations(annotationMap) {
|
|
return Array.from(annotationMap.values())
|
|
.map((annotation) => ({
|
|
start: annotation.start,
|
|
end: annotation.end,
|
|
message: annotation.messages.join("; "),
|
|
}))
|
|
.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
}
|
|
|
|
function renderAnnotatedTextareaOverlay(value, annotations) {
|
|
const text = value ? `${value}` : "";
|
|
const normalizedAnnotations = Array.isArray(annotations) ? annotations : [];
|
|
|
|
if (!text.length) {
|
|
return "​";
|
|
}
|
|
|
|
if (!normalizedAnnotations.length) {
|
|
return `${escapeHtml(text)}${text.endsWith("\n") ? "\n " : ""}`;
|
|
}
|
|
|
|
let cursor = 0;
|
|
let html = "";
|
|
|
|
normalizedAnnotations.forEach((annotation) => {
|
|
if (
|
|
annotation.start < cursor ||
|
|
annotation.start >= annotation.end ||
|
|
annotation.start < 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
html += escapeHtml(text.slice(cursor, annotation.start));
|
|
html += `<span class="pdk-annotated-textarea__invalid">${escapeHtml(
|
|
text.slice(annotation.start, annotation.end),
|
|
)}</span>`;
|
|
cursor = annotation.end;
|
|
});
|
|
|
|
html += escapeHtml(text.slice(cursor));
|
|
|
|
if (text.endsWith("\n")) {
|
|
html += "\n ";
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function attachAnnotatedTextarea(textarea, analyzer) {
|
|
if (!textarea || typeof analyzer !== "function") {
|
|
return;
|
|
}
|
|
|
|
ensureAnnotatedTextareaStyles();
|
|
|
|
if (textarea.__podkopAnnotatedTextareaController) {
|
|
textarea.__podkopAnnotatedTextareaController.analyzer = analyzer;
|
|
textarea.__podkopAnnotatedTextareaController.update();
|
|
return;
|
|
}
|
|
|
|
const wrapper = textarea.parentNode;
|
|
if (!wrapper) {
|
|
return;
|
|
}
|
|
|
|
wrapper.classList.add("pdk-annotated-textarea");
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "pdk-annotated-textarea__overlay";
|
|
overlay.setAttribute("aria-hidden", "true");
|
|
wrapper.insertBefore(overlay, textarea.nextSibling);
|
|
|
|
const controller = {
|
|
analyzer,
|
|
textarea,
|
|
wrapper,
|
|
overlay,
|
|
update() {
|
|
const analysis = this.analyzer(this.textarea.value);
|
|
this.overlay.innerHTML = renderAnnotatedTextareaOverlay(
|
|
this.textarea.value,
|
|
analysis.annotations,
|
|
);
|
|
syncAnnotatedTextareaOverlay(this.textarea, this.wrapper, this.overlay);
|
|
},
|
|
};
|
|
|
|
textarea.__podkopAnnotatedTextareaController = controller;
|
|
|
|
const updateAnnotatedTextarea = () => controller.update();
|
|
textarea.addEventListener("input", updateAnnotatedTextarea);
|
|
textarea.addEventListener("change", updateAnnotatedTextarea);
|
|
textarea.addEventListener("scroll", updateAnnotatedTextarea, {
|
|
passive: true,
|
|
});
|
|
textarea.addEventListener("keyup", updateAnnotatedTextarea);
|
|
|
|
if (typeof ResizeObserver === "function") {
|
|
const resizeObserver = new ResizeObserver(() => controller.update());
|
|
resizeObserver.observe(textarea);
|
|
controller.resizeObserver = resizeObserver;
|
|
}
|
|
|
|
controller.update();
|
|
}
|
|
|
|
function refreshAnnotatedTextareaValidation(option, section_id, textarea) {
|
|
if (option && typeof option.triggerValidation === "function") {
|
|
option.triggerValidation(section_id);
|
|
}
|
|
|
|
if (
|
|
textarea &&
|
|
textarea.__podkopAnnotatedTextareaController &&
|
|
typeof textarea.__podkopAnnotatedTextareaController.update === "function"
|
|
) {
|
|
textarea.__podkopAnnotatedTextareaController.update();
|
|
}
|
|
}
|
|
|
|
function attachNfqwsRemoteValidation(option, section_id, textarea) {
|
|
if (!textarea || textarea.__podkopNfqwsRemoteValidationAttached) {
|
|
return;
|
|
}
|
|
|
|
textarea.__podkopNfqwsRemoteValidationAttached = true;
|
|
textarea.__podkopNfqwsRemoteValidationRequestId = 0;
|
|
textarea.__podkopNfqwsRemoteValidationTimer = null;
|
|
|
|
const runValidation = () => {
|
|
const value = textarea.value;
|
|
const localAnalysis = buildNfqwsLocalAnalysis(value);
|
|
if (!localAnalysis.valid) {
|
|
refreshAnnotatedTextareaValidation(option, section_id, textarea);
|
|
return;
|
|
}
|
|
|
|
const requestId =
|
|
(textarea.__podkopNfqwsRemoteValidationRequestId || 0) + 1;
|
|
textarea.__podkopNfqwsRemoteValidationRequestId = requestId;
|
|
|
|
validateNfqwsStrategyRemotely(value).then(() => {
|
|
if (textarea.__podkopNfqwsRemoteValidationRequestId !== requestId) {
|
|
return;
|
|
}
|
|
|
|
refreshAnnotatedTextareaValidation(option, section_id, textarea);
|
|
});
|
|
};
|
|
|
|
const scheduleValidation = (delay = NFQWS_REMOTE_VALIDATION_DEBOUNCE_MS) => {
|
|
if (textarea.__podkopNfqwsRemoteValidationTimer) {
|
|
window.clearTimeout(textarea.__podkopNfqwsRemoteValidationTimer);
|
|
}
|
|
|
|
textarea.__podkopNfqwsRemoteValidationTimer = window.setTimeout(() => {
|
|
textarea.__podkopNfqwsRemoteValidationTimer = null;
|
|
runValidation();
|
|
}, delay);
|
|
};
|
|
|
|
textarea.addEventListener("input", () => scheduleValidation());
|
|
textarea.addEventListener("change", () => scheduleValidation(0));
|
|
textarea.addEventListener("blur", () => scheduleValidation(0));
|
|
|
|
scheduleValidation(0);
|
|
}
|
|
|
|
function parseCommentAwareListTokens(value) {
|
|
const text = value ? `${value}` : "";
|
|
const tokens = [];
|
|
const lines = text.split(/\r\n|\r|\n/);
|
|
const newlines = text.match(/\r\n|\r|\n/g) || [];
|
|
let offset = 0;
|
|
|
|
lines.forEach((line, index) => {
|
|
const hashIndex = line.indexOf("#");
|
|
const slashIndex = line.indexOf("//");
|
|
let commentIndex = -1;
|
|
|
|
if (hashIndex >= 0 && slashIndex >= 0) {
|
|
commentIndex = Math.min(hashIndex, slashIndex);
|
|
} else if (hashIndex >= 0) {
|
|
commentIndex = hashIndex;
|
|
} else if (slashIndex >= 0) {
|
|
commentIndex = slashIndex;
|
|
}
|
|
|
|
const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
|
|
const matcher = /[^,\s]+/g;
|
|
let match;
|
|
|
|
while ((match = matcher.exec(source)) !== null) {
|
|
tokens.push({
|
|
value: match[0],
|
|
start: offset + match.index,
|
|
end: offset + match.index + match[0].length,
|
|
});
|
|
}
|
|
|
|
offset += line.length + (newlines[index] ? newlines[index].length : 0);
|
|
});
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function analyzeTextListValue(value, validateItem, emptyMessage, options = {}) {
|
|
const text = value ? `${value}` : "";
|
|
if (!text.length) {
|
|
return { valid: true, message: "", annotations: [] };
|
|
}
|
|
|
|
const tokens = parseCommentAwareListTokens(text);
|
|
if (!tokens.length) {
|
|
return { valid: false, message: emptyMessage, annotations: [] };
|
|
}
|
|
|
|
const duplicateMessage = options.duplicateMessage || getDuplicateValueText();
|
|
const annotationMap = new Map();
|
|
const errors = [];
|
|
const seen = new Set();
|
|
|
|
tokens.forEach((token) => {
|
|
if (typeof validateItem === "function") {
|
|
const validation = validateItem(token.value);
|
|
if (!validation.valid) {
|
|
errors.push(`${token.value}: ${validation.message}`);
|
|
addAnnotationIssue(annotationMap, token, validation.message);
|
|
}
|
|
}
|
|
|
|
const normalized = options.normalizeDuplicateValue
|
|
? options.normalizeDuplicateValue(token.value)
|
|
: token.value;
|
|
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
if (seen.has(normalized)) {
|
|
errors.push(`${token.value}: ${duplicateMessage}`);
|
|
addAnnotationIssue(annotationMap, token, duplicateMessage);
|
|
return;
|
|
}
|
|
|
|
seen.add(normalized);
|
|
});
|
|
|
|
if (!errors.length) {
|
|
return { valid: true, message: "", annotations: [] };
|
|
}
|
|
|
|
return {
|
|
valid: false,
|
|
message: [getValidationHeaderText(), ...errors].join("\n"),
|
|
annotations: finalizeAnnotations(annotationMap),
|
|
};
|
|
}
|
|
|
|
function analyzeDomainSuffixText(value) {
|
|
return analyzeTextListValue(
|
|
value,
|
|
(item) => main.validateDomain(item, true),
|
|
_("At least one valid domain must be specified."),
|
|
{
|
|
normalizeDuplicateValue: (item) => `${item}`.toLowerCase(),
|
|
},
|
|
);
|
|
}
|
|
|
|
function analyzeIpCidrText(value) {
|
|
return analyzeTextListValue(
|
|
value,
|
|
(item) => main.validateSubnet(item),
|
|
_("At least one valid IP or subnet must be specified."),
|
|
{
|
|
normalizeDuplicateValue: (item) => `${item}`.trim(),
|
|
},
|
|
);
|
|
}
|
|
|
|
function getNfqwsOptionArgumentMode(option) {
|
|
if (NFQWS_REQUIRED_ARG_OPTIONS.has(option)) {
|
|
return "required";
|
|
}
|
|
|
|
if (NFQWS_OPTIONAL_ARG_OPTIONS.has(option)) {
|
|
return "optional";
|
|
}
|
|
|
|
if (NFQWS_NO_ARG_OPTIONS.has(option)) {
|
|
return "none";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
function normalizeNfqwsStrategyWhitespace(value) {
|
|
return value ? `${value}`.replace(/\s+/g, " ").trim() : "";
|
|
}
|
|
|
|
function parseNfqwsRuntimeTokens(value) {
|
|
const text = value ? `${value}` : "";
|
|
const tokens = [];
|
|
const matcher = /\S+/g;
|
|
let match;
|
|
|
|
while ((match = matcher.exec(text)) !== null) {
|
|
tokens.push({
|
|
value: match[0],
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
});
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
function normalizeNfqwsStrategyValue(value) {
|
|
const normalized = normalizeNfqwsStrategyWhitespace(value);
|
|
if (!normalized.length) {
|
|
return "";
|
|
}
|
|
|
|
return normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT
|
|
? ZAPRET_DEFAULT_NFQWS_OPT
|
|
: normalized;
|
|
}
|
|
|
|
function getCachedNfqwsRemoteValidation(value) {
|
|
const normalized = normalizeNfqwsStrategyValue(value);
|
|
return normalized.length
|
|
? nfqwsRemoteValidationCache.get(normalized) || null
|
|
: null;
|
|
}
|
|
|
|
function cacheNfqwsRemoteValidation(value, result) {
|
|
const normalized = normalizeNfqwsStrategyValue(value);
|
|
if (!normalized.length) {
|
|
return result;
|
|
}
|
|
|
|
const cached = {
|
|
valid: result && result.valid === true,
|
|
message: result && result.message ? `${result.message}` : "",
|
|
needle: result && result.needle ? `${result.needle}` : "",
|
|
needles:
|
|
result && Array.isArray(result.needles)
|
|
? result.needles.filter(Boolean).map((item) => `${item}`)
|
|
: result && result.needle
|
|
? [`${result.needle}`]
|
|
: [],
|
|
};
|
|
|
|
nfqwsRemoteValidationCache.set(normalized, cached);
|
|
return cached;
|
|
}
|
|
|
|
function buildNfqwsRemoteValidationFallback(error) {
|
|
const message =
|
|
error && error.message
|
|
? `${error.message}`
|
|
: _("Unable to validate the NFQWS strategy through the backend parser.");
|
|
|
|
return {
|
|
valid: false,
|
|
message: _("Backend validation failed: %s").format(message),
|
|
needle: "",
|
|
needles: [],
|
|
};
|
|
}
|
|
|
|
function validateNfqwsStrategyRemotely(value) {
|
|
const normalized = normalizeNfqwsStrategyValue(value);
|
|
|
|
if (!normalized.length) {
|
|
return Promise.resolve({
|
|
valid: true,
|
|
message: "",
|
|
needle: "",
|
|
needles: [],
|
|
});
|
|
}
|
|
|
|
if (nfqwsRemoteValidationCache.has(normalized)) {
|
|
return Promise.resolve(nfqwsRemoteValidationCache.get(normalized));
|
|
}
|
|
|
|
if (nfqwsRemoteValidationInflight.has(normalized)) {
|
|
return nfqwsRemoteValidationInflight.get(normalized);
|
|
}
|
|
|
|
const validationTask = fs
|
|
.exec(NFQWS_VALIDATION_COMMAND, [
|
|
"validate_nfqws_strategy_json",
|
|
normalized,
|
|
])
|
|
.then((result) => {
|
|
const payload = JSON.parse(
|
|
(result && result.stdout ? result.stdout : "{}").trim() || "{}",
|
|
);
|
|
return cacheNfqwsRemoteValidation(normalized, {
|
|
valid: payload.valid === true,
|
|
message: payload.message || "",
|
|
needle: payload.needle || "",
|
|
needles: Array.isArray(payload.needles)
|
|
? payload.needles.filter(Boolean)
|
|
: payload.needle
|
|
? [payload.needle]
|
|
: [],
|
|
});
|
|
})
|
|
.catch((error) =>
|
|
cacheNfqwsRemoteValidation(
|
|
normalized,
|
|
buildNfqwsRemoteValidationFallback(error),
|
|
),
|
|
)
|
|
.finally(() => {
|
|
nfqwsRemoteValidationInflight.delete(normalized);
|
|
});
|
|
|
|
nfqwsRemoteValidationInflight.set(normalized, validationTask);
|
|
return validationTask;
|
|
}
|
|
|
|
function getNfqwsForbiddenTokenInfo(token, index) {
|
|
const configFileMessage = _(
|
|
"External nfqws config files bypass Podkop Plus queue management and explicit validation.",
|
|
);
|
|
const hostSelectionMessage = _(
|
|
"Resource selection by hostname inside nfqws is not supported here; sing-box selects resources before NFQUEUE.",
|
|
);
|
|
const ipSelectionMessage = _(
|
|
"Resource selection by IP or CIDR inside nfqws is not supported here; sing-box selects resources before NFQUEUE.",
|
|
);
|
|
const placeholderMessage = _(
|
|
"Zapret hostlist templates are not supported here because Podkop Plus does not expand them for per-rule NFQWS strategies.",
|
|
);
|
|
const queueMessage = _(
|
|
"The NFQUEUE number is assigned by Podkop Plus for each rule and must not be overridden here.",
|
|
);
|
|
const fwmarkMessage = _(
|
|
"The desync fwmark is managed by Podkop Plus for loop prevention and must not be overridden here.",
|
|
);
|
|
const daemonMessage = _(
|
|
"Podkop Plus manages the nfqws process lifecycle itself, so daemon mode is not allowed here.",
|
|
);
|
|
const dryRunMessage = _(
|
|
"This field must start a working nfqws strategy; --dry-run exits immediately and is not allowed here.",
|
|
);
|
|
const versionMessage = _(
|
|
"This field must start a working nfqws strategy; --version exits immediately and is not allowed here.",
|
|
);
|
|
|
|
if (index === 0 && (token.startsWith("@") || token.startsWith("$"))) {
|
|
return {
|
|
reason: configFileMessage,
|
|
captureNextValue: false,
|
|
};
|
|
}
|
|
|
|
if (token === "<HOSTLIST>" || token === "<HOSTLIST_NOAUTO>") {
|
|
return {
|
|
reason: placeholderMessage,
|
|
captureNextValue: false,
|
|
};
|
|
}
|
|
|
|
if (
|
|
token === "--hostlist" ||
|
|
token.startsWith("--hostlist=") ||
|
|
token === "--hostlist-domains" ||
|
|
token.startsWith("--hostlist-domains=") ||
|
|
token === "--hostlist-exclude" ||
|
|
token.startsWith("--hostlist-exclude=") ||
|
|
token === "--hostlist-exclude-domains" ||
|
|
token.startsWith("--hostlist-exclude-domains=") ||
|
|
token === "--hostlist-auto" ||
|
|
token.startsWith("--hostlist-auto=") ||
|
|
token === "--hostlist-auto-fail-threshold" ||
|
|
token.startsWith("--hostlist-auto-fail-threshold=") ||
|
|
token === "--hostlist-auto-fail-time" ||
|
|
token.startsWith("--hostlist-auto-fail-time=") ||
|
|
token === "--hostlist-auto-retrans-threshold" ||
|
|
token.startsWith("--hostlist-auto-retrans-threshold=") ||
|
|
token === "--hostlist-auto-debug" ||
|
|
token.startsWith("--hostlist-auto-debug=")
|
|
) {
|
|
return {
|
|
reason: hostSelectionMessage,
|
|
captureNextValue: !token.includes("="),
|
|
};
|
|
}
|
|
|
|
if (
|
|
token === "--ipset" ||
|
|
token.startsWith("--ipset=") ||
|
|
token === "--ipset-ip" ||
|
|
token.startsWith("--ipset-ip=") ||
|
|
token === "--ipset-exclude" ||
|
|
token.startsWith("--ipset-exclude=") ||
|
|
token === "--ipset-exclude-ip" ||
|
|
token.startsWith("--ipset-exclude-ip=")
|
|
) {
|
|
return {
|
|
reason: ipSelectionMessage,
|
|
captureNextValue: !token.includes("="),
|
|
};
|
|
}
|
|
|
|
if (token === "--qnum" || token.startsWith("--qnum=")) {
|
|
return {
|
|
reason: queueMessage,
|
|
captureNextValue: !token.includes("="),
|
|
};
|
|
}
|
|
|
|
if (
|
|
token === "--dpi-desync-fwmark" ||
|
|
token.startsWith("--dpi-desync-fwmark=")
|
|
) {
|
|
return {
|
|
reason: fwmarkMessage,
|
|
captureNextValue: !token.includes("="),
|
|
};
|
|
}
|
|
|
|
if (token === "--daemon") {
|
|
return {
|
|
reason: daemonMessage,
|
|
captureNextValue: false,
|
|
};
|
|
}
|
|
|
|
if (token === "--dry-run") {
|
|
return {
|
|
reason: dryRunMessage,
|
|
captureNextValue: false,
|
|
};
|
|
}
|
|
|
|
if (token === "--version") {
|
|
return {
|
|
reason: versionMessage,
|
|
captureNextValue: false,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildNfqwsLocalAnalysis(value) {
|
|
const text = value ? `${value}` : "";
|
|
if (!text.trim().length) {
|
|
return {
|
|
valid: false,
|
|
message: _("NFQWS strategy cannot be empty"),
|
|
annotations: [],
|
|
};
|
|
}
|
|
|
|
if (text.trim() === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) {
|
|
return { valid: true, message: "", annotations: [] };
|
|
}
|
|
|
|
const tokens = parseNfqwsRuntimeTokens(text);
|
|
const annotationMap = new Map();
|
|
const errors = [];
|
|
|
|
for (let index = 0; index < tokens.length; ) {
|
|
const token = tokens[index];
|
|
const bareToken = token.value.includes("=")
|
|
? token.value.slice(0, token.value.indexOf("="))
|
|
: token.value;
|
|
const nextToken = tokens[index + 1] || null;
|
|
|
|
const forbidden = getNfqwsForbiddenTokenInfo(token.value, index);
|
|
if (forbidden) {
|
|
addAnnotationIssue(annotationMap, token, forbidden.reason);
|
|
|
|
let displayToken = token.value;
|
|
|
|
if (
|
|
forbidden.captureNextValue &&
|
|
nextToken &&
|
|
!nextToken.value.startsWith("--")
|
|
) {
|
|
addAnnotationIssue(annotationMap, nextToken, forbidden.reason);
|
|
displayToken = `${displayToken} ${nextToken.value}`;
|
|
index += 2;
|
|
} else {
|
|
index += 1;
|
|
}
|
|
|
|
errors.push(`${displayToken}: ${forbidden.reason}`);
|
|
continue;
|
|
}
|
|
|
|
if (!token.value.startsWith("--")) {
|
|
const reason = _(
|
|
"Unexpected standalone token. Use explicit flags such as --name or --name=value.",
|
|
);
|
|
addAnnotationIssue(annotationMap, token, reason);
|
|
errors.push(`${token.value}: ${reason}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const mode = getNfqwsOptionArgumentMode(bareToken);
|
|
if (mode === "unknown") {
|
|
const reason = _("Unknown NFQWS flag.");
|
|
addAnnotationIssue(annotationMap, token, reason);
|
|
errors.push(`${token.value}: ${reason}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (mode === "none") {
|
|
if (token.value.includes("=")) {
|
|
const reason = _("This flag does not accept a value.");
|
|
addAnnotationIssue(annotationMap, token, reason);
|
|
errors.push(`${token.value}: ${reason}`);
|
|
}
|
|
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (mode === "optional") {
|
|
if (
|
|
nextToken &&
|
|
!token.value.includes("=") &&
|
|
!nextToken.value.startsWith("--")
|
|
) {
|
|
const reason = _(
|
|
"Optional values must be attached with '=' here; a separate token would be ignored by nfqws.",
|
|
);
|
|
addAnnotationIssue(annotationMap, token, reason);
|
|
addAnnotationIssue(annotationMap, nextToken, reason);
|
|
errors.push(`${token.value} ${nextToken.value}: ${reason}`);
|
|
index += 2;
|
|
} else {
|
|
index += 1;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!token.value.includes("=")) {
|
|
if (!nextToken || nextToken.value.startsWith("--")) {
|
|
const reason = _("This option requires a value.");
|
|
addAnnotationIssue(annotationMap, token, reason);
|
|
errors.push(`${token.value}: ${reason}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
index += 2;
|
|
continue;
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
if (!errors.length) {
|
|
return { valid: true, message: "", annotations: [] };
|
|
}
|
|
|
|
return {
|
|
valid: false,
|
|
message: [getValidationHeaderText(), ...errors].join("\n"),
|
|
annotations: finalizeAnnotations(annotationMap),
|
|
};
|
|
}
|
|
|
|
function addNfqwsRemoteValidationNeedleAnnotations(
|
|
annotationMap,
|
|
tokens,
|
|
remoteValidation,
|
|
needle,
|
|
) {
|
|
if (!needle.length) {
|
|
return;
|
|
}
|
|
|
|
let matched = false;
|
|
|
|
tokens.forEach((token) => {
|
|
const tokenValue = token.value || "";
|
|
const optionMatch =
|
|
needle.startsWith("--") &&
|
|
(tokenValue === needle || tokenValue.startsWith(`${needle}=`));
|
|
const valueMatch =
|
|
tokenValue === needle ||
|
|
tokenValue.endsWith(`=${needle}`) ||
|
|
(!needle.startsWith("--") && tokenValue.includes(`=${needle},`)) ||
|
|
(!needle.startsWith("--") && tokenValue.endsWith(`=${needle}`));
|
|
|
|
if (optionMatch || valueMatch) {
|
|
addAnnotationIssue(annotationMap, token, remoteValidation.message);
|
|
matched = true;
|
|
}
|
|
});
|
|
|
|
if (matched) {
|
|
return;
|
|
}
|
|
|
|
if (needle.startsWith("--")) {
|
|
tokens
|
|
.filter((token) => token.value && token.value.startsWith(needle))
|
|
.forEach((token) =>
|
|
addAnnotationIssue(annotationMap, token, remoteValidation.message),
|
|
);
|
|
}
|
|
}
|
|
|
|
function addNfqwsRemoteValidationAnnotations(
|
|
annotationMap,
|
|
tokens,
|
|
remoteValidation,
|
|
) {
|
|
const needles =
|
|
remoteValidation &&
|
|
Array.isArray(remoteValidation.needles) &&
|
|
remoteValidation.needles.length
|
|
? remoteValidation.needles.map((needle) => `${needle}`)
|
|
: remoteValidation && remoteValidation.needle
|
|
? [`${remoteValidation.needle}`]
|
|
: [];
|
|
|
|
needles.forEach((needle) =>
|
|
addNfqwsRemoteValidationNeedleAnnotations(
|
|
annotationMap,
|
|
tokens,
|
|
remoteValidation,
|
|
needle,
|
|
),
|
|
);
|
|
}
|
|
|
|
function analyzeNfqwsStrategy(value) {
|
|
const localAnalysis = buildNfqwsLocalAnalysis(value);
|
|
if (!localAnalysis.valid) {
|
|
return localAnalysis;
|
|
}
|
|
|
|
const remoteValidation = getCachedNfqwsRemoteValidation(value);
|
|
if (!remoteValidation || remoteValidation.valid) {
|
|
return localAnalysis;
|
|
}
|
|
|
|
const text = value ? `${value}` : "";
|
|
const tokens = parseNfqwsRuntimeTokens(text);
|
|
const annotationMap = new Map();
|
|
|
|
localAnalysis.annotations.forEach((annotation) =>
|
|
addAnnotationIssue(annotationMap, annotation, annotation.message),
|
|
);
|
|
addNfqwsRemoteValidationAnnotations(annotationMap, tokens, remoteValidation);
|
|
|
|
return {
|
|
valid: false,
|
|
message: [getValidationHeaderText(), remoteValidation.message].join("\n"),
|
|
annotations: finalizeAnnotations(annotationMap),
|
|
};
|
|
}
|
|
|
|
function configureTextareaOption(option, analyzer) {
|
|
const originalRenderWidget = option.renderWidget;
|
|
|
|
option.renderWidget = function (section_id, option_index, cfgvalue) {
|
|
const node = originalRenderWidget.call(
|
|
this,
|
|
section_id,
|
|
option_index,
|
|
cfgvalue,
|
|
);
|
|
const textarea =
|
|
node && typeof node.querySelector === "function"
|
|
? node.querySelector("textarea")
|
|
: node;
|
|
|
|
if (textarea) {
|
|
applyTextareaInputAttributes(textarea);
|
|
if (typeof analyzer === "function") {
|
|
attachAnnotatedTextarea(textarea, analyzer);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
};
|
|
}
|
|
|
|
function addDynamicConditionField(section, config) {
|
|
const o = section.taboption(
|
|
"conditions",
|
|
form.DynamicList,
|
|
config.key,
|
|
config.label,
|
|
config.description,
|
|
);
|
|
|
|
o.modalonly = true;
|
|
if (config.dynamicValidate) {
|
|
o.validate = config.dynamicValidate;
|
|
}
|
|
|
|
o.load = function (section_id) {
|
|
const values = getConfigListValues(section_id, config.key);
|
|
if (values.length) {
|
|
return values;
|
|
}
|
|
|
|
const legacyText = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`);
|
|
return legacyText ? main.parseValueList(legacyText) : [];
|
|
};
|
|
|
|
o.write = function (section_id, value) {
|
|
writeListOption(section_id, config.key, value);
|
|
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`);
|
|
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`);
|
|
};
|
|
}
|
|
|
|
function addTextConditionField(section, config) {
|
|
const o = section.taboption(
|
|
"conditions",
|
|
form.TextValue,
|
|
`${config.key}_text`,
|
|
config.label,
|
|
config.description,
|
|
);
|
|
|
|
o.rows = 8;
|
|
o.wrap = "soft";
|
|
o.textarea = true;
|
|
o.modalonly = true;
|
|
if (config.textAnalyze) {
|
|
o.validate = function (_section_id, value) {
|
|
const analysis = config.textAnalyze(value);
|
|
return analysis.valid ? true : analysis.message;
|
|
};
|
|
} else if (config.textValidate) {
|
|
o.validate = config.textValidate;
|
|
}
|
|
configureTextareaOption(o, config.textAnalyze);
|
|
|
|
o.load = function (section_id) {
|
|
const textValue = uci.get(UCI_PACKAGE, section_id, `${config.key}_text`);
|
|
if (textValue) {
|
|
return textValue;
|
|
}
|
|
|
|
return valuesToText(uci.get(UCI_PACKAGE, section_id, config.key));
|
|
};
|
|
|
|
o.write = function (section_id, value) {
|
|
const normalized = value ? `${value}`.trim() : "";
|
|
|
|
if (normalized.length) {
|
|
uci.set(UCI_PACKAGE, section_id, `${config.key}_text`, normalized);
|
|
} else {
|
|
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text`);
|
|
}
|
|
|
|
uci.unset(UCI_PACKAGE, section_id, config.key);
|
|
uci.unset(UCI_PACKAGE, section_id, `${config.key}_text_mode`);
|
|
};
|
|
}
|
|
|
|
function loadRulesetValues(option) {
|
|
delete option.keylist;
|
|
delete option.vallist;
|
|
|
|
Object.entries(main.DOMAIN_LIST_OPTIONS).forEach(([key, label]) => {
|
|
option.value(key, _(label));
|
|
});
|
|
}
|
|
|
|
function isBuiltinRulesetValue(value) {
|
|
return Object.prototype.hasOwnProperty.call(main.DOMAIN_LIST_OPTIONS, value);
|
|
}
|
|
|
|
function validateCustomRulesetReference(value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
const validation = main.validateUrl(value);
|
|
if (validation.valid && value.endsWith(".srs")) {
|
|
return true;
|
|
}
|
|
|
|
return _("Rule set must be a direct .srs URL or a local .srs path");
|
|
}
|
|
|
|
if (value.startsWith("/")) {
|
|
const validation = main.validatePath(value);
|
|
if (validation.valid && value.endsWith(".srs")) {
|
|
return true;
|
|
}
|
|
|
|
return _("Rule set must be a direct .srs URL or a local .srs path");
|
|
}
|
|
|
|
return _("Rule set must be a direct .srs URL or a local .srs path");
|
|
}
|
|
|
|
function getRulesetReferences(section_id) {
|
|
return getConfigListValues(section_id, "rule_set");
|
|
}
|
|
|
|
function getBuiltInRulesetReferences(section_id) {
|
|
const communityValues = getConfigListValues(
|
|
section_id,
|
|
"community_lists",
|
|
).filter((value) => isBuiltinRulesetValue(value));
|
|
const legacyBuiltIns = getRulesetReferences(section_id).filter((value) =>
|
|
isBuiltinRulesetValue(value),
|
|
);
|
|
|
|
const values = communityValues.length > 0 ? communityValues : legacyBuiltIns;
|
|
|
|
return values.filter(
|
|
(value, index, values) =>
|
|
isBuiltinRulesetValue(value) && values.indexOf(value) === index,
|
|
);
|
|
}
|
|
|
|
function getCustomRulesetReferences(section_id) {
|
|
return getRulesetReferences(section_id).filter(
|
|
(value) => !isBuiltinRulesetValue(value),
|
|
);
|
|
}
|
|
|
|
function createSectionContent(section) {
|
|
let o;
|
|
|
|
section.tab("settings", _("Settings"));
|
|
section.tab("conditions", _("Conditions"));
|
|
|
|
o = section.taboption("settings", form.Flag, "enabled", _("Enable"));
|
|
o.default = "1";
|
|
o.rmempty = false;
|
|
o.editable = true;
|
|
o.width = "6rem";
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.DummyValue,
|
|
"_action_display",
|
|
_("Action"),
|
|
);
|
|
o.modalonly = false;
|
|
o.rawhtml = true;
|
|
o.load = function () {
|
|
return ensureZapretAvailabilityLoaded();
|
|
};
|
|
o.cfgvalue = function (section_id) {
|
|
return getRuleActionDisplayMarkup(section_id);
|
|
};
|
|
o.textvalue = function (section_id) {
|
|
return getRuleActionDisplayValue(section_id);
|
|
};
|
|
o.width = "7rem";
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Value,
|
|
"label",
|
|
_("Section name"),
|
|
_("Visible name of this section"),
|
|
);
|
|
o.rmempty = false;
|
|
o.modalonly = true;
|
|
o.load = function (section_id) {
|
|
return uci.get(UCI_PACKAGE, section_id, "label") || section_id;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.ListValue,
|
|
"action",
|
|
_("Action"),
|
|
_("What Podkop Plus should do when this section matches"),
|
|
);
|
|
populateActionOptionValues(o);
|
|
o.default = "proxy";
|
|
o.rmempty = false;
|
|
o.modalonly = true;
|
|
o.load = function () {
|
|
return ensureZapretAvailabilityLoaded().then(() => {
|
|
populateActionOptionValues(this);
|
|
});
|
|
};
|
|
{
|
|
const originalRenderWidget = o.renderWidget;
|
|
o.renderWidget = function (section_id, option_index, cfgvalue) {
|
|
const node = originalRenderWidget.call(
|
|
this,
|
|
section_id,
|
|
option_index,
|
|
cfgvalue,
|
|
);
|
|
disableUnavailableZapretOption(node);
|
|
return node;
|
|
};
|
|
}
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.TextValue,
|
|
"nfqws_opt",
|
|
_("NFQWS Strategy"),
|
|
);
|
|
o.depends("action", "zapret");
|
|
o.rows = 6;
|
|
o.wrap = "soft";
|
|
o.textarea = true;
|
|
o.modalonly = true;
|
|
o.load = function (section_id) {
|
|
const value = uci.get(UCI_PACKAGE, section_id, "nfqws_opt");
|
|
if (!value || value === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT) {
|
|
return ZAPRET_DEFAULT_NFQWS_OPT;
|
|
}
|
|
|
|
return value;
|
|
};
|
|
o.write = function (section_id, value) {
|
|
const normalized = normalizeNfqwsStrategyValue(value);
|
|
const nextValue =
|
|
!normalized.length || normalized === ZAPRET_LEGACY_DEFAULT_NFQWS_OPT
|
|
? ZAPRET_DEFAULT_NFQWS_OPT
|
|
: normalized;
|
|
|
|
uci.set(UCI_PACKAGE, section_id, "nfqws_opt", nextValue);
|
|
};
|
|
o.validate = function (_section_id, value) {
|
|
const analysis = analyzeNfqwsStrategy(value);
|
|
return analysis.valid ? true : analysis.message;
|
|
};
|
|
configureTextareaOption(o, analyzeNfqwsStrategy);
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.ListValue,
|
|
"proxy_config_type",
|
|
_("Connection Type"),
|
|
_("How to configure the proxy connection for this section"),
|
|
);
|
|
o.value("url", _("Connection URL"));
|
|
o.value("selector", "Selector");
|
|
o.value("urltest", "URLTest");
|
|
o.value("outbound", _("Outbound JSON"));
|
|
o.value("interface", _("Interface"));
|
|
o.default = "url";
|
|
o.rmempty = false;
|
|
o.depends("action", "proxy");
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.TextValue,
|
|
"proxy_string",
|
|
_("Connection"),
|
|
_("vless://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links"),
|
|
);
|
|
o.depends({ action: "proxy", proxy_config_type: "url" });
|
|
o.rows = 5;
|
|
o.wrap = "soft";
|
|
o.textarea = true;
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateProxyUrl(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
configureTextareaOption(o);
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.TextValue,
|
|
"outbound_json",
|
|
_("Outbound JSON"),
|
|
_("Enter a complete sing-box outbound object"),
|
|
);
|
|
o.depends({ action: "proxy", proxy_config_type: "outbound" });
|
|
o.rows = 10;
|
|
o.wrap = "soft";
|
|
o.textarea = true;
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateOutboundJson(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
configureTextareaOption(o);
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.DynamicList,
|
|
"selector_proxy_links",
|
|
_("Selector connections"),
|
|
_("A manual group of proxy URLs for this section"),
|
|
);
|
|
o.depends({ action: "proxy", proxy_config_type: "selector" });
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateProxyUrl(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.DynamicList,
|
|
"urltest_proxy_links",
|
|
_("URLTest connections"),
|
|
_("A latency-tested group of proxy URLs for this section"),
|
|
);
|
|
o.depends({ action: "proxy", proxy_config_type: "urltest" });
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateProxyUrl(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.ListValue,
|
|
"urltest_check_interval",
|
|
_("URLTest interval"),
|
|
);
|
|
o.value("30s", _("Every 30 seconds"));
|
|
o.value("1m", _("Every minute"));
|
|
o.value("3m", _("Every 3 minutes"));
|
|
o.value("5m", _("Every 5 minutes"));
|
|
o.default = "3m";
|
|
o.rmempty = false;
|
|
o.depends({ action: "proxy", proxy_config_type: "urltest" });
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Value,
|
|
"urltest_tolerance",
|
|
_("URLTest tolerance"),
|
|
_("Maximum response time delta in milliseconds"),
|
|
);
|
|
o.default = "50";
|
|
o.rmempty = false;
|
|
o.depends({ action: "proxy", proxy_config_type: "urltest" });
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const parsed = parseFloat(value);
|
|
if (
|
|
/^[0-9]+$/.test(value) &&
|
|
!isNaN(parsed) &&
|
|
isFinite(parsed) &&
|
|
parsed >= 50 &&
|
|
parsed <= 1000
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return _("Must be a number in the range of 50 - 1000");
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Value,
|
|
"urltest_testing_url",
|
|
_("URLTest URL"),
|
|
);
|
|
o.value(
|
|
"https://www.gstatic.com/generate_204",
|
|
"https://www.gstatic.com/generate_204 (Google)",
|
|
);
|
|
o.value(
|
|
"https://cp.cloudflare.com/generate_204",
|
|
"https://cp.cloudflare.com/generate_204 (Cloudflare)",
|
|
);
|
|
o.value("https://captive.apple.com", "https://captive.apple.com (Apple)");
|
|
o.value(
|
|
"https://connectivity-check.ubuntu.com",
|
|
"https://connectivity-check.ubuntu.com (Ubuntu)",
|
|
);
|
|
o.default = "https://www.gstatic.com/generate_204";
|
|
o.rmempty = false;
|
|
o.depends({ action: "proxy", proxy_config_type: "urltest" });
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateUrl(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Flag,
|
|
"enable_udp_over_tcp",
|
|
_("UDP over TCP"),
|
|
_("Applicable for SOCKS and Shadowsocks links"),
|
|
);
|
|
o.default = "0";
|
|
o.rmempty = false;
|
|
o.depends("action", "proxy");
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
widgets.DeviceSelect,
|
|
"interface",
|
|
_("Interface"),
|
|
_("Use a network interface as the outbound for this section"),
|
|
);
|
|
o.noaliases = true;
|
|
o.nobridges = false;
|
|
o.noinactive = false;
|
|
o.depends({ action: "proxy", proxy_config_type: "interface" });
|
|
o.modalonly = true;
|
|
o.filter = function (_section_id, value) {
|
|
const blockedInterfaces = [
|
|
"br-lan",
|
|
"eth0",
|
|
"eth1",
|
|
"wan",
|
|
"phy0-ap0",
|
|
"phy1-ap0",
|
|
"pppoe-wan",
|
|
"lan",
|
|
];
|
|
|
|
if (blockedInterfaces.includes(value)) {
|
|
return false;
|
|
}
|
|
|
|
const device = this.devices.find((dev) => dev.getName() === value);
|
|
if (!device) {
|
|
return true;
|
|
}
|
|
|
|
const type = device.getType();
|
|
const isWireless =
|
|
type === "wifi" || type === "wireless" || type.indexOf("wlan") >= 0;
|
|
|
|
return !isWireless;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Flag,
|
|
"domain_resolver_enabled",
|
|
_("Resolver for interface outbound"),
|
|
_("Enable a dedicated DNS resolver when this section uses an interface"),
|
|
);
|
|
o.default = "0";
|
|
o.rmempty = false;
|
|
o.depends({ action: "proxy", proxy_config_type: "interface" });
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.ListValue,
|
|
"domain_resolver_dns_type",
|
|
_("DNS protocol"),
|
|
);
|
|
o.value("doh", _("DNS over HTTPS (DoH)"));
|
|
o.value("dot", _("DNS over TLS (DoT)"));
|
|
o.value("udp", "UDP");
|
|
o.default = "udp";
|
|
o.rmempty = false;
|
|
o.depends({
|
|
action: "proxy",
|
|
proxy_config_type: "interface",
|
|
domain_resolver_enabled: "1",
|
|
});
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Value,
|
|
"domain_resolver_dns_server",
|
|
_("DNS server"),
|
|
);
|
|
Object.entries(main.DNS_SERVER_OPTIONS).forEach(([key, label]) => {
|
|
o.value(key, _(label));
|
|
});
|
|
o.default = "8.8.8.8";
|
|
o.rmempty = false;
|
|
o.depends({
|
|
action: "proxy",
|
|
proxy_config_type: "interface",
|
|
domain_resolver_enabled: "1",
|
|
});
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
const validation = main.validateDNS(value);
|
|
return validation.valid ? true : validation.message;
|
|
};
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Flag,
|
|
"mixed_proxy_enabled",
|
|
_("Enable Mixed Proxy"),
|
|
_("Expose this section as a local HTTP+SOCKS proxy"),
|
|
);
|
|
o.default = "0";
|
|
o.rmempty = false;
|
|
o.depends("action", "proxy");
|
|
o.modalonly = true;
|
|
|
|
o = section.taboption(
|
|
"settings",
|
|
form.Value,
|
|
"mixed_proxy_port",
|
|
_("Mixed Proxy Port"),
|
|
_("Port for the local mixed proxy of this section"),
|
|
);
|
|
o.rmempty = false;
|
|
o.depends({ action: "proxy", mixed_proxy_enabled: "1" });
|
|
o.modalonly = true;
|
|
o.validate = function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return _("Port cannot be empty");
|
|
}
|
|
|
|
const parsed = parseInt(value, 10);
|
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= 65535) {
|
|
return true;
|
|
}
|
|
|
|
return _("Invalid port number. Must be between 1 and 65535");
|
|
};
|
|
|
|
addTextConditionField(section, {
|
|
key: "domain_suffix",
|
|
label: _("Domains"),
|
|
description: _("Match domains including all subdomains"),
|
|
textAnalyze: analyzeDomainSuffixText,
|
|
});
|
|
|
|
addTextConditionField(section, {
|
|
key: "ip_cidr",
|
|
label: _("IPs"),
|
|
description: _("Match destination IPs or subnets"),
|
|
textAnalyze: analyzeIpCidrText,
|
|
});
|
|
|
|
addDynamicConditionField(section, {
|
|
key: "domain",
|
|
label: _("Exact full domain"),
|
|
description: _("Match only one exact full domain name"),
|
|
dynamicValidate: function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateDomain(value);
|
|
return validation.valid ? true : validation.message;
|
|
},
|
|
});
|
|
|
|
addDynamicConditionField(section, {
|
|
key: "domain_keyword",
|
|
label: _("Domain keyword"),
|
|
description: _("Match domains containing a substring"),
|
|
dynamicValidate: validateKeyword,
|
|
});
|
|
|
|
addDynamicConditionField(section, {
|
|
key: "domain_regex",
|
|
label: _("Domain regex"),
|
|
description: _("Match domains using a regular expression"),
|
|
dynamicValidate: validateRegex,
|
|
});
|
|
|
|
addDynamicConditionField(section, {
|
|
key: "source_ip_cidr",
|
|
label: _("Source IPs"),
|
|
description: _("Match source IPs or subnets"),
|
|
dynamicValidate: function (_section_id, value) {
|
|
if (!value || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const validation = main.validateSubnet(value);
|
|
return validation.valid ? true : validation.message;
|
|
},
|
|
});
|
|
|
|
const builtInRulesetOption = section.taboption(
|
|
"conditions",
|
|
form.DynamicList,
|
|
"community_lists",
|
|
_("Built-in rule sets"),
|
|
_("Select a predefined list for routing"),
|
|
);
|
|
builtInRulesetOption.modalonly = true;
|
|
builtInRulesetOption.placeholder = _("Service list");
|
|
builtInRulesetOption.load = function (section_id) {
|
|
loadRulesetValues(this);
|
|
return getBuiltInRulesetReferences(section_id);
|
|
};
|
|
let isProcessingBuiltIns = false;
|
|
builtInRulesetOption.onchange = function (_ev, section_id, value) {
|
|
if (isProcessingBuiltIns) {
|
|
return;
|
|
}
|
|
|
|
isProcessingBuiltIns = true;
|
|
|
|
try {
|
|
const values = Array.isArray(value)
|
|
? value.filter(Boolean)
|
|
: value
|
|
? [value]
|
|
: [];
|
|
let newValues = [...values];
|
|
const notifications = [];
|
|
|
|
const selectedRegionalOptions = main.REGIONAL_OPTIONS.filter((opt) =>
|
|
newValues.includes(opt),
|
|
);
|
|
|
|
if (selectedRegionalOptions.length > 1) {
|
|
const lastSelected =
|
|
selectedRegionalOptions[selectedRegionalOptions.length - 1];
|
|
const removedRegions = selectedRegionalOptions.slice(0, -1);
|
|
newValues = newValues.filter(
|
|
(v) => v === lastSelected || !main.REGIONAL_OPTIONS.includes(v),
|
|
);
|
|
notifications.push(
|
|
E("p", {}, [
|
|
E("strong", {}, _("Regional options cannot be used together")),
|
|
E("br"),
|
|
_(
|
|
"Warning: %s cannot be used together with %s. Previous selections have been removed.",
|
|
).format(removedRegions.join(", "), lastSelected),
|
|
]),
|
|
);
|
|
}
|
|
|
|
if (newValues.includes("russia_inside")) {
|
|
const removedServices = newValues.filter(
|
|
(v) => !main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
|
|
);
|
|
if (removedServices.length > 0) {
|
|
newValues = newValues.filter((v) =>
|
|
main.ALLOWED_WITH_RUSSIA_INSIDE.includes(v),
|
|
);
|
|
notifications.push(
|
|
E("p", { class: "alert-message warning" }, [
|
|
E("strong", {}, _("Russia inside restrictions")),
|
|
E("br"),
|
|
_(
|
|
"Warning: Russia inside can only be used with %s. %s already in Russia inside and have been removed from selection.",
|
|
).format(
|
|
main.ALLOWED_WITH_RUSSIA_INSIDE.map(
|
|
(key) => main.DOMAIN_LIST_OPTIONS[key],
|
|
)
|
|
.filter((label) => label !== "Russia inside")
|
|
.join(", "),
|
|
removedServices.join(", "),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
JSON.stringify(newValues.slice().sort()) !==
|
|
JSON.stringify(values.slice().sort())
|
|
) {
|
|
this.getUIElement(section_id).setValue(newValues);
|
|
}
|
|
|
|
notifications.forEach((notification) =>
|
|
ui.addNotification(null, notification),
|
|
);
|
|
} finally {
|
|
isProcessingBuiltIns = false;
|
|
}
|
|
};
|
|
|
|
const ruleSetOption = section.taboption(
|
|
"conditions",
|
|
form.DynamicList,
|
|
"rule_set",
|
|
_("Rule sets"),
|
|
_("Add URLs or local paths to .srs lists"),
|
|
);
|
|
ruleSetOption.modalonly = true;
|
|
ruleSetOption.load = function (section_id) {
|
|
return getCustomRulesetReferences(section_id);
|
|
};
|
|
ruleSetOption.validate = function (_section_id, value) {
|
|
return validateCustomRulesetReference(value);
|
|
};
|
|
}
|
|
|
|
const EntryPoint = {
|
|
createSectionContent,
|
|
};
|
|
|
|
return baseclass.extend(EntryPoint);
|