Ver código fonte

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)
ilyusha 7 horas atrás
pai
commit
d600de2c2e

+ 17 - 2
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<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 - 0
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();
 

+ 79 - 0
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(<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();
+      }
+    });
+  });
+});

+ 1 - 0
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:

+ 16 - 0
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
 }

+ 21 - 0
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])
+		}
+	}
+}

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "التنزيل عبر outbound (اختياري)",
       "geodataFile": "اسم الملف",
       "geodataAddFile": "إضافة ملف",
+      "geodataUseStandardSources": "استخدام المصادر القياسية",
       "geodataSaveRestart": "حفظ وإعادة تشغيل Xray",
       "geodataConfirmTitle": "حفظ إعدادات geodata؟",
       "geodataConfirmContent": "سيتم تحديث قالب إعدادات Xray وإعادة تشغيل Xray.",

+ 1 - 0
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.",

+ 1 - 0
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.",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "دانلود از طریق خروجی (اختیاری)",
       "geodataFile": "نام فایل",
       "geodataAddFile": "افزودن فایل",
+      "geodataUseStandardSources": "استفاده از منابع استاندارد",
       "geodataSaveRestart": "ذخیره و ری‌استارت Xray",
       "geodataConfirmTitle": "تنظیمات geodata ذخیره شود؟",
       "geodataConfirmContent": "قالب پیکربندی Xray به‌روزرسانی و Xray ری‌استارت می‌شود.",

+ 1 - 0
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.",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "アウトバウンド経由でダウンロード(任意)",
       "geodataFile": "ファイル名",
       "geodataAddFile": "ファイルを追加",
+      "geodataUseStandardSources": "標準ソースを使用",
       "geodataSaveRestart": "保存して Xray を再起動",
       "geodataConfirmTitle": "geodata 設定を保存しますか?",
       "geodataConfirmContent": "Xray 設定テンプレートを更新し、Xray を再起動します。",

+ 1 - 0
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.",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Скачивать через outbound (необязательно)",
       "geodataFile": "Имя файла",
       "geodataAddFile": "Добавить файл",
+      "geodataUseStandardSources": "Использовать штатные источники",
       "geodataSaveRestart": "Сохранить и перезапустить Xray",
       "geodataConfirmTitle": "Сохранить настройки geodata?",
       "geodataConfirmContent": "Шаблон конфигурации Xray будет обновлён, а Xray перезапущен.",

+ 1 - 0
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.",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "Завантажувати через outbound (необов’язково)",
       "geodataFile": "Ім’я файлу",
       "geodataAddFile": "Додати файл",
+      "geodataUseStandardSources": "Використати стандартні джерела",
       "geodataSaveRestart": "Зберегти та перезапустити Xray",
       "geodataConfirmTitle": "Зберегти налаштування geodata?",
       "geodataConfirmContent": "Шаблон конфігурації Xray буде оновлено, а Xray перезапущено.",

+ 1 - 0
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.",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "通过出站下载(可选)",
       "geodataFile": "文件名",
       "geodataAddFile": "添加文件",
+      "geodataUseStandardSources": "使用标准来源",
       "geodataSaveRestart": "保存并重启 Xray",
       "geodataConfirmTitle": "保存 geodata 设置?",
       "geodataConfirmContent": "将更新 Xray 配置模板并重启 Xray。",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -221,6 +221,7 @@
       "geodataOutbound": "透過出站下載(可選)",
       "geodataFile": "檔案名稱",
       "geodataAddFile": "新增檔案",
+      "geodataUseStandardSources": "使用標準來源",
       "geodataSaveRestart": "儲存並重啟 Xray",
       "geodataConfirmTitle": "儲存 geodata 設定?",
       "geodataConfirmContent": "將更新 Xray 設定範本並重啟 Xray。",