mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-05 05:32:07 +03:00
b9eda09da9
npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7 and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11, and npm would not move either. Neither npm update, a targeted install, nor --package-lock-only broke the cycle, so node_modules and package-lock.json were regenerated from scratch (601 packages, 0 vulnerabilities). oxlint 1.79.0 then promoted five React Compiler rules into the correctness category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree, so nothing in our code changed - the rule set grew. They are fixed rather than suppressed: - refs (31): latest-value ref writes moved out of render into an effect. onlineClientsRef turned out to be write-only and is gone; expireDiffRef and trafficDiffRef were replaced by reading the values directly. - set-state-in-effect (55): reset-on-open modals now adjust state during render; where an effect mixed a synchronous reset with an async fetch, the reset moved to render and the effect kept only the request. useMediaQuery became useSyncExternalStore. - preserve-manual-memoization (11): optional-chained deps the compiler cannot match, hoisted to locals or dropped where the memo wrapped a string concat. - purity (3): Date.now() in render replaced by a state-backed clock, which also refreshes the expiry tag every 60s instead of freezing it until the next unrelated re-render. - immutability (1): applyClientStatsEvent merged websocket traffic into DBInbound rows in place; it now rebuilds only the rows it touches. Two things fell out of that. clientCount is derived with useMemo instead of an imperative rebuildClientCount() called from five sites, which also fixes a staleness bug where changing the expiry or traffic threshold left the counts alone until some later rebuild. statsVersion existed only to force a re-render after an in-place mutation, is meaningless now that rows are replaced, and nothing read it, so it is removed. Also adds a lint:fix script - oxlint --fix was previously only reachable through the lint-staged hook.
865 lines
20 KiB
TypeScript
865 lines
20 KiB
TypeScript
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);
|
|
const [synced, setSynced] = useState({ open: props.open, value: props.value });
|
|
if (synced.open !== props.open || synced.value !== props.value) {
|
|
setSynced({ open: props.open, value: props.value });
|
|
setOpen(props.open);
|
|
setValue(props.value);
|
|
}
|
|
return (
|
|
<Space orientation="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: '' },
|
|
};
|