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

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>

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();

View File

@@ -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();
}
});
});
});

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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])
}
}
}

View File

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

View File

@@ -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.",

View File

@@ -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.",

View File

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

View File

@@ -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.",

View File

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

View File

@@ -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.",

View File

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

View File

@@ -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.",

View File

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

View File

@@ -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.",

View File

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

View File

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