feat(settings): add Block tab for JSON subscription routing rules (#6466)

* feat(settings): add Block tab for JSON subscription routing rules

Expose the existing blackhole outbound in the subscription formats UI so
operators can add block domain/IP rules without editing subJsonRules by
hand. Scope Direct/Block helpers by outboundTag so the tabs keep separate
rule objects, and keep block rules ahead of direct for Xray match order.

* fix(settings): preserve rule order and clear foreign leftovers

Stop sorting the whole subJsonRules array on every write. Prepend block
defaults only when enabling Block. Clearing the last managed tag also
drops foreign-tag leftovers so the panel can reach an empty setting.
Fixes oxfmt on SubscriptionFormatsTab.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
mrchatam
2026-09-12 12:44:56 +03:30
committed by GitHub
parent 51e0afdd90
commit b332d88438
14 changed files with 124 additions and 46 deletions
@@ -8,6 +8,7 @@ import {
RocketOutlined,
SendOutlined,
SettingOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
@@ -31,10 +32,17 @@ const DEFAULT_MUX = {
xudpConcurrency: 16,
xudpProxyUDP443: 'reject',
};
const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
type SubJsonRule = { type: string; outboundTag: string; domain?: string[]; ip?: string[] };
const DEFAULT_DIRECT_RULES: SubJsonRule[] = [
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
];
const DEFAULT_BLOCK_RULES: SubJsonRule[] = [
{ type: 'field', outboundTag: 'block', domain: ['geosite:category-ads-all'] },
];
const BLOCK_IP_RULE: SubJsonRule = { type: 'field', outboundTag: 'block', ip: [] };
const directIPsOptions = [
{ label: 'Private IP', value: 'geoip:private' },
@@ -57,6 +65,10 @@ const directDomainsOptions = [
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
];
const blockDomainsOptions = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
];
function readJson<T>(raw: string, fallback: T): T {
try {
@@ -67,6 +79,11 @@ function readJson<T>(raw: string, fallback: T): T {
}
}
function readRules(raw: string): SubJsonRule[] {
const parsed = readJson<unknown>(raw, null);
return Array.isArray(parsed) ? (parsed as SubJsonRule[]) : [];
}
export default function SubscriptionFormatsTab({
allSetting,
updateSetting,
@@ -75,7 +92,6 @@ export default function SubscriptionFormatsTab({
const { isMobile } = useMediaQuery();
const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== '';
const muxObj = useMemo(
() =>
@@ -92,57 +108,48 @@ export default function SubscriptionFormatsTab({
updateSetting({ subJsonMux: JSON.stringify(next) });
}
const ruleArray = useMemo(() => {
if (!directEnabled) return null;
return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
}, [allSetting.subJsonRules, directEnabled]);
const ruleArray = useMemo(() => readRules(allSetting.subJsonRules), [allSetting.subJsonRules]);
const directEnabled = ruleArray.some((r) => r.outboundTag === 'direct');
const blockEnabled = ruleArray.some((r) => r.outboundTag === 'block');
const directIPs = useMemo(() => {
if (!ruleArray) return [];
const ipRule = ruleArray.find((r) => r.ip);
return ipRule?.ip ?? [];
}, [ruleArray]);
const ruleValues = (tag: string, key: 'ip' | 'domain') =>
ruleArray.find((r) => r.outboundTag === tag && r[key])?.[key] ?? [];
const directDomains = useMemo(() => {
if (!ruleArray) return [];
const dRule = ruleArray.find((r) => r.domain);
return dRule?.domain ?? [];
}, [ruleArray]);
function setDirectEnabled(v: boolean) {
updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
function writeRules(rules: SubJsonRule[]) {
updateSetting({ subJsonRules: rules.length > 0 ? JSON.stringify(rules) : '' });
}
function setDirectIPs(value: string[]) {
if (!ruleArray) return;
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.ip);
} else {
let idx = rules.findIndex((r) => r.ip);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[1] });
idx = rules.length - 1;
}
rules[idx] = { ...rules[idx], ip: [...value] };
function setTagEnabled(tag: string, defaults: SubJsonRule[], enabled: boolean) {
const rest = ruleArray.filter((r) => r.outboundTag !== tag);
if (!enabled) {
// Turning off the last managed tag also drops foreign-tag leftovers so
// the panel still has a path back to an empty subJsonRules.
const hasManaged = rest.some((r) => r.outboundTag === 'direct' || r.outboundTag === 'block');
writeRules(hasManaged ? rest : []);
return;
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
// Prepend block defaults so ads match before direct; never re-sort the rest.
writeRules(tag === 'block' ? [...defaults, ...rest] : [...rest, ...defaults]);
}
function setDirectDomains(value: string[]) {
if (!ruleArray) return;
function setRuleValues(
tag: string,
key: 'ip' | 'domain',
template: SubJsonRule,
value: string[],
) {
let rules = [...ruleArray];
if (value.length === 0) {
rules = rules.filter((r) => !r.domain);
rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
} else {
let idx = rules.findIndex((r) => r.domain);
if (idx === -1) {
rules.push({ ...DEFAULT_RULES[0] });
idx = rules.length - 1;
let index = rules.findIndex((r) => r.outboundTag === tag && r[key]);
if (index === -1) {
rules.push({ ...template });
index = rules.length - 1;
}
rules[idx] = { ...rules[idx], domain: [...value] };
rules[index] = { ...rules[index], [key]: [...value] };
}
updateSetting({ subJsonRules: JSON.stringify(rules) });
writeRules(rules);
}
return (
@@ -385,16 +392,19 @@ export default function SubscriptionFormatsTab({
title={t('pages.settings.direct')}
description={t('pages.settings.directDesc')}
>
<Switch checked={directEnabled} onChange={setDirectEnabled} />
<Switch
checked={directEnabled}
onChange={(v) => setTagEnabled('direct', DEFAULT_DIRECT_RULES, v)}
/>
</SettingListItem>
{directEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
value={ruleValues('direct', 'ip')}
style={{ width: '100%' }}
onChange={setDirectIPs}
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
options={directIPsOptions}
/>
</SettingListItem>
@@ -408,9 +418,11 @@ export default function SubscriptionFormatsTab({
>
<Select
mode="tags"
value={directDomains}
value={ruleValues('direct', 'domain')}
style={{ width: '100%' }}
onChange={setDirectDomains}
onChange={(v) =>
setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
}
options={directDomainsOptions}
/>
</SettingListItem>
@@ -419,6 +431,46 @@ export default function SubscriptionFormatsTab({
</>
),
},
{
key: '5',
label: catTabLabel(<StopOutlined />, t('pages.settings.block'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.block')}
description={t('pages.settings.blockDesc')}
>
<Switch
checked={blockEnabled}
onChange={(v) => setTagEnabled('block', DEFAULT_BLOCK_RULES, v)}
/>
</SettingListItem>
{blockEnabled && (
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
<Select
mode="tags"
value={ruleValues('block', 'domain')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
options={blockDomainsOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
<Select
mode="tags"
value={ruleValues('block', 'ip')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
options={directIPsOptions}
/>
</SettingListItem>
</div>
)}
</>
),
},
]}
/>
);