GeoTokenInput.stories.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { useEffect, useState, type ReactNode } from 'react';
  2. import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
  3. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  4. import { expect, within } from 'storybook/test';
  5. import { Space } from 'antd';
  6. import { parseTokens } from '@/lib/xray/geoTokens';
  7. import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
  8. import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
  9. type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
  10. type GeoRoutes = Record<string, GeoResponder>;
  11. const realFetch = window.fetch.bind(window);
  12. let activeRoutes: GeoRoutes = {};
  13. function requestUrl(input: RequestInfo | URL): URL {
  14. if (typeof input === 'string') return new URL(input, window.location.origin);
  15. if (input instanceof URL) return input;
  16. return new URL(input.url, window.location.origin);
  17. }
  18. function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  19. const url = requestUrl(input);
  20. const responder = activeRoutes[url.pathname];
  21. if (!responder) return realFetch(input, init);
  22. const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
  23. const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
  24. return Promise.resolve(
  25. new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
  26. );
  27. }
  28. function activate(routes: GeoRoutes): void {
  29. activeRoutes = routes;
  30. window.fetch = geoFetch;
  31. }
  32. function deactivate(routes: GeoRoutes): void {
  33. if (activeRoutes === routes) activeRoutes = {};
  34. }
  35. function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
  36. const [client] = useState(() => {
  37. activate(routes);
  38. return new QueryClient({
  39. defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  40. });
  41. });
  42. useEffect(() => {
  43. activate(routes);
  44. return () => deactivate(routes);
  45. }, [routes]);
  46. return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
  47. }
  48. const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
  49. const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
  50. const SITE_ENTRIES: Record<string, GeoEntry[]> = {
  51. 'category-ads-all': [
  52. domain('doubleclick.net'),
  53. domain('googleadservices.com'),
  54. domain('googlesyndication.com'),
  55. domain('criteo.com'),
  56. domain('taboola.com'),
  57. domain('outbrain.com'),
  58. ],
  59. cn: [
  60. domain('baidu.com'),
  61. domain('qq.com'),
  62. domain('taobao.com'),
  63. domain('weibo.com'),
  64. domain('bilibili.com'),
  65. ],
  66. google: [
  67. domain('google.com'),
  68. domain('googleapis.com'),
  69. domain('gstatic.com'),
  70. domain('googleusercontent.com'),
  71. domain('ggpht.com'),
  72. domain('android.com'),
  73. ],
  74. netflix: [
  75. domain('netflix.com'),
  76. domain('nflximg.net'),
  77. domain('nflxvideo.net'),
  78. domain('fast.com'),
  79. ],
  80. telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
  81. youtube: [
  82. domain('youtube.com'),
  83. domain('youtu.be'),
  84. domain('ytimg.com'),
  85. domain('googlevideo.com'),
  86. ],
  87. };
  88. const IP_ENTRIES: Record<string, GeoEntry[]> = {
  89. cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
  90. cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
  91. private: [
  92. '10.0.0.0/8',
  93. '127.0.0.0/8',
  94. '169.254.0.0/16',
  95. '172.16.0.0/12',
  96. '192.168.0.0/16',
  97. '::1/128',
  98. 'fc00::/7',
  99. 'fe80::/10',
  100. ].map(cidr),
  101. telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
  102. };
  103. const SITE_ATTRIBUTES: Record<string, string[]> = {
  104. google: ['ads', 'cn'],
  105. youtube: ['ads'],
  106. };
  107. function categoriesOf(
  108. entries: Record<string, GeoEntry[]>,
  109. attributes: Record<string, string[]> = {},
  110. ): GeoCategory[] {
  111. return Object.keys(entries)
  112. .sort()
  113. .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
  114. }
  115. const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
  116. {
  117. 'geosite.dat': {
  118. categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES),
  119. entries: SITE_ENTRIES,
  120. },
  121. 'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
  122. };
  123. const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
  124. const FILES: GeoFile[] = [
  125. {
  126. name: 'geosite.dat',
  127. kind: 'site',
  128. size: 4_812_544,
  129. modifiedAt: UPDATED_AT,
  130. categories: DATASETS['geosite.dat'].categories.length,
  131. },
  132. {
  133. name: 'geoip.dat',
  134. kind: 'ip',
  135. size: 8_694_272,
  136. modifiedAt: UPDATED_AT,
  137. categories: DATASETS['geoip.dat'].categories.length,
  138. },
  139. ];
  140. function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
  141. const [prefix, ...rest] = token.split(':');
  142. const code = (value: string) => value.split('@')[0].toLowerCase();
  143. if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
  144. if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
  145. if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
  146. return isIP && prefix === 'ext-ip'
  147. ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }
  148. : null;
  149. }
  150. function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
  151. const issues: GeodataTokenIssue[] = [];
  152. for (const token of tokens) {
  153. const reference = referenceOf(token, isIP);
  154. if (!reference) continue;
  155. const dataset = DATASETS[reference.file];
  156. if (!dataset) {
  157. issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
  158. continue;
  159. }
  160. if (!dataset.categories.some((category) => category.code === reference.code)) {
  161. issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
  162. }
  163. }
  164. return issues;
  165. }
  166. const routes: GeoRoutes = {
  167. '/csrf-token': () => 'storybook-csrf-token',
  168. '/panel/api/xray/geodata/files': () => FILES,
  169. '/panel/api/xray/geodata/categories': (query) => {
  170. const dataset = DATASETS[query.get('file') ?? ''];
  171. const needle = (query.get('q') ?? '').trim().toLowerCase();
  172. const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
  173. return { total: items.length, items };
  174. },
  175. '/panel/api/xray/geodata/entries': (query) => {
  176. const dataset = DATASETS[query.get('file') ?? ''];
  177. const needle = (query.get('q') ?? '').trim().toLowerCase();
  178. const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
  179. entry.value.toLowerCase().includes(needle),
  180. );
  181. const offset = Number(query.get('offset') ?? 0);
  182. const limit = Number(query.get('limit') ?? 100);
  183. return { total: matched.length, items: matched.slice(offset, offset + limit) };
  184. },
  185. '/panel/api/xray/geodata/validate': (_query, form) =>
  186. validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
  187. };
  188. const withGeodata: Decorator = function GeodataBackend(Story) {
  189. return (
  190. <GeoApi routes={routes}>
  191. <Story />
  192. </GeoApi>
  193. );
  194. };
  195. function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
  196. const [current, setCurrent] = useState(value);
  197. const [synced, setSynced] = useState(value);
  198. if (synced !== value) {
  199. setSynced(value);
  200. setCurrent(value);
  201. }
  202. return (
  203. <Space orientation="vertical" size={4} style={{ width: 460 }}>
  204. <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
  205. <GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
  206. </Space>
  207. );
  208. }
  209. const meta = {
  210. title: 'Geodata/GeoTokenInput',
  211. component: GeoTokenInput,
  212. tags: ['autodocs'],
  213. parameters: {
  214. layout: 'padded',
  215. a11y: {
  216. config: {
  217. rules: [{ id: 'color-contrast', enabled: false }],
  218. },
  219. },
  220. docs: {
  221. description: {
  222. component:
  223. 'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
  224. },
  225. },
  226. },
  227. decorators: [withGeodata],
  228. args: { kind: 'domain' },
  229. argTypes: {
  230. value: { description: 'Comma separated rule string held by the parent form.' },
  231. onChange: {
  232. description: 'Called with the full rule string on every edit and on Apply from the browser.',
  233. },
  234. onBlur: {
  235. description: 'Forwarded to the input; used by React Hook Form to mark the field touched.',
  236. },
  237. kind: {
  238. description:
  239. 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
  240. control: 'inline-radio',
  241. options: ['domain', 'ip'],
  242. },
  243. placeholder: { description: 'Placeholder shown while the field is empty.' },
  244. id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
  245. },
  246. render: (args) => <ControlledTokenInput {...args} />,
  247. } satisfies Meta<typeof GeoTokenInput>;
  248. export default meta;
  249. type Story = StoryObj<typeof meta>;
  250. export const Empty: Story = {
  251. args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
  252. };
  253. export const DomainTokens: Story = {
  254. args: { kind: 'domain', value: 'geosite:google, google.com' },
  255. };
  256. export const IpTokens: Story = {
  257. args: { kind: 'ip', value: 'geoip:private' },
  258. };
  259. export const UnknownCategory: Story = {
  260. args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
  261. play: async ({ canvasElement }) => {
  262. const canvas = within(canvasElement);
  263. await expect(
  264. await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }),
  265. ).toBeVisible();
  266. },
  267. };