mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-25 16:52:10 +03:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -331,6 +331,11 @@ export interface FallbackParentInfo {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface GeodataCategories {
|
||||
domain: string[];
|
||||
ip: string[];
|
||||
}
|
||||
|
||||
export interface HistoryOfSeeders {
|
||||
id: number;
|
||||
seederName: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user