feat(geodata): add standard source presets (#6504)

* feat(geodata): add standard source presets

Expose the existing geofile allowlist to the Geodata editor so administrators can configure the supported scheduled downloads without copying URLs manually

Tested with npm run test, npm run lint, npm run typecheck, npm run format:check, and go test ./internal/web/service ./internal/web/controller -run '^(TestStandardGeodataSources|TestGeodata)' -count=1

Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)

* fix(geodata): preserve custom source entries

Add missing standard sources instead of replacing existing custom entries.

Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)
This commit is contained in:
ilyusha
2026-09-13 21:02:43 +03:00
committed by GitHub
parent ff1a6c3caf
commit d600de2c2e
19 changed files with 148 additions and 2 deletions
+17 -2
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { XrayConfigPayloadSchema } from '@/schemas/xray';
import { HttpUtil } from '@/utils';
interface GeodataAssetRow {
@@ -37,6 +38,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
const [cron, setCron] = useState(DEFAULT_CRON);
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [standardSources, setStandardSources] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
@@ -45,8 +47,10 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
const parsed = XrayConfigPayloadSchema.safeParse(JSON.parse(msg.obj));
if (!parsed.success) return;
const payload = parsed.data;
const next = payload.xraySetting as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
@@ -62,6 +66,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
setOutbound(
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
);
setStandardSources(payload.geodataSources ?? []);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
@@ -106,6 +111,13 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
);
}
function addStandardSources() {
setRows((prev) => {
const files = new Set(prev.map((row) => row.file));
return [...prev, ...standardSources.filter((source) => !files.has(source.file))];
});
}
function save() {
if (!template) return;
const assets = rows
@@ -217,6 +229,9 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
>
{t('pages.index.geodataAddFile')}
</Button>
<Button onClick={addStandardSources} disabled={standardSources.length === 0}>
{t('pages.index.geodataUseStandardSources')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
+1
View File
@@ -56,6 +56,7 @@ export const XrayConfigPayloadSchema = z
// balancers / routing rules.
subscriptionOutbounds: z.array(z.unknown()).optional(),
subscriptionOutboundTags: z.array(z.string()).optional(),
geodataSources: z.array(z.object({ url: z.string(), file: z.string() })).optional(),
})
.loose();
@@ -0,0 +1,79 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import GeodataSection from '@/pages/index/GeodataSection';
import { HttpUtil, Msg } from '@/utils';
const STANDARD_SOURCES = [
{
url: 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat',
file: 'geoip.dat',
},
{
url: 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat',
file: 'geosite.dat',
},
{
url: 'https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat',
file: 'geoip_IR.dat',
},
{
url: 'https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat',
file: 'geosite_IR.dat',
},
{
url: 'https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat',
file: 'geoip_RU.dat',
},
{
url: 'https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat',
file: 'geosite_RU.dat',
},
];
const CUSTOM_SOURCE = {
url: 'https://example.com/geosite_custom.dat',
file: 'geosite_custom.dat',
};
afterEach(() => {
vi.restoreAllMocks();
});
describe('GeodataSection', () => {
it('adds the standard sources without removing custom sources', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(
new Msg(
true,
'',
JSON.stringify({
xraySetting: { outbounds: [], geodata: { assets: [CUSTOM_SOURCE] } },
geodataSources: STANDARD_SOURCES,
}),
),
);
const user = userEvent.setup();
render(<GeodataSection active onBusy={vi.fn()} onClose={vi.fn()} />);
await screen.findByRole('button', {
name: 'Use standard sources',
});
await waitFor(() =>
expect(
(screen.getByRole('button', { name: 'Use standard sources' }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
await user.click(screen.getByRole('button', { name: 'Use standard sources' }));
await waitFor(() => {
expect(screen.getByDisplayValue(CUSTOM_SOURCE.url)).toBeTruthy();
for (const source of STANDARD_SOURCES) {
expect(screen.getByDisplayValue(source.url)).toBeTruthy();
expect(screen.getByDisplayValue(source.file)).toBeTruthy();
}
});
});
});