fix: address the automated review round on PR #6154

- Replace parseGeodataFile's full proto.Unmarshal with a protowire-based
  scan that reads only each entry's Code, skipping every Domain/CIDR
  payload without allocating it -- the actual bulk of a real
  geoip.dat/geosite.dat. Also caps the file read at 256 MiB.
- Hold geodataMu across the full scan-and-maybe-parse in
  GetGeodataCategories instead of releasing it around the parse, so
  concurrent cache misses (e.g. several browser tabs) can't all
  independently re-parse every file; clone the cached slices before
  returning them so a caller mutating its result can't corrupt the cache.
- Gate useGeodataCategories on the rule editor's own `open` state instead
  of firing on every visit to the Routing tab.
- formatGeodataSuggestion now compares filenames with strings.EqualFold,
  matching scanGeodataFiles' own case-insensitive match -- a file that IS
  the default one on a case-insensitive filesystem (e.g. Windows) no
  longer gets the long ext: form.
- Fix a real bug the review's hypothesis led to: Select mode="tags" only
  commits the search text on Enter/comma, so clicking Save right after
  typing (a blur, not an Enter) silently dropped the value entirely, with
  no domain/ip key at all in the saved rule. Wrap it in a small
  TagsAutocomplete that also commits on blur. Same autocomplete now
  applies to sourceIP, which accepts geoip:/ext: too.
- Guard useGeodataCategories' fetch per-field with Array.isArray instead
  of a single top-level `?? EMPTY_CATEGORIES`, since parseMsg returns the
  original unvalidated obj (not null) on a schema mismatch.
- Test fixes: exact slices.Equal instead of slices.Contains-only
  assertions, t.Run subtests, a cache-hit-skips-reparse test (via a
  test-only parse counter), a returns-independent-slices test, a
  file-size-cap test, and four new frontend tests covering the tags
  round-trip including the blur-commit regression above.
- GeodataCategories now goes through the same generated-example path as
  every other response type (StructAllow + example: tags + responseSchema
  in endpoints.ts) instead of a hand-written response string. The
  existing hand-written GeodataCategoriesSchema in schemas/routing.ts is
  unrelated to this and is left alone -- CLAUDE.md is explicit that Zod
  schemas under src/schemas/ are the source of truth and only the
  generated example/openapi path comes from Go example: tags.
- Drop the two PR-illustration screenshots from media/ -- nothing in the
  repo referenced them; they only ever needed to exist in the PR
  description itself.

Not changed: leaving geodataFileKind's leak into generated/{types,zod}.ts
as-is. internal/web/service's openapigen request has no AliasAllow at
all, so every non-struct type in the package already leaks this way
(e.g. staticEgressResolver, transportBits predate this PR) -- scoping an
AliasAllow for the whole package is a real cleanup but a separate, wider
change than this PR's own footprint, and needs checking nothing already
depends on those existing generated aliases first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-30 01:16:12 +03:00
parent 98dce6e5d4
commit 34c43c8a9d
12 changed files with 507 additions and 96 deletions
@@ -11,7 +11,15 @@ async function fetchGeodataCategories(): Promise<GeodataCategories> {
const msg = await HttpUtil.get('/panel/api/xray/getGeodataCategories', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
const validated = parseMsg(msg, GeodataCategoriesSchema, 'xray/getGeodataCategories');
return validated.obj ?? EMPTY_CATEGORIES;
// parseMsg falls back to the original, unvalidated obj on a schema mismatch
// (see zodValidate.ts) rather than clearing it, so each field is guarded
// independently here -- same reasoning as useInboundOptions's Array.isArray
// check, just applied per-field since this response is an object of two
// arrays rather than one top-level array.
return {
domain: Array.isArray(validated.obj?.domain) ? validated.obj.domain : EMPTY_CATEGORIES.domain,
ip: Array.isArray(validated.obj?.ip) ? validated.obj.ip : EMPTY_CATEGORIES.ip,
};
}
// Deliberately not staleTime: Infinity like useInboundOptions: geodata .dat
@@ -20,9 +28,16 @@ async function fetchGeodataCategories(): Promise<GeodataCategories> {
// global default staleTime lets a long-open tab pick up newly downloaded
// categories on refocus, at near-zero backend cost thanks to the
// mtime/size cache in GetGeodataCategories.
export function useGeodataCategories() {
//
// enabled defaults to true but is meant to be passed as `open` from the rule
// editor modal: the underlying scan/parse is the expensive part of this
// feature (see GetGeodataCategories), so it should run when the editor is
// actually opened, not on every visit to the Routing tab that merely mounts
// this modal closed.
export function useGeodataCategories(enabled = true) {
return useQuery({
queryKey: keys.xray.geodataCategories(),
queryFn: fetchGeodataCategories,
enabled,
});
}
+10
View File
@@ -315,6 +315,16 @@ export const EXAMPLES: Record<string, unknown> = {
"masterId": 0,
"path": ""
},
"GeodataCategories": {
"domain": [
"geosite:cn",
"geosite:youtube"
],
"ip": [
"geoip:cn",
"geoip:private"
]
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
+30
View File
@@ -1352,6 +1352,36 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"GeodataCategories": {
"description": "GeodataCategories lists every geosite/geoip category found in the .dat\nfiles currently present in the Xray bin folder, already formatted as\nready-to-use xray-core routing-rule values (see formatGeodataSuggestion).\nReturned by XraySettingService.GetGeodataCategories and served as\nGET /panel/api/xray/getGeodataCategories for the routing rule editor's\nDomain/IP autocomplete.",
"properties": {
"domain": {
"example": [
"geosite:cn",
"geosite:youtube"
],
"items": {
"type": "string"
},
"type": "array"
},
"ip": {
"example": [
"geoip:cn",
"geoip:private"
],
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"domain",
"ip"
],
"type": "object"
},
"HistoryOfSeeders": {
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
"properties": {
+5
View File
@@ -331,6 +331,11 @@ export interface FallbackParentInfo {
path?: string;
}
export interface GeodataCategories {
domain: string[];
ip: string[];
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
+6
View File
@@ -357,6 +357,12 @@ export const FallbackParentInfoSchema = z.object({
});
export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
export const GeodataCategoriesSchema = z.object({
domain: z.array(z.string()),
ip: z.array(z.string()),
});
export type GeodataCategories = z.infer<typeof GeodataCategoriesSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
+1 -1
View File
@@ -1281,7 +1281,7 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/xray/getGeodataCategories',
summary: 'Return every geosite/geoip category found in the .dat files currently present in the Xray bin folder (including custom files added via the Geodata auto-update feature), formatted as ready-to-use routing rule values, e.g. "geosite:youtube" or "ext:geosite_roscom.dat:some-code".',
response: '{\n "success": true,\n "obj": {\n "domain": ["geosite:cn", "geosite:youtube"],\n "ip": ["geoip:cn", "geoip:private"]\n }\n}',
responseSchema: 'GeodataCategories',
},
{
method: 'POST',
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
@@ -82,6 +82,51 @@ function filterBySubstring(input: string, option?: { value?: string }): boolean
return typeof option?.value === 'string' && option.value.toLowerCase().includes(input.toLowerCase());
}
interface TagsAutocompleteProps {
id?: string;
value?: string[];
onChange?: (value: string[]) => void;
onBlur?: () => void;
options: { value: string; label: string }[];
placeholder?: string;
}
// A plain Select mode="tags" only commits the text being typed into a tag on
// Enter or a tokenSeparator character -- clicking Save directly (a blur, not
// an Enter) silently drops it, with no `domain`/`ip` key at all ending up in
// the saved rule. This wraps it with a controlled searchValue that also gets
// committed as a tag on blur, so free-text entry behaves like the old Input
// it replaced.
function TagsAutocomplete({ id, value, onChange, onBlur, options, placeholder }: TagsAutocompleteProps) {
const [searchValue, setSearchValue] = useState('');
function commitSearchValue(next: string[]) {
const trimmed = searchValue.trim();
setSearchValue('');
if (!trimmed || next.includes(trimmed)) return next;
return [...next, trimmed];
}
return (
<Select
id={id}
mode="tags"
value={value}
searchValue={searchValue}
onSearch={setSearchValue}
onChange={(next) => onChange?.(next as string[])}
onBlur={() => {
onChange?.(commitSearchValue(value ?? []));
onBlur?.();
}}
options={options}
tokenSeparators={[',']}
filterOption={filterBySubstring}
placeholder={placeholder}
/>
);
}
export default function RuleFormModal({
open,
rule,
@@ -98,7 +143,7 @@ export default function RuleFormModal({
const { data: inboundOptions } = useInboundOptions();
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
const { data: geodataCategories } = useGeodataCategories();
const { data: geodataCategories } = useGeodataCategories(open);
const domainOptions = useMemo(
() => (geodataCategories?.domain ?? []).map((value) => ({ value, label: value })),
[geodataCategories],
@@ -201,8 +246,9 @@ export default function RuleFormModal({
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
transform={{ input: toTagsArray, output: fromTagsArray }}
>
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
<TagsAutocomplete id="sourceIP" options={ipOptions} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
@@ -283,13 +329,7 @@ export default function RuleFormModal({
}
transform={{ input: toTagsArray, output: fromTagsArray }}
>
<Select
mode="tags"
options={ipOptions}
tokenSeparators={[',']}
filterOption={filterBySubstring}
placeholder="0.0.0.0/8, fc00::/7, geoip:ir"
/>
<TagsAutocomplete id="ip" options={ipOptions} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
@@ -301,13 +341,7 @@ export default function RuleFormModal({
}
transform={{ input: toTagsArray, output: fromTagsArray }}
>
<Select
mode="tags"
options={domainOptions}
tokenSeparators={[',']}
filterOption={filterBySubstring}
placeholder="google.com, geosite:cn"
/>
<TagsAutocomplete id="domain" options={domainOptions} placeholder="google.com, geosite:cn" />
</FormField>
<FormField
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
import { renderWithProviders } from './test-utils';
function domainInput(): HTMLInputElement {
const control = document.getElementById('domain');
const select = control?.closest('.ant-select') as HTMLElement;
return select.querySelector('input') as HTMLInputElement;
}
function selectedTags(fieldId: string): string[] {
const control = document.getElementById(fieldId);
const select = control?.closest('.ant-select') as HTMLElement;
return Array.from(select.querySelectorAll('.ant-select-selection-item')).map(
(el) => el.getAttribute('title') ?? el.textContent ?? '',
);
}
describe('RuleFormModal domain/ip tags autocomplete', () => {
it('renders a comma-separated existing value as tags and preserves it unchanged on save', () => {
const onConfirm = vi.fn();
renderWithProviders(
<RuleFormModal
open
rule={{ type: 'field', domain: 'google.com,geosite:cn', enabled: true }}
inboundTags={[]}
outboundTags={['block']}
balancerTags={[]}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
);
expect(selectedTags('domain')).toEqual(['google.com', 'geosite:cn']);
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).toMatchObject({ domain: ['google.com', 'geosite:cn'] });
});
it('adds a typed value to the tag list on Enter and includes it on save', () => {
const onConfirm = vi.fn();
renderWithProviders(
<RuleFormModal
open
rule={null}
inboundTags={[]}
outboundTags={['block']}
balancerTags={[]}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
);
const input = domainInput();
fireEvent.change(input, { target: { value: 'example.com' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onConfirm.mock.calls[0][0]).toMatchObject({ domain: ['example.com'] });
});
it('commits a typed value on blur even without pressing Enter or a comma', () => {
// Regression test: a plain Select mode="tags" only commits the search
// text on Enter/tokenSeparator. Clicking Save directly is a blur, not an
// Enter -- without TagsAutocomplete's onBlur commit, the typed value was
// silently dropped and the rule saved with no `domain` key at all.
const onConfirm = vi.fn();
renderWithProviders(
<RuleFormModal
open
rule={null}
inboundTags={[]}
outboundTags={['block']}
balancerTags={[]}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
);
const input = domainInput();
fireEvent.change(input, { target: { value: 'blurred.com' } });
// A real click on Save blurs the still-focused input first (native
// browser focus handling); jsdom's fireEvent.click doesn't replicate
// that side effect, so blur is fired explicitly to match what a real
// click does immediately before Save's own handler runs.
fireEvent.blur(input);
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).toMatchObject({ domain: ['blurred.com'] });
});
it('omits domain entirely when left empty', () => {
const onConfirm = vi.fn();
renderWithProviders(
<RuleFormModal
open
rule={null}
inboundTags={[]}
outboundTags={['block']}
balancerTags={[]}
onClose={vi.fn()}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).not.toHaveProperty('domain');
});
});