From d600de2c2e8aee709687ae82ada54a1decb6f11c Mon Sep 17 00:00:00 2001 From: ilyusha Date: Sun, 13 Sep 2026 21:02:43 +0300 Subject: [PATCH] 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) --- frontend/src/pages/index/GeodataSection.tsx | 19 ++++- frontend/src/schemas/xray.ts | 1 + frontend/src/test/geodata-section.test.tsx | 79 +++++++++++++++++++++ internal/web/controller/xray_setting.go | 1 + internal/web/service/server.go | 16 +++++ internal/web/service/server_geofile_test.go | 21 ++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 19 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/geodata-section.test.tsx diff --git a/frontend/src/pages/index/GeodataSection.tsx b/frontend/src/pages/index/GeodataSection.tsx index 26839ad1e..e9e698910 100644 --- a/frontend/src/pages/index/GeodataSection.tsx +++ b/frontend/src/pages/index/GeodataSection.tsx @@ -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(undefined); const [rows, setRows] = useState([]); + const [standardSources, setStandardSources] = useState([]); const [outboundTags, setOutboundTags] = useState([]); const [template, setTemplate] = useState | 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; - const next = (payload.xraySetting || {}) as Record; + const parsed = XrayConfigPayloadSchema.safeParse(JSON.parse(msg.obj)); + if (!parsed.success) return; + const payload = parsed.data; + const next = payload.xraySetting as Record; 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')} + diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 7b7e1c60c..ad3089bd0 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -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(); diff --git a/frontend/src/test/geodata-section.test.tsx b/frontend/src/test/geodata-section.test.tsx new file mode 100644 index 000000000..8c11ffb53 --- /dev/null +++ b/frontend/src/test/geodata-section.test.tsx @@ -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(); + + 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(); + } + }); + }); +}); diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index be6ce1446..ca8b9ac72 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -116,6 +116,7 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) { "inboundTags": json.RawMessage(inboundTags), "clientReverseTags": json.RawMessage(clientReverseTags), "outboundTestUrl": outboundTestUrl, + "geodataSources": service.StandardGeodataSources(), } // Surface subscription outbounds (and their tags) so the frontend can: diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 70e10b608..711203cc6 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -2200,6 +2200,22 @@ var geofileAllowlist = map[string]geofileEntry{ "geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geosite.dat", "geosite_RU.dat"}, } +// GeodataSource identifies a file Xray downloads through its geodata configuration. +type GeodataSource struct { + URL string `json:"url"` + File string `json:"file"` +} + +// StandardGeodataSources derives the panel presets from the geofile update allowlist. +func StandardGeodataSources() []GeodataSource { + sources := make([]GeodataSource, 0, len(geofileAllowlist)) + for _, entry := range geofileAllowlist { + sources = append(sources, GeodataSource{URL: entry.latestURL(), File: entry.FileName}) + } + slices.SortFunc(sources, func(a, b GeodataSource) int { return strings.Compare(a.File, b.File) }) + return sources +} + func (entry geofileEntry) latestURL() string { return entry.Repo + "/releases/latest/download/" + entry.Asset } diff --git a/internal/web/service/server_geofile_test.go b/internal/web/service/server_geofile_test.go index 14c91c8cf..d2ace06f1 100644 --- a/internal/web/service/server_geofile_test.go +++ b/internal/web/service/server_geofile_test.go @@ -392,3 +392,24 @@ func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) { t.Fatalf("error = %q, want it to name the allowlist", err) } } + +func TestStandardGeodataSources(t *testing.T) { + want := []GeodataSource{ + {URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip.dat"}, + {URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", File: "geoip_IR.dat"}, + {URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip_RU.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/geosite.dat", File: "geosite_IR.dat"}, + {URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite_RU.dat"}, + } + + got := StandardGeodataSources() + if len(got) != len(want) { + t.Fatalf("sources = %d entries, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("source %d = %+v, want %+v", i, got[i], want[i]) + } + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 88adaa8c4..1d4871399 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -221,6 +221,7 @@ "geodataOutbound": "التنزيل عبر outbound (اختياري)", "geodataFile": "اسم الملف", "geodataAddFile": "إضافة ملف", + "geodataUseStandardSources": "استخدام المصادر القياسية", "geodataSaveRestart": "حفظ وإعادة تشغيل Xray", "geodataConfirmTitle": "حفظ إعدادات geodata؟", "geodataConfirmContent": "سيتم تحديث قالب إعدادات Xray وإعادة تشغيل Xray.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index a2078187c..c1eb94a9d 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -221,6 +221,7 @@ "geodataOutbound": "Download through outbound (optional)", "geodataFile": "File name", "geodataAddFile": "Add file", + "geodataUseStandardSources": "Use standard sources", "geodataSaveRestart": "Save & Restart Xray", "geodataConfirmTitle": "Save geodata settings?", "geodataConfirmContent": "This updates the Xray config template and restarts Xray.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 8841955c2..876b5bd49 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -221,6 +221,7 @@ "geodataOutbound": "Descargar a través de outbound (opcional)", "geodataFile": "Nombre de archivo", "geodataAddFile": "Añadir archivo", + "geodataUseStandardSources": "Usar fuentes estándar", "geodataSaveRestart": "Guardar y reiniciar Xray", "geodataConfirmTitle": "¿Guardar la configuración de geodata?", "geodataConfirmContent": "Se actualizará la plantilla de configuración de Xray y se reiniciará Xray.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index c4e410511..eaf308331 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -221,6 +221,7 @@ "geodataOutbound": "دانلود از طریق خروجی (اختیاری)", "geodataFile": "نام فایل", "geodataAddFile": "افزودن فایل", + "geodataUseStandardSources": "استفاده از منابع استاندارد", "geodataSaveRestart": "ذخیره و ری‌استارت Xray", "geodataConfirmTitle": "تنظیمات geodata ذخیره شود؟", "geodataConfirmContent": "قالب پیکربندی Xray به‌روزرسانی و Xray ری‌استارت می‌شود.", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 9c21bbd70..48c8e97dd 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -221,6 +221,7 @@ "geodataOutbound": "Unduh melalui outbound (opsional)", "geodataFile": "Nama berkas", "geodataAddFile": "Tambah berkas", + "geodataUseStandardSources": "Gunakan sumber standar", "geodataSaveRestart": "Simpan & Mulai Ulang Xray", "geodataConfirmTitle": "Simpan pengaturan geodata?", "geodataConfirmContent": "Templat konfigurasi Xray akan diperbarui dan Xray akan dimulai ulang.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index ff119cc0e..080416a31 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -221,6 +221,7 @@ "geodataOutbound": "アウトバウンド経由でダウンロード(任意)", "geodataFile": "ファイル名", "geodataAddFile": "ファイルを追加", + "geodataUseStandardSources": "標準ソースを使用", "geodataSaveRestart": "保存して Xray を再起動", "geodataConfirmTitle": "geodata 設定を保存しますか?", "geodataConfirmContent": "Xray 設定テンプレートを更新し、Xray を再起動します。", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index bf8fcba79..6a42f84b1 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -221,6 +221,7 @@ "geodataOutbound": "Baixar através de outbound (opcional)", "geodataFile": "Nome do arquivo", "geodataAddFile": "Adicionar arquivo", + "geodataUseStandardSources": "Usar fontes padrão", "geodataSaveRestart": "Salvar e reiniciar o Xray", "geodataConfirmTitle": "Salvar configurações de geodata?", "geodataConfirmContent": "O modelo de configuração do Xray será atualizado e o Xray será reiniciado.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index d5ef410ab..59da10b23 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -221,6 +221,7 @@ "geodataOutbound": "Скачивать через outbound (необязательно)", "geodataFile": "Имя файла", "geodataAddFile": "Добавить файл", + "geodataUseStandardSources": "Использовать штатные источники", "geodataSaveRestart": "Сохранить и перезапустить Xray", "geodataConfirmTitle": "Сохранить настройки geodata?", "geodataConfirmContent": "Шаблон конфигурации Xray будет обновлён, а Xray перезапущен.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 9bc37e3b3..11b2200d9 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -221,6 +221,7 @@ "geodataOutbound": "Outbound üzerinden indir (isteğe bağlı)", "geodataFile": "Dosya adı", "geodataAddFile": "Dosya ekle", + "geodataUseStandardSources": "Standart kaynakları kullan", "geodataSaveRestart": "Kaydet ve Xray'i Yeniden Başlat", "geodataConfirmTitle": "Geodata ayarları kaydedilsin mi?", "geodataConfirmContent": "Xray yapılandırma şablonu güncellenecek ve Xray yeniden başlatılacak.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 11da763f9..6dd2c163d 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -221,6 +221,7 @@ "geodataOutbound": "Завантажувати через outbound (необов’язково)", "geodataFile": "Ім’я файлу", "geodataAddFile": "Додати файл", + "geodataUseStandardSources": "Використати стандартні джерела", "geodataSaveRestart": "Зберегти та перезапустити Xray", "geodataConfirmTitle": "Зберегти налаштування geodata?", "geodataConfirmContent": "Шаблон конфігурації Xray буде оновлено, а Xray перезапущено.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 2f93797c3..d5c89ac9b 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -221,6 +221,7 @@ "geodataOutbound": "Tải qua outbound (tùy chọn)", "geodataFile": "Tên tệp", "geodataAddFile": "Thêm tệp", + "geodataUseStandardSources": "Dùng nguồn tiêu chuẩn", "geodataSaveRestart": "Lưu và khởi động lại Xray", "geodataConfirmTitle": "Lưu cài đặt geodata?", "geodataConfirmContent": "Mẫu cấu hình Xray sẽ được cập nhật và Xray sẽ khởi động lại.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 539337455..5350e8c95 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -221,6 +221,7 @@ "geodataOutbound": "通过出站下载(可选)", "geodataFile": "文件名", "geodataAddFile": "添加文件", + "geodataUseStandardSources": "使用标准来源", "geodataSaveRestart": "保存并重启 Xray", "geodataConfirmTitle": "保存 geodata 设置?", "geodataConfirmContent": "将更新 Xray 配置模板并重启 Xray。", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 401963111..3ec37d4b5 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -221,6 +221,7 @@ "geodataOutbound": "透過出站下載(可選)", "geodataFile": "檔案名稱", "geodataAddFile": "新增檔案", + "geodataUseStandardSources": "使用標準來源", "geodataSaveRestart": "儲存並重啟 Xray", "geodataConfirmTitle": "儲存 geodata 設定?", "geodataConfirmContent": "將更新 Xray 設定範本並重啟 Xray。",