mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 09:14:59 +03:00
feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
|
||||
import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
|
||||
|
||||
import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
|
||||
|
||||
type GeoResponder = (query: URLSearchParams) => unknown;
|
||||
type GeoRoutes = Record<string, GeoResponder>;
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
let activeRoutes: GeoRoutes = {};
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url, window.location.origin);
|
||||
}
|
||||
|
||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = requestUrl(input);
|
||||
const responder = activeRoutes[url.pathname];
|
||||
if (!responder) return realFetch(input, init);
|
||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
|
||||
return Promise.resolve(
|
||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
}
|
||||
|
||||
function activate(routes: GeoRoutes): void {
|
||||
activeRoutes = routes;
|
||||
window.fetch = geoFetch;
|
||||
}
|
||||
|
||||
function deactivate(routes: GeoRoutes): void {
|
||||
if (activeRoutes === routes) activeRoutes = {};
|
||||
}
|
||||
|
||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
||||
const [client] = useState(() => {
|
||||
activate(routes);
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
});
|
||||
useEffect(() => {
|
||||
activate(routes);
|
||||
return () => deactivate(routes);
|
||||
}, [routes]);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
||||
const full = (value: string): GeoEntry => ({ kind: 'full', value });
|
||||
const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
|
||||
const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
|
||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
||||
|
||||
const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
|
||||
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
|
||||
|
||||
const CC_TLDS = [
|
||||
'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch',
|
||||
'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th',
|
||||
'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu',
|
||||
'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk',
|
||||
'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk',
|
||||
'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq',
|
||||
'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg',
|
||||
'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw',
|
||||
'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to',
|
||||
'tt', 'vg', 'vu', 'ws',
|
||||
];
|
||||
|
||||
const AD_HOSTS = [
|
||||
'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch',
|
||||
'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic',
|
||||
'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola',
|
||||
'teads', 'yieldmo', 'zemanta',
|
||||
];
|
||||
|
||||
const CN_BRANDS = [
|
||||
'58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban',
|
||||
'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina',
|
||||
'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu',
|
||||
];
|
||||
|
||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
amazon: [
|
||||
domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'),
|
||||
domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'),
|
||||
domain('cloudfront.net'), full('www.amazon.co.jp'),
|
||||
],
|
||||
apple: [
|
||||
domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'),
|
||||
domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'),
|
||||
],
|
||||
'category-ads': [
|
||||
domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'),
|
||||
domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'),
|
||||
],
|
||||
'category-ads-all': [
|
||||
domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
|
||||
domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'),
|
||||
keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
||||
],
|
||||
cloudflare: [
|
||||
domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'),
|
||||
domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'),
|
||||
],
|
||||
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
|
||||
discord: [
|
||||
domain('discord.com'), domain('discord.gg'), domain('discordapp.com'),
|
||||
domain('discordapp.net'), domain('discord.media'),
|
||||
],
|
||||
facebook: [
|
||||
domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'),
|
||||
domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'),
|
||||
],
|
||||
'geolocation-!cn': [
|
||||
keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'),
|
||||
domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'),
|
||||
],
|
||||
'geolocation-cn': [
|
||||
domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'),
|
||||
...cross(CN_BRANDS.slice(0, 18), ['cn']),
|
||||
],
|
||||
github: [
|
||||
domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'),
|
||||
domain('github.io'), domain('ghcr.io'), domain('git.io'),
|
||||
],
|
||||
google: [
|
||||
domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
|
||||
domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'),
|
||||
domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'),
|
||||
domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
||||
],
|
||||
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
|
||||
microsoft: [
|
||||
domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'),
|
||||
domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'),
|
||||
domain('sharepoint.com'), domain('skype.com'), domain('bing.com'),
|
||||
],
|
||||
netflix: [
|
||||
domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'),
|
||||
domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'),
|
||||
],
|
||||
openai: [
|
||||
domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'),
|
||||
domain('oaiusercontent.com'), domain('sora.com'),
|
||||
],
|
||||
spotify: [
|
||||
domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'),
|
||||
domain('spotifycdn.net'),
|
||||
],
|
||||
steam: [
|
||||
domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'),
|
||||
domain('steamcontent.com'), domain('valvesoftware.com'),
|
||||
],
|
||||
telegram: [
|
||||
domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'),
|
||||
domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'),
|
||||
full('comments.app'), keyword('telegram'),
|
||||
],
|
||||
tiktok: [
|
||||
domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'),
|
||||
domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'),
|
||||
],
|
||||
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
|
||||
twitter: [
|
||||
domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'),
|
||||
domain('periscope.tv'),
|
||||
],
|
||||
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
|
||||
youtube: [
|
||||
domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'),
|
||||
domain('youtube-nocookie.com'), domain('yt.be'),
|
||||
],
|
||||
};
|
||||
|
||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
||||
amazon: ['ads'],
|
||||
apple: ['cn'],
|
||||
facebook: ['ads'],
|
||||
google: ['ads', 'cn'],
|
||||
instagram: ['ads'],
|
||||
microsoft: ['cn'],
|
||||
tiktok: ['ads', 'cn'],
|
||||
twitter: ['ads'],
|
||||
youtube: ['ads'],
|
||||
};
|
||||
|
||||
const CN_BLOCKS = [
|
||||
'1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22',
|
||||
'39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12',
|
||||
'103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9',
|
||||
'114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10',
|
||||
'121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12',
|
||||
'180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12',
|
||||
'211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12',
|
||||
'2001:250::/35', '2400:3200::/32', '2408:8000::/20',
|
||||
];
|
||||
|
||||
const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) =>
|
||||
`${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
||||
);
|
||||
|
||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
cloudflare: [
|
||||
'103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
|
||||
'108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
|
||||
'173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
|
||||
'2400:cb00::/32', '2606:4700::/32',
|
||||
].map(cidr),
|
||||
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
|
||||
facebook: [
|
||||
'31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19',
|
||||
'157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32',
|
||||
].map(cidr),
|
||||
google: [
|
||||
'8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20',
|
||||
'72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16',
|
||||
'216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32',
|
||||
].map(cidr),
|
||||
ir: [
|
||||
'2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15',
|
||||
'80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22',
|
||||
'188.34.0.0/17', '217.218.0.0/15',
|
||||
].map(cidr),
|
||||
netflix: [
|
||||
'23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17',
|
||||
'108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20',
|
||||
].map(cidr),
|
||||
private: [
|
||||
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
|
||||
'192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
|
||||
'203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7',
|
||||
'fe80::/10',
|
||||
].map(cidr),
|
||||
ru: [
|
||||
'2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18',
|
||||
'77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19',
|
||||
'82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17',
|
||||
'94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16',
|
||||
'217.66.152.0/21', '2a00:1148::/32',
|
||||
].map(cidr),
|
||||
telegram: [
|
||||
'91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22',
|
||||
'91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48',
|
||||
'2001:b28:f23f::/48',
|
||||
].map(cidr),
|
||||
us: [
|
||||
'3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10',
|
||||
'63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10',
|
||||
'199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24',
|
||||
].map(cidr),
|
||||
};
|
||||
|
||||
function categoriesOf(
|
||||
entries: Record<string, GeoEntry[]>,
|
||||
attributes: Record<string, string[]> = {},
|
||||
): GeoCategory[] {
|
||||
return Object.keys(entries)
|
||||
.sort()
|
||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
||||
}
|
||||
|
||||
const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
|
||||
const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
|
||||
|
||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
||||
|
||||
const GEOSITE_FILE: GeoFile = {
|
||||
name: 'geosite.dat',
|
||||
kind: 'site',
|
||||
size: 4_812_544,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: SITE_CATEGORIES.length,
|
||||
};
|
||||
|
||||
const GEOIP_FILE: GeoFile = {
|
||||
name: 'geoip.dat',
|
||||
kind: 'ip',
|
||||
size: 8_694_272,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: IP_CATEGORIES.length,
|
||||
};
|
||||
|
||||
const DAMAGED_FILE: GeoFile = {
|
||||
name: 'geosite-custom.dat',
|
||||
kind: 'site',
|
||||
size: 262_144,
|
||||
modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
|
||||
categories: 0,
|
||||
error: 'proto: cannot parse invalid wire-format data',
|
||||
};
|
||||
|
||||
const OVERSIZED_FILE: GeoFile = {
|
||||
name: 'geoip-full.dat',
|
||||
kind: 'ip',
|
||||
size: 96_468_992,
|
||||
modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
|
||||
categories: 0,
|
||||
error: 'geodata file is too large to browse',
|
||||
};
|
||||
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
|
||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
||||
};
|
||||
|
||||
function routesFor(files: GeoFile[]): GeoRoutes {
|
||||
return {
|
||||
'/panel/api/xray/geodata/files': () => files,
|
||||
'/panel/api/xray/geodata/categories': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
||||
return { total: items.length, items };
|
||||
},
|
||||
'/panel/api/xray/geodata/entries': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
||||
entry.value.toLowerCase().includes(needle),
|
||||
);
|
||||
const offset = Number(query.get('offset') ?? 0);
|
||||
const limit = Number(query.get('limit') ?? 100);
|
||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withFiles(files: GeoFile[]): Decorator {
|
||||
const routes = routesFor(files);
|
||||
return function GeodataBackend(Story) {
|
||||
return (
|
||||
<GeoApi routes={routes}>
|
||||
<Story />
|
||||
</GeoApi>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
|
||||
|
||||
function BrowserDemo(props: GeoBrowserModalProps) {
|
||||
const [open, setOpen] = useState(props.open);
|
||||
const [value, setValue] = useState(props.value);
|
||||
useEffect(() => setOpen(props.open), [props.open]);
|
||||
useEffect(() => setValue(props.value), [props.value]);
|
||||
return (
|
||||
<Space direction="vertical" size={12}>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
|
||||
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
|
||||
</Space>
|
||||
<GeoBrowserModal
|
||||
{...props}
|
||||
open={open}
|
||||
value={value}
|
||||
onApply={(next) => {
|
||||
setValue(next);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Geodata/GeoBrowserModal',
|
||||
component: GeoBrowserModal,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [{ id: 'color-contrast', enabled: false }],
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
open: true,
|
||||
kind: 'site',
|
||||
value: '',
|
||||
onApply: () => undefined,
|
||||
onClose: () => undefined,
|
||||
},
|
||||
argTypes: {
|
||||
open: { description: 'Whether the modal is visible.' },
|
||||
kind: {
|
||||
description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
||||
control: 'inline-radio',
|
||||
options: ['site', 'ip'],
|
||||
},
|
||||
value: {
|
||||
description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
||||
},
|
||||
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
|
||||
onClose: { description: 'Called when the modal is dismissed.' },
|
||||
},
|
||||
render: (args) => <BrowserDemo {...args} />,
|
||||
} satisfies Meta<typeof GeoBrowserModal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SiteDatabase: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
|
||||
};
|
||||
|
||||
export const CategoryPreview: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'site', value: 'geosite:google' },
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: 'color-contrast', enabled: false },
|
||||
{ id: 'scrollable-region-focusable', enabled: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
|
||||
await userEvent.click(await body.findByText('telegram'));
|
||||
await expect(await body.findByText('t.me')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const IpDatabase: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
|
||||
};
|
||||
|
||||
export const NoDatabases: Story = {
|
||||
decorators: [withFiles([])],
|
||||
args: { kind: 'site', value: 'geosite:google' },
|
||||
};
|
||||
|
||||
export const DamagedDatabase: Story = {
|
||||
decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
|
||||
args: { kind: 'site', value: '' },
|
||||
};
|
||||
Reference in New Issue
Block a user