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。",