Explorar el Código

Add custom geosite/geoip URL sources (#3980)

* feat: add custom geosite/geoip URL sources

Register DB model, panel API, index/xray UI, and i18n.

* fix
Vladislav Tupikin hace 2 días
padre
commit
7466916e02

+ 8 - 0
README.ar_EG.md

@@ -22,6 +22,14 @@
 
 كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
 
+## مصادر DAT مخصصة GeoSite / GeoIP
+
+يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_<alias>.dat` و`geoip_<alias>.dat`.
+
+**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`).
+
+**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-` → `_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز.
+
 ## البدء السريع
 
 ```

+ 8 - 0
README.es_ES.md

@@ -22,6 +22,14 @@
 
 Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
 
+## Fuentes DAT personalizadas GeoSite / GeoIP
+
+Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_<alias>.dat` y `geoip_<alias>.dat`.
+
+**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`).
+
+**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-` → `_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada.
+
 ## Inicio Rápido
 
 ```

+ 8 - 0
README.fa_IR.md

@@ -22,6 +22,14 @@
 
 به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
 
+## منابع DAT سفارشی GeoSite / GeoIP
+
+سرپرستان می‌توانند از طریق پنل فایل‌های `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی به‌روزرسانی ژئوفایل‌های داخلی). فایل‌ها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیش‌فرض `bin/`) با نام‌های ثابت `geosite_<alias>.dat` و `geoip_<alias>.dat` ذخیره می‌شوند.
+
+**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`).
+
+**نام‌های رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمال‌شده (`strings.ToLower`، `-` → `_`) مقایسه می‌شود. نام‌های واردشده و رکورد پایگاه داده بازنویسی نمی‌شوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان می‌خورند.
+
 ## شروع سریع
 
 ```

+ 8 - 0
README.md

@@ -22,6 +22,14 @@
 
 As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
 
+## Custom GeoSite / GeoIP DAT sources
+
+Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_<alias>.dat` and `geoip_<alias>.dat`.
+
+**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`).
+
+**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-` → `_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry.
+
 ## Quick Start
 
 ```bash

+ 8 - 0
README.ru_RU.md

@@ -22,6 +22,14 @@
 
 Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
 
+## Пользовательские GeoSite / GeoIP (DAT)
+
+В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_<alias>.dat` и `geoip_<alias>.dat`.
+
+**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`).
+
+**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-` → `_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись.
+
 ## Быстрый старт
 
 ```

+ 8 - 0
README.zh_CN.md

@@ -22,6 +22,14 @@
 
 作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
 
+## 自定义 GeoSite / GeoIP(DAT)
+
+管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_<alias>.dat` 和 `geoip_<alias>.dat`。
+
+**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag` 或 `ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。
+
+**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower`,`-` → `_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir` 与 `geoip_ir` 视为同一保留项。
+
 ## 快速开始
 
 ```

+ 2 - 2
database/db.go

@@ -38,6 +38,7 @@ func initModels() error {
 		&model.InboundClientIps{},
 		&xray.ClientTraffic{},
 		&model.HistoryOfSeeders{},
+		&model.CustomGeoResource{},
 	}
 	for _, model := range models {
 		if err := db.AutoMigrate(model); err != nil {
@@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
 	return db
 }
 
-// IsNotFound checks if the given error is a GORM record not found error.
 func IsNotFound(err error) bool {
-	return err == gorm.ErrRecordNotFound
+	return errors.Is(err, gorm.ErrRecordNotFound)
 }
 
 // IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.

+ 12 - 0
database/model/model.go

@@ -104,6 +104,18 @@ type Setting struct {
 	Value string `json:"value" form:"value"`
 }
 
+type CustomGeoResource struct {
+	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
+	Alias         string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
+	Url           string `json:"url" gorm:"not null"`
+	LocalPath     string `json:"localPath" gorm:"column:local_path"`
+	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
+	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
+	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
+	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
+}
+
 // Client represents a client configuration for Xray inbounds with traffic limits and settings.
 type Client struct {
 	ID         string `json:"id"`                           // Unique client identifier

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web/assets/css/custom.min.css


+ 5 - 3
web/controller/api.go

@@ -18,9 +18,9 @@ type APIController struct {
 }
 
 // NewAPIController creates a new APIController instance and initializes its routes.
-func NewAPIController(g *gin.RouterGroup) *APIController {
+func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
 	a := &APIController{}
-	a.initRouter(g)
+	a.initRouter(g, customGeo)
 	return a
 }
 
@@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 }
 
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
-func (a *APIController) initRouter(g *gin.RouterGroup) {
+func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
@@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	server := api.Group("/server")
 	a.serverController = NewServerController(server)
 
+	NewCustomGeoController(api.Group("/custom-geo"), customGeo)
+
 	// Extra routes
 	api.GET("/backuptotgbot", a.BackuptoTgbot)
 }

+ 174 - 0
web/controller/custom_geo.go

@@ -0,0 +1,174 @@
+package controller
+
+import (
+	"errors"
+	"net/http"
+	"strconv"
+
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/entity"
+	"github.com/mhsanaei/3x-ui/v2/web/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+type CustomGeoController struct {
+	BaseController
+	customGeoService *service.CustomGeoService
+}
+
+func NewCustomGeoController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *CustomGeoController {
+	a := &CustomGeoController{customGeoService: customGeo}
+	a.initRouter(g)
+	return a
+}
+
+func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
+	g.GET("/list", a.list)
+	g.GET("/aliases", a.aliases)
+	g.POST("/add", a.add)
+	g.POST("/update/:id", a.update)
+	g.POST("/delete/:id", a.delete)
+	g.POST("/download/:id", a.download)
+	g.POST("/update-all", a.updateAll)
+}
+
+func mapCustomGeoErr(c *gin.Context, err error) error {
+	if err == nil {
+		return nil
+	}
+	switch {
+	case errors.Is(err, service.ErrCustomGeoInvalidType):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
+	case errors.Is(err, service.ErrCustomGeoAliasRequired):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
+	case errors.Is(err, service.ErrCustomGeoAliasPattern):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
+	case errors.Is(err, service.ErrCustomGeoAliasReserved):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
+	case errors.Is(err, service.ErrCustomGeoURLRequired):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
+	case errors.Is(err, service.ErrCustomGeoInvalidURL):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
+	case errors.Is(err, service.ErrCustomGeoURLScheme):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
+	case errors.Is(err, service.ErrCustomGeoURLHost):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
+	case errors.Is(err, service.ErrCustomGeoDuplicateAlias):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
+	case errors.Is(err, service.ErrCustomGeoNotFound):
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
+	case errors.Is(err, service.ErrCustomGeoDownload):
+		logger.Warning("custom geo download:", err)
+		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
+	default:
+		return err
+	}
+}
+
+func (a *CustomGeoController) list(c *gin.Context) {
+	list, err := a.customGeoService.GetAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
+		return
+	}
+	jsonObj(c, list, nil)
+}
+
+func (a *CustomGeoController) aliases(c *gin.Context) {
+	out, err := a.customGeoService.GetAliasesForUI()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
+		return
+	}
+	jsonObj(c, out, nil)
+}
+
+type customGeoForm struct {
+	Type  string `json:"type" form:"type"`
+	Alias string `json:"alias" form:"alias"`
+	Url   string `json:"url" form:"url"`
+}
+
+func (a *CustomGeoController) add(c *gin.Context) {
+	var form customGeoForm
+	if err := c.ShouldBind(&form); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
+		return
+	}
+	r := &model.CustomGeoResource{
+		Type:  form.Type,
+		Alias: form.Alias,
+		Url:   form.Url,
+	}
+	err := a.customGeoService.Create(r)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
+}
+
+func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
+		return 0, false
+	}
+	if id <= 0 {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
+		return 0, false
+	}
+	return id, true
+}
+
+func (a *CustomGeoController) update(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	var form customGeoForm
+	if bindErr := c.ShouldBind(&form); bindErr != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
+		return
+	}
+	r := &model.CustomGeoResource{
+		Type:  form.Type,
+		Alias: form.Alias,
+		Url:   form.Url,
+	}
+	err := a.customGeoService.Update(id, r)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) delete(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	name, err := a.customGeoService.Delete(id)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) download(c *gin.Context) {
+	id, ok := parseCustomGeoID(c, c.Param("id"))
+	if !ok {
+		return
+	}
+	name, err := a.customGeoService.TriggerUpdate(id)
+	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
+}
+
+func (a *CustomGeoController) updateAll(c *gin.Context) {
+	res, err := a.customGeoService.TriggerUpdateAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
+		return
+	}
+	if len(res.Failed) > 0 {
+		c.JSON(http.StatusOK, entity.Msg{
+			Success: false,
+			Msg:     I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
+			Obj:     res,
+		})
+		return
+	}
+	jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
+}

+ 11 - 2
web/controller/util.go

@@ -50,8 +50,17 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 		}
 	} else {
 		m.Success = false
-		m.Msg = msg + " (" + err.Error() + ")"
-		logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+		errStr := err.Error()
+		if errStr != "" {
+			m.Msg = msg + " (" + errStr + ")"
+			logger.Warning(msg+" "+I18nWeb(c, "fail")+": ", err)
+		} else if msg != "" {
+			m.Msg = msg
+			logger.Warning(msg + " " + I18nWeb(c, "fail"))
+		} else {
+			m.Msg = I18nWeb(c, "somethingWentWrong")
+			logger.Warning(I18nWeb(c, "somethingWentWrong") + " " + I18nWeb(c, "fail"))
+		}
 	}
 	c.JSON(http.StatusOK, m)
 }

+ 220 - 2
web/html/index.html

@@ -2,6 +2,20 @@
 {{ template "page/head_end" .}}
 
 {{ template "page/body_start" .}}
+<style>
+  body.dark .custom-geo-section code.custom-geo-ext-code {
+    color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
+    background: var(--dark-color-surface-200, #222d42);
+    border: 1px solid var(--dark-color-stroke, #2c3950);
+    padding: 2px 6px;
+    border-radius: 3px;
+  }
+  html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
+    color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
+    background: var(--dark-color-surface-700, #111929);
+    border-color: var(--dark-color-stroke, #2c3950);
+  }
+</style>
 <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
   <a-sidebar></a-sidebar>
   <a-layout id="content-layout">
@@ -105,7 +119,7 @@
                           </a-row>
                         </span>
                         <template slot="content">
-                          <span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
+                          <span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
                         </template>
                         <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
                           :class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
@@ -113,7 +127,7 @@
                     </template>
                   </template>
                   <template #actions>
-                    <a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
+                    <a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
                       <a-icon type="bars"></a-icon>
                       <span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
                     </a-space>
@@ -328,8 +342,65 @@
         <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
             "pages.index.geofilesUpdateAll" }}</a-button></div>
       </a-collapse-panel>
+      <a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
+        <div class="custom-geo-section">
+        <a-alert type="info" show-icon class="mb-10"
+          message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
+        <div class="mb-10">
+          <a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
+            {{ i18n "pages.index.customGeoAdd" }}
+          </a-button>
+          <a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
+            "pages.index.geofilesUpdateAll" }}</a-button>
+        </div>
+        <a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
+          :loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
+          <template slot="extDat" slot-scope="text, record">
+            <code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
+          </template>
+          <template slot="lastUpdatedAt" slot-scope="text, record">
+            <span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
+            <span v-else>—</span>
+          </template>
+          <template slot="action" slot-scope="text, record">
+            <a-space size="small">
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
+                <a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
+              </a-tooltip>
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
+                <a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
+              </a-tooltip>
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
+                <a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
+              </a-tooltip>
+            </a-space>
+          </template>
+        </a-table>
+        </div>
+      </a-collapse-panel>
     </a-collapse>
   </a-modal>
+  <a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
+    :confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
+    :class="themeSwitcher.currentTheme">
+    <a-form layout="vertical">
+      <a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
+        <a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
+          <a-select-option value="geosite">geosite</a-select-option>
+          <a-select-option value="geoip">geoip</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
+        <a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
+      </a-form-item>
+      <a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
+        <a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
+      </a-form-item>
+    </a-form>
+  </a-modal>
   <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
     :class="themeSwitcher.currentTheme" width="800px" footer="">
     <template slot="title">
@@ -870,6 +941,12 @@
     },
   };
 
+  const customGeoColumns = [
+    { title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
+    { title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
+    { title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
+  ];
+
   const app = new Vue({
     delimiters: ['[[', ']]'],
     el: '#app',
@@ -893,6 +970,25 @@
       showAlert: false,
       showIp: false,
       ipLimitEnable: false,
+      customGeoColumns,
+      customGeoList: [],
+      customGeoLoading: false,
+      customGeoUpdatingAll: false,
+      customGeoActionId: null,
+      customGeoModal: {
+        visible: false,
+        editId: null,
+        saving: false,
+        form: {
+          type: 'geosite',
+          alias: '',
+          url: '',
+        },
+      },
+      customGeoValidation: {
+        alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
+        url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
+      },
     },
     methods: {
       loading(spinning, tip = '{{ i18n "loading"}}') {
@@ -961,6 +1057,128 @@
           return;
         }
         versionModal.show(msg.obj);
+        this.loadCustomGeo();
+      },
+      customGeoFormatTime(ts) {
+        if (!ts) return '';
+        return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
+      },
+      customGeoExtDisplay(record) {
+        const fn = record.type === 'geoip'
+          ? `geoip_${record.alias}.dat`
+          : `geosite_${record.alias}.dat`;
+        return `ext:${fn}:tag`;
+      },
+      async loadCustomGeo() {
+        this.customGeoLoading = true;
+        try {
+          const msg = await HttpUtil.get('/panel/api/custom-geo/list');
+          if (msg.success && Array.isArray(msg.obj)) {
+            this.customGeoList = msg.obj;
+          }
+        } finally {
+          this.customGeoLoading = false;
+        }
+      },
+      openCustomGeoModal(record) {
+        if (record) {
+          this.customGeoModal.editId = record.id;
+          this.customGeoModal.form = {
+            type: record.type,
+            alias: record.alias,
+            url: record.url,
+          };
+        } else {
+          this.customGeoModal.editId = null;
+          this.customGeoModal.form = {
+            type: 'geosite',
+            alias: '',
+            url: '',
+          };
+        }
+        this.customGeoModal.visible = true;
+      },
+      validateCustomGeoForm() {
+        const f = this.customGeoModal.form;
+        const re = /^[a-z0-9_-]+$/;
+        if (!re.test(f.alias || '')) {
+          this.$message.error(this.customGeoValidation.alias);
+          return false;
+        }
+        const u = (f.url || '').trim();
+        if (!/^https?:\/\//i.test(u)) {
+          this.$message.error(this.customGeoValidation.url);
+          return false;
+        }
+        try {
+          const parsed = new URL(u);
+          if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+            this.$message.error(this.customGeoValidation.url);
+            return false;
+          }
+        } catch (e) {
+          this.$message.error(this.customGeoValidation.url);
+          return false;
+        }
+        return true;
+      },
+      async submitCustomGeo() {
+        if (!this.validateCustomGeoForm()) {
+          return;
+        }
+        const f = this.customGeoModal.form;
+        this.customGeoModal.saving = true;
+        try {
+          let msg;
+          if (this.customGeoModal.editId) {
+            msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
+          } else {
+            msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
+          }
+          if (msg && msg.success) {
+            this.customGeoModal.visible = false;
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoModal.saving = false;
+        }
+      },
+      confirmDeleteCustomGeo(record) {
+        this.$confirm({
+          title: '{{ i18n "pages.index.customGeoDelete" }}',
+          content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
+          okText: '{{ i18n "confirm"}}',
+          cancelText: '{{ i18n "cancel"}}',
+          class: themeSwitcher.currentTheme,
+          onOk: async () => {
+            const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
+            if (msg.success) {
+              await this.loadCustomGeo();
+            }
+          },
+        });
+      },
+      async downloadCustomGeo(id) {
+        this.customGeoActionId = id;
+        try {
+          const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
+          if (msg.success) {
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoActionId = null;
+        }
+      },
+      async updateAllCustomGeo() {
+        this.customGeoUpdatingAll = true;
+        try {
+          const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
+          if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
+            await this.loadCustomGeo();
+          }
+        } finally {
+          this.customGeoUpdatingAll = false;
+        }
       },
       switchV2rayVersion(version) {
         this.$confirm({

+ 27 - 0
web/html/xray.html

@@ -259,6 +259,7 @@
       refreshing: false,
       restartResult: '',
       showAlert: false,
+      customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
       advSettings: 'xraySetting',
       obsSettings: '',
       cm: null,
@@ -1054,6 +1055,31 @@
       },
       showWarp() {
         warpModal.show();
+      },
+      async loadCustomGeoAliases() {
+        try {
+          const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
+          if (!msg.success) {
+            console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
+            return;
+          }
+          if (!msg.obj) return;
+          const { geoip = [], geosite = [] } = msg.obj;
+          const geoSuffix = this.customGeoAliasLabelSuffix || '';
+          geoip.forEach((x) => {
+            this.settingsData.IPsOptions.push({
+              label: x.alias + geoSuffix,
+              value: x.extExample,
+            });
+          });
+          geosite.forEach((x) => {
+            const opt = { label: x.alias + geoSuffix, value: x.extExample };
+            this.settingsData.DomainsOptions.push(opt);
+            this.settingsData.BlockDomainsOptions.push(opt);
+          });
+        } catch (e) {
+          console.error('Failed to load custom geo aliases:', e);
+        }
       }
     },
     async mounted() {
@@ -1061,6 +1087,7 @@
         this.showAlert = true;
       }
       await this.getXraySetting();
+      await this.loadCustomGeoAliases();
       await this.getXrayResult();
       await this.getOutboundsTraffic();
 

+ 603 - 0
web/service/custom_geo.go

@@ -0,0 +1,603 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/config"
+	"github.com/mhsanaei/3x-ui/v2/database"
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/logger"
+)
+
+const (
+	customGeoTypeGeosite  = "geosite"
+	customGeoTypeGeoip    = "geoip"
+	minDatBytes           = 64
+	customGeoProbeTimeout = 12 * time.Second
+)
+
+var (
+	customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
+	reservedCustomAliases = map[string]struct{}{
+		"geoip": {}, "geosite": {},
+		"geoip_ir": {}, "geosite_ir": {},
+		"geoip_ru": {}, "geosite_ru": {},
+	}
+	ErrCustomGeoInvalidType    = errors.New("custom_geo_invalid_type")
+	ErrCustomGeoAliasRequired  = errors.New("custom_geo_alias_required")
+	ErrCustomGeoAliasPattern   = errors.New("custom_geo_alias_pattern")
+	ErrCustomGeoAliasReserved  = errors.New("custom_geo_alias_reserved")
+	ErrCustomGeoURLRequired    = errors.New("custom_geo_url_required")
+	ErrCustomGeoInvalidURL     = errors.New("custom_geo_invalid_url")
+	ErrCustomGeoURLScheme      = errors.New("custom_geo_url_scheme")
+	ErrCustomGeoURLHost        = errors.New("custom_geo_url_host")
+	ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
+	ErrCustomGeoNotFound       = errors.New("custom_geo_not_found")
+	ErrCustomGeoDownload       = errors.New("custom_geo_download")
+)
+
+type CustomGeoUpdateAllItem struct {
+	Id       int    `json:"id"`
+	Alias    string `json:"alias"`
+	FileName string `json:"fileName"`
+}
+
+type CustomGeoUpdateAllFailure struct {
+	Id       int    `json:"id"`
+	Alias    string `json:"alias"`
+	FileName string `json:"fileName"`
+	Err      string `json:"error"`
+}
+
+type CustomGeoUpdateAllResult struct {
+	Succeeded []CustomGeoUpdateAllItem    `json:"succeeded"`
+	Failed    []CustomGeoUpdateAllFailure `json:"failed"`
+}
+
+type CustomGeoService struct {
+	serverService    *ServerService
+	updateAllGetAll  func() ([]model.CustomGeoResource, error)
+	updateAllApply   func(id int, onStartup bool) (string, error)
+	updateAllRestart func() error
+}
+
+func NewCustomGeoService() *CustomGeoService {
+	s := &CustomGeoService{
+		serverService: &ServerService{},
+	}
+	s.updateAllGetAll = s.GetAll
+	s.updateAllApply = s.applyDownloadAndPersist
+	s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
+	return s
+}
+
+func NormalizeAliasKey(alias string) string {
+	return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
+}
+
+func (s *CustomGeoService) fileNameFor(typ, alias string) string {
+	if typ == customGeoTypeGeoip {
+		return fmt.Sprintf("geoip_%s.dat", alias)
+	}
+	return fmt.Sprintf("geosite_%s.dat", alias)
+}
+
+func (s *CustomGeoService) validateType(typ string) error {
+	if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
+		return ErrCustomGeoInvalidType
+	}
+	return nil
+}
+
+func (s *CustomGeoService) validateAlias(alias string) error {
+	if alias == "" {
+		return ErrCustomGeoAliasRequired
+	}
+	if !customGeoAliasPattern.MatchString(alias) {
+		return ErrCustomGeoAliasPattern
+	}
+	if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
+		return ErrCustomGeoAliasReserved
+	}
+	return nil
+}
+
+func (s *CustomGeoService) validateURL(raw string) error {
+	if raw == "" {
+		return ErrCustomGeoURLRequired
+	}
+	u, err := url.Parse(raw)
+	if err != nil {
+		return ErrCustomGeoInvalidURL
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return ErrCustomGeoURLScheme
+	}
+	if u.Host == "" {
+		return ErrCustomGeoURLHost
+	}
+	return nil
+}
+
+func localDatFileNeedsRepair(path string) bool {
+	fi, err := os.Stat(path)
+	if err != nil {
+		return true
+	}
+	if fi.IsDir() {
+		return true
+	}
+	return fi.Size() < int64(minDatBytes)
+}
+
+func CustomGeoLocalFileNeedsRepair(path string) bool {
+	return localDatFileNeedsRepair(path)
+}
+
+func probeCustomGeoURLWithGET(rawURL string) error {
+	client := &http.Client{Timeout: customGeoProbeTimeout}
+	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Range", "bytes=0-0")
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
+	switch resp.StatusCode {
+	case http.StatusOK, http.StatusPartialContent:
+		return nil
+	default:
+		return fmt.Errorf("get range status %d", resp.StatusCode)
+	}
+}
+
+func probeCustomGeoURL(rawURL string) error {
+	client := &http.Client{Timeout: customGeoProbeTimeout}
+	req, err := http.NewRequest(http.MethodHead, rawURL, nil)
+	if err != nil {
+		return err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	_ = resp.Body.Close()
+	sc := resp.StatusCode
+	if sc >= 200 && sc < 300 {
+		return nil
+	}
+	if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
+		return probeCustomGeoURLWithGET(rawURL)
+	}
+	return fmt.Errorf("head status %d", sc)
+}
+
+func (s *CustomGeoService) EnsureOnStartup() {
+	list, err := s.GetAll()
+	if err != nil {
+		logger.Warning("custom geo startup: load list:", err)
+		return
+	}
+	n := len(list)
+	if n == 0 {
+		logger.Info("custom geo startup: no custom geofiles configured")
+		return
+	}
+	logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
+	for i := range list {
+		r := &list[i]
+		if err := s.validateURL(r.Url); err != nil {
+			logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
+			continue
+		}
+		s.syncLocalPath(r)
+		localPath := r.LocalPath
+		if !localDatFileNeedsRepair(localPath) {
+			logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
+			continue
+		}
+		logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
+		if err := probeCustomGeoURL(r.Url); err != nil {
+			logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
+		}
+		_, _ = s.applyDownloadAndPersist(r.Id, true)
+	}
+}
+
+func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
+	skipped, lm, err := s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, false)
+	if err != nil {
+		return false, "", err
+	}
+	if skipped {
+		if _, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
+			return true, lm, nil
+		}
+		return s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, true)
+	}
+	return false, lm, nil
+}
+
+func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
+	var req *http.Request
+	req, err = http.NewRequest(http.MethodGet, resourceURL, nil)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	if !forceFull {
+		if fi, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
+			if !fi.ModTime().IsZero() {
+				req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
+			} else if lastModifiedHeader != "" {
+				if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
+					req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
+				}
+			}
+		}
+	}
+
+	client := &http.Client{Timeout: 10 * time.Minute}
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	defer resp.Body.Close()
+
+	var serverModTime time.Time
+	if lm := resp.Header.Get("Last-Modified"); lm != "" {
+		if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
+			serverModTime = parsed
+			newLastModified = lm
+		}
+	}
+
+	updateModTime := func() {
+		if !serverModTime.IsZero() {
+			_ = os.Chtimes(destPath, serverModTime, serverModTime)
+		}
+	}
+
+	if resp.StatusCode == http.StatusNotModified {
+		if forceFull {
+			return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
+		}
+		updateModTime()
+		return true, newLastModified, nil
+	}
+	if resp.StatusCode != http.StatusOK {
+		return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
+	}
+
+	binDir := filepath.Dir(destPath)
+	if err = os.MkdirAll(binDir, 0o755); err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	tmpPath := destPath + ".tmp"
+	out, err := os.Create(tmpPath)
+	if err != nil {
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	n, err := io.Copy(out, resp.Body)
+	closeErr := out.Close()
+	if err != nil {
+		_ = os.Remove(tmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+	if closeErr != nil {
+		_ = os.Remove(tmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
+	}
+	if n < minDatBytes {
+		_ = os.Remove(tmpPath)
+		return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
+	}
+
+	if err = os.Rename(tmpPath, destPath); err != nil {
+		_ = os.Remove(tmpPath)
+		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
+	}
+
+	updateModTime()
+	if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
+		newLastModified = resp.Header.Get("Last-Modified")
+	}
+	return false, newLastModified, nil
+}
+
+func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
+	if r.LocalPath != "" {
+		return r.LocalPath
+	}
+	return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
+}
+
+func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
+	p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
+	r.LocalPath = p
+}
+
+func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
+	if err := s.validateType(r.Type); err != nil {
+		return err
+	}
+	if err := s.validateAlias(r.Alias); err != nil {
+		return err
+	}
+	if err := s.validateURL(r.Url); err != nil {
+		return err
+	}
+	var existing int64
+	database.GetDB().Model(&model.CustomGeoResource{}).
+		Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
+	if existing > 0 {
+		return ErrCustomGeoDuplicateAlias
+	}
+	s.syncLocalPath(r)
+	skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
+	if err != nil {
+		return err
+	}
+	now := time.Now().Unix()
+	r.LastUpdatedAt = now
+	r.LastModified = lm
+	if err = database.GetDB().Create(r).Error; err != nil {
+		_ = os.Remove(r.LocalPath)
+		return err
+	}
+	logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo create: restart xray:", err)
+	}
+	return nil
+}
+
+func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
+	var cur model.CustomGeoResource
+	if err := database.GetDB().First(&cur, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return ErrCustomGeoNotFound
+		}
+		return err
+	}
+	if err := s.validateType(r.Type); err != nil {
+		return err
+	}
+	if err := s.validateAlias(r.Alias); err != nil {
+		return err
+	}
+	if err := s.validateURL(r.Url); err != nil {
+		return err
+	}
+	if cur.Type != r.Type || cur.Alias != r.Alias {
+		var cnt int64
+		database.GetDB().Model(&model.CustomGeoResource{}).
+			Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
+			Count(&cnt)
+		if cnt > 0 {
+			return ErrCustomGeoDuplicateAlias
+		}
+	}
+	oldPath := s.resolveDestPath(&cur)
+	s.syncLocalPath(r)
+	r.Id = id
+	r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
+	if oldPath != r.LocalPath && oldPath != "" {
+		if _, err := os.Stat(oldPath); err == nil {
+			_ = os.Remove(oldPath)
+		}
+	}
+	_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
+	if err != nil {
+		return err
+	}
+	r.LastUpdatedAt = time.Now().Unix()
+	r.LastModified = lm
+	err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
+		"geo_type":        r.Type,
+		"alias":           r.Alias,
+		"url":             r.Url,
+		"local_path":      r.LocalPath,
+		"last_updated_at": r.LastUpdatedAt,
+		"last_modified":   r.LastModified,
+	}).Error
+	if err != nil {
+		return err
+	}
+	logger.Infof("custom geo updated id=%d", id)
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo update: restart xray:", err)
+	}
+	return nil
+}
+
+func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
+	var r model.CustomGeoResource
+	if err := database.GetDB().First(&r, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return "", ErrCustomGeoNotFound
+		}
+		return "", err
+	}
+	displayName = s.fileNameFor(r.Type, r.Alias)
+	p := s.resolveDestPath(&r)
+	if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
+		return displayName, err
+	}
+	if p != "" {
+		if _, err := os.Stat(p); err == nil {
+			if rmErr := os.Remove(p); rmErr != nil {
+				logger.Warningf("custom geo delete file %s: %v", p, rmErr)
+			}
+		}
+	}
+	logger.Infof("custom geo deleted id=%d", id)
+	if err := s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo delete: restart xray:", err)
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
+	var list []model.CustomGeoResource
+	err := database.GetDB().Order("id asc").Find(&list).Error
+	return list, err
+}
+
+func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
+	var r model.CustomGeoResource
+	if err := database.GetDB().First(&r, id).Error; err != nil {
+		if database.IsNotFound(err) {
+			return "", ErrCustomGeoNotFound
+		}
+		return "", err
+	}
+	displayName = s.fileNameFor(r.Type, r.Alias)
+	s.syncLocalPath(&r)
+	skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
+	if err != nil {
+		if onStartup {
+			logger.Warningf("custom geo startup download id=%d: %v", id, err)
+		} else {
+			logger.Warningf("custom geo manual update id=%d: %v", id, err)
+		}
+		return displayName, err
+	}
+	now := time.Now().Unix()
+	updates := map[string]any{
+		"last_modified":   lm,
+		"local_path":      r.LocalPath,
+		"last_updated_at": now,
+	}
+	if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+		if onStartup {
+			logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
+		} else {
+			logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
+		}
+		return displayName, err
+	}
+	if skipped {
+		if onStartup {
+			logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
+		} else {
+			logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
+		}
+	} else {
+		if onStartup {
+			logger.Infof("custom geo startup download ok id=%d", id)
+		} else {
+			logger.Infof("custom geo manual update ok id=%d", id)
+		}
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
+	displayName, err := s.applyDownloadAndPersist(id, false)
+	if err != nil {
+		return displayName, err
+	}
+	if err = s.serverService.RestartXrayService(); err != nil {
+		logger.Warning("custom geo manual update: restart xray:", err)
+	}
+	return displayName, nil
+}
+
+func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
+	var list []model.CustomGeoResource
+	var err error
+	if s.updateAllGetAll != nil {
+		list, err = s.updateAllGetAll()
+	} else {
+		list, err = s.GetAll()
+	}
+	if err != nil {
+		return nil, err
+	}
+	res := &CustomGeoUpdateAllResult{}
+	if len(list) == 0 {
+		return res, nil
+	}
+	for _, r := range list {
+		var name string
+		var applyErr error
+		if s.updateAllApply != nil {
+			name, applyErr = s.updateAllApply(r.Id, false)
+		} else {
+			name, applyErr = s.applyDownloadAndPersist(r.Id, false)
+		}
+		if applyErr != nil {
+			res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
+				Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
+			})
+			continue
+		}
+		res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
+			Id: r.Id, Alias: r.Alias, FileName: name,
+		})
+	}
+	if len(res.Succeeded) > 0 {
+		var restartErr error
+		if s.updateAllRestart != nil {
+			restartErr = s.updateAllRestart()
+		} else {
+			restartErr = s.serverService.RestartXrayService()
+		}
+		if restartErr != nil {
+			logger.Warning("custom geo update all: restart xray:", restartErr)
+		}
+	}
+	return res, nil
+}
+
+type CustomGeoAliasItem struct {
+	Alias      string `json:"alias"`
+	Type       string `json:"type"`
+	FileName   string `json:"fileName"`
+	ExtExample string `json:"extExample"`
+}
+
+type CustomGeoAliasesResponse struct {
+	Geosite []CustomGeoAliasItem `json:"geosite"`
+	Geoip   []CustomGeoAliasItem `json:"geoip"`
+}
+
+func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
+	list, err := s.GetAll()
+	if err != nil {
+		logger.Warning("custom geo GetAliasesForUI:", err)
+		return CustomGeoAliasesResponse{}, err
+	}
+	var out CustomGeoAliasesResponse
+	for _, r := range list {
+		fn := s.fileNameFor(r.Type, r.Alias)
+		ex := fmt.Sprintf("ext:%s:tag", fn)
+		item := CustomGeoAliasItem{
+			Alias:      r.Alias,
+			Type:       r.Type,
+			FileName:   fn,
+			ExtExample: ex,
+		}
+		if r.Type == customGeoTypeGeoip {
+			out.Geoip = append(out.Geoip, item)
+		} else {
+			out.Geosite = append(out.Geosite, item)
+		}
+	}
+	return out, nil
+}

+ 330 - 0
web/service/custom_geo_test.go

@@ -0,0 +1,330 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+)
+
+func TestNormalizeAliasKey(t *testing.T) {
+	if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
+		t.Fatalf("got %q", got)
+	}
+	if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
+		t.Fatalf("got %q", got)
+	}
+}
+
+func TestNewCustomGeoService(t *testing.T) {
+	s := NewCustomGeoService()
+	if err := s.validateAlias("ok_alias-1"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestTriggerUpdateAllAllSuccess(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "a"},
+			{Id: 2, Alias: "b"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		return fmt.Sprintf("geo_%d.dat", id), nil
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 1 {
+		t.Fatalf("expected 1 restart, got %d", restartCalls)
+	}
+}
+
+func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "ok"},
+			{Id: 2, Alias: "bad"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		if id == 2 {
+			return "geo_2.dat", ErrCustomGeoDownload
+		}
+		return "geo_1.dat", nil
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 1 {
+		t.Fatalf("expected 1 restart, got %d", restartCalls)
+	}
+}
+
+func TestTriggerUpdateAllAllFailure(t *testing.T) {
+	s := CustomGeoService{}
+	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
+		return []model.CustomGeoResource{
+			{Id: 1, Alias: "a"},
+			{Id: 2, Alias: "b"},
+		}, nil
+	}
+	s.updateAllApply = func(id int, onStartup bool) (string, error) {
+		return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
+	}
+	restartCalls := 0
+	s.updateAllRestart = func() error {
+		restartCalls++
+		return nil
+	}
+
+	res, err := s.TriggerUpdateAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
+		t.Fatalf("unexpected result: %+v", res)
+	}
+	if restartCalls != 0 {
+		t.Fatalf("expected 0 restart, got %d", restartCalls)
+	}
+}
+
+func TestCustomGeoValidateAlias(t *testing.T) {
+	s := CustomGeoService{}
+	if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
+		t.Fatal("empty alias")
+	}
+	if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
+		t.Fatal("uppercase")
+	}
+	if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
+		t.Fatal("space")
+	}
+	if err := s.validateAlias("ok_alias-1"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
+		t.Fatal("reserved")
+	}
+}
+
+func TestCustomGeoValidateURL(t *testing.T) {
+	s := CustomGeoService{}
+	if err := s.validateURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
+		t.Fatal("empty")
+	}
+	if err := s.validateURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
+		t.Fatal("ftp")
+	}
+	if err := s.validateURL("https://example.com/a.dat"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestCustomGeoValidateType(t *testing.T) {
+	s := CustomGeoService{}
+	if err := s.validateType("geosite"); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
+		t.Fatal("bad type")
+	}
+}
+
+func TestCustomGeoDownloadToPath(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Test", "1")
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_t.dat")
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("expected download")
+	}
+	st, err := os.Stat(dest)
+	if err != nil || st.Size() < minDatBytes {
+		t.Fatalf("file %v", err)
+	}
+	skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
+	if err2 != nil || !skipped2 {
+		t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
+	}
+}
+
+func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
+	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.Header().Set("Last-Modified", lm)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_rebuild.dat")
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("must not treat as not-modified when local file is missing")
+	}
+	if _, err := os.Stat(dest); err != nil {
+		t.Fatal("file should exist after container-style rebuild")
+	}
+}
+
+func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
+	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") != "" {
+			w.WriteHeader(http.StatusNotModified)
+			return
+		}
+		w.Header().Set("Last-Modified", lm)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(make([]byte, minDatBytes+1))
+	}))
+	defer ts.Close()
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	dest := filepath.Join(dir, "geoip_bad.dat")
+	if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	s := CustomGeoService{}
+	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if skipped {
+		t.Fatal("corrupt local file must be re-downloaded, not 304")
+	}
+	st, err := os.Stat(dest)
+	if err != nil || st.Size() < minDatBytes {
+		t.Fatalf("file repaired: %v", err)
+	}
+}
+
+func TestCustomGeoFileNameFor(t *testing.T) {
+	s := CustomGeoService{}
+	if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
+		t.Fatal("geoip name")
+	}
+	if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
+		t.Fatal("geosite name")
+	}
+}
+
+func TestLocalDatFileNeedsRepair(t *testing.T) {
+	dir := t.TempDir()
+	if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
+		t.Fatal("missing")
+	}
+	smallPath := filepath.Join(dir, "small.dat")
+	if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if !localDatFileNeedsRepair(smallPath) {
+		t.Fatal("small")
+	}
+	okPath := filepath.Join(dir, "ok.dat")
+	if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if localDatFileNeedsRepair(okPath) {
+		t.Fatal("ok size")
+	}
+	dirPath := filepath.Join(dir, "isdir.dat")
+	if err := os.Mkdir(dirPath, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if !localDatFileNeedsRepair(dirPath) {
+		t.Fatal("dir should need repair")
+	}
+	if !CustomGeoLocalFileNeedsRepair(dirPath) {
+		t.Fatal("exported wrapper dir")
+	}
+	if CustomGeoLocalFileNeedsRepair(okPath) {
+		t.Fatal("exported wrapper ok file")
+	}
+}
+
+func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodHead {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer ts.Close()
+	if err := probeCustomGeoURL(ts.URL); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodHead {
+			w.WriteHeader(http.StatusMethodNotAllowed)
+			return
+		}
+		if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
+			w.WriteHeader(http.StatusPartialContent)
+			_, _ = w.Write([]byte{0})
+			return
+		}
+		w.WriteHeader(http.StatusBadRequest)
+	}))
+	defer ts.Close()
+	if err := probeCustomGeoURL(ts.URL); err != nil {
+		t.Fatal(err)
+	}
+}

+ 41 - 0
web/translation/translate.ar_EG.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
 "getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
 "getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
+"customGeoTitle" = "GeoSite / GeoIP مخصص"
+"customGeoAdd" = "إضافة"
+"customGeoType" = "النوع"
+"customGeoAlias" = "الاسم المستعار"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "مفعّل"
+"customGeoLastUpdated" = "آخر تحديث"
+"customGeoExtColumn" = "التوجيه (ext:…)"
+"customGeoToastUpdateAll" = "تم تحديث جميع المصادر المخصصة"
+"customGeoActions" = "إجراءات"
+"customGeoEdit" = "تعديل"
+"customGeoDelete" = "حذف"
+"customGeoDownload" = "تحديث الآن"
+"customGeoModalAdd" = "إضافة geo مخصص"
+"customGeoModalEdit" = "تعديل geo مخصص"
+"customGeoModalSave" = "حفظ"
+"customGeoDeleteConfirm" = "حذف مصدر geo المخصص هذا؟"
+"customGeoRoutingHint" = "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag)."
+"customGeoInvalidId" = "معرّف المورد غير صالح"
+"customGeoAliasesError" = "تعذّر تحميل أسماء geo المخصصة"
+"customGeoValidationAlias" = "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط"
+"customGeoValidationUrl" = "يجب أن يبدأ الرابط بـ http:// أو https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (مخصص)"
+"customGeoToastList" = "قائمة geo المخصص"
+"customGeoToastAdd" = "إضافة geo مخصص"
+"customGeoToastUpdate" = "تحديث geo مخصص"
+"customGeoToastDelete" = "تم حذف geofile «{{ .fileName }}» المخصص"
+"customGeoToastDownload" = "تم تحديث geofile «{{ .fileName }}»"
+"customGeoErrInvalidType" = "يجب أن يكون النوع geosite أو geoip"
+"customGeoErrAliasRequired" = "الاسم المستعار مطلوب"
+"customGeoErrAliasPattern" = "الاسم المستعار يحتوي على أحرف غير مسموحة"
+"customGeoErrAliasReserved" = "هذا الاسم محجوز"
+"customGeoErrUrlRequired" = "الرابط مطلوب"
+"customGeoErrInvalidUrl" = "الرابط غير صالح"
+"customGeoErrUrlScheme" = "يجب أن يستخدم الرابط http أو https"
+"customGeoErrUrlHost" = "مضيف الرابط غير صالح"
+"customGeoErrDuplicateAlias" = "هذا الاسم مستخدم مسبقاً لهذا النوع"
+"customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
+"customGeoErrDownload" = "فشل التنزيل"
+"customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
 
 [pages.inbounds]
 "allTimeTraffic" = "إجمالي حركة المرور"

+ 41 - 0
web/translation/translate.en_US.toml

@@ -150,6 +150,47 @@
 "geofilesUpdateDialogDesc" = "This will update all geofiles."
 "geofilesUpdateAll" = "Update all"
 "geofileUpdatePopover" = "Geofile updated successfully"
+"customGeoTitle" = "Custom GeoSite / GeoIP"
+"customGeoAdd" = "Add"
+"customGeoType" = "Type"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Enabled"
+"customGeoLastUpdated" = "Last updated"
+"customGeoExtColumn" = "Routing (ext:…)"
+"customGeoToastUpdateAll" = "All custom geo sources updated"
+"customGeoActions" = "Actions"
+"customGeoEdit" = "Edit"
+"customGeoDelete" = "Delete"
+"customGeoDownload" = "Update now"
+"customGeoModalAdd" = "Add custom geo"
+"customGeoModalEdit" = "Edit custom geo"
+"customGeoModalSave" = "Save"
+"customGeoDeleteConfirm" = "Delete this custom geo source?"
+"customGeoRoutingHint" = "In routing rules use the value column as ext:file.dat:tag (replace tag)."
+"customGeoInvalidId" = "Invalid resource id"
+"customGeoAliasesError" = "Failed to load custom geo aliases"
+"customGeoValidationAlias" = "Alias may only contain lowercase letters, digits, - and _"
+"customGeoValidationUrl" = "URL must start with http:// or https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (custom)"
+"customGeoToastList" = "Custom geo list"
+"customGeoToastAdd" = "Add custom geo"
+"customGeoToastUpdate" = "Update custom geo"
+"customGeoToastDelete" = "Custom geo file “{{ .fileName }}” deleted"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” updated"
+"customGeoErrInvalidType" = "Type must be geosite or geoip"
+"customGeoErrAliasRequired" = "Alias is required"
+"customGeoErrAliasPattern" = "Alias must match allowed characters"
+"customGeoErrAliasReserved" = "This alias is reserved"
+"customGeoErrUrlRequired" = "URL is required"
+"customGeoErrInvalidUrl" = "URL is invalid"
+"customGeoErrUrlScheme" = "URL must use http or https"
+"customGeoErrUrlHost" = "URL host is invalid"
+"customGeoErrDuplicateAlias" = "This alias is already used for this type"
+"customGeoErrNotFound" = "Custom geo source not found"
+"customGeoErrDownload" = "Download failed"
+"customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
 "dontRefresh" = "Installation is in progress, please do not refresh this page"
 "logs" = "Logs"
 "config" = "Config"

+ 41 - 0
web/translation/translate.es_ES.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Ocurrió un error al leer la base de datos"
 "getDatabaseError" = "Ocurrió un error al obtener la base de datos"
 "getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
+"customGeoTitle" = "GeoSite / GeoIP personalizados"
+"customGeoAdd" = "Añadir"
+"customGeoType" = "Tipo"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Activado"
+"customGeoLastUpdated" = "Última actualización"
+"customGeoExtColumn" = "Enrutamiento (ext:…)"
+"customGeoToastUpdateAll" = "Todas las fuentes personalizadas se actualizaron"
+"customGeoActions" = "Acciones"
+"customGeoEdit" = "Editar"
+"customGeoDelete" = "Eliminar"
+"customGeoDownload" = "Actualizar ahora"
+"customGeoModalAdd" = "Añadir geo personalizado"
+"customGeoModalEdit" = "Editar geo personalizado"
+"customGeoModalSave" = "Guardar"
+"customGeoDeleteConfirm" = "¿Eliminar esta fuente geo personalizada?"
+"customGeoRoutingHint" = "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta)."
+"customGeoInvalidId" = "Id de recurso no válido"
+"customGeoAliasesError" = "No se pudieron cargar los alias geo personalizados"
+"customGeoValidationAlias" = "El alias solo puede contener letras minúsculas, dígitos, - y _"
+"customGeoValidationUrl" = "La URL debe comenzar con http:// o https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (personalizado)"
+"customGeoToastList" = "Lista de geo personalizado"
+"customGeoToastAdd" = "Añadir geo personalizado"
+"customGeoToastUpdate" = "Actualizar geo personalizado"
+"customGeoToastDelete" = "Geofile personalizado «{{ .fileName }}» eliminado"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» actualizado"
+"customGeoErrInvalidType" = "El tipo debe ser geosite o geoip"
+"customGeoErrAliasRequired" = "El alias es obligatorio"
+"customGeoErrAliasPattern" = "El alias contiene caracteres no permitidos"
+"customGeoErrAliasReserved" = "Este alias está reservado"
+"customGeoErrUrlRequired" = "La URL es obligatoria"
+"customGeoErrInvalidUrl" = "La URL no es válida"
+"customGeoErrUrlScheme" = "La URL debe usar http o https"
+"customGeoErrUrlHost" = "El host de la URL no es válido"
+"customGeoErrDuplicateAlias" = "Este alias ya se usa para este tipo"
+"customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
+"customGeoErrDownload" = "Error de descarga"
+"customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tráfico Total"

+ 41 - 0
web/translation/translate.fa_IR.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "خطا در خواندن پایگاه داده"
 "getDatabaseError" = "خطا در دریافت پایگاه داده"
 "getConfigError" = "خطا در دریافت فایل پیکربندی"
+"customGeoTitle" = "GeoSite / GeoIP سفارشی"
+"customGeoAdd" = "افزودن"
+"customGeoType" = "نوع"
+"customGeoAlias" = "نام مستعار"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "فعال"
+"customGeoLastUpdated" = "آخرین به‌روزرسانی"
+"customGeoExtColumn" = "مسیریابی (ext:…)"
+"customGeoToastUpdateAll" = "همه منابع سفارشی به‌روزرسانی شدند"
+"customGeoActions" = "اقدامات"
+"customGeoEdit" = "ویرایش"
+"customGeoDelete" = "حذف"
+"customGeoDownload" = "به‌روزرسانی اکنون"
+"customGeoModalAdd" = "افزودن geo سفارشی"
+"customGeoModalEdit" = "ویرایش geo سفارشی"
+"customGeoModalSave" = "ذخیره"
+"customGeoDeleteConfirm" = "این منبع geo سفارشی حذف شود؟"
+"customGeoRoutingHint" = "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید)."
+"customGeoInvalidId" = "شناسه منبع نامعتبر است"
+"customGeoAliasesError" = "بارگذاری نام مستعارهای geo سفارشی ناموفق بود"
+"customGeoValidationAlias" = "نام مستعار فقط حروف کوچک، اعداد، - و _"
+"customGeoValidationUrl" = "URL باید با http:// یا https:// شروع شود"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (سفارشی)"
+"customGeoToastList" = "فهرست geo سفارشی"
+"customGeoToastAdd" = "افزودن geo سفارشی"
+"customGeoToastUpdate" = "به‌روزرسانی geo سفارشی"
+"customGeoToastDelete" = "geofile سفارشی «{{ .fileName }}» حذف شد"
+"customGeoToastDownload" = "geofile «{{ .fileName }}» به‌روزرسانی شد"
+"customGeoErrInvalidType" = "نوع باید geosite یا geoip باشد"
+"customGeoErrAliasRequired" = "نام مستعار لازم است"
+"customGeoErrAliasPattern" = "نام مستعار دارای نویسه نامجاز است"
+"customGeoErrAliasReserved" = "این نام مستعار رزرو است"
+"customGeoErrUrlRequired" = "URL لازم است"
+"customGeoErrInvalidUrl" = "URL نامعتبر است"
+"customGeoErrUrlScheme" = "URL باید http یا https باشد"
+"customGeoErrUrlHost" = "میزبان URL نامعتبر است"
+"customGeoErrDuplicateAlias" = "این نام مستعار برای این نوع قبلاً استفاده شده است"
+"customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
+"customGeoErrDownload" = "بارگیری ناموفق بود"
+"customGeoErrUpdateAllIncomplete" = "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
 
 [pages.inbounds]
 "allTimeTraffic" = "کل ترافیک"

+ 41 - 0
web/translation/translate.id_ID.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Terjadi kesalahan saat membaca database"
 "getDatabaseError" = "Terjadi kesalahan saat mengambil database"
 "getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
+"customGeoTitle" = "GeoSite / GeoIP kustom"
+"customGeoAdd" = "Tambah"
+"customGeoType" = "Jenis"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Aktif"
+"customGeoLastUpdated" = "Terakhir diperbarui"
+"customGeoExtColumn" = "Routing (ext:…)"
+"customGeoToastUpdateAll" = "Semua sumber kustom telah diperbarui"
+"customGeoActions" = "Aksi"
+"customGeoEdit" = "Edit"
+"customGeoDelete" = "Hapus"
+"customGeoDownload" = "Perbarui sekarang"
+"customGeoModalAdd" = "Tambah geo kustom"
+"customGeoModalEdit" = "Edit geo kustom"
+"customGeoModalSave" = "Simpan"
+"customGeoDeleteConfirm" = "Hapus sumber geo kustom ini?"
+"customGeoRoutingHint" = "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag)."
+"customGeoInvalidId" = "ID sumber tidak valid"
+"customGeoAliasesError" = "Gagal memuat alias geo kustom"
+"customGeoValidationAlias" = "Alias hanya huruf kecil, angka, - dan _"
+"customGeoValidationUrl" = "URL harus diawali http:// atau https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (kustom)"
+"customGeoToastList" = "Daftar geo kustom"
+"customGeoToastAdd" = "Tambah geo kustom"
+"customGeoToastUpdate" = "Perbarui geo kustom"
+"customGeoToastDelete" = "Geofile kustom “{{ .fileName }}” dihapus"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” diperbarui"
+"customGeoErrInvalidType" = "Jenis harus geosite atau geoip"
+"customGeoErrAliasRequired" = "Alias wajib diisi"
+"customGeoErrAliasPattern" = "Alias berisi karakter yang tidak diizinkan"
+"customGeoErrAliasReserved" = "Alias ini dicadangkan"
+"customGeoErrUrlRequired" = "URL wajib diisi"
+"customGeoErrInvalidUrl" = "URL tidak valid"
+"customGeoErrUrlScheme" = "URL harus memakai http atau https"
+"customGeoErrUrlHost" = "Host URL tidak valid"
+"customGeoErrDuplicateAlias" = "Alias ini sudah dipakai untuk jenis ini"
+"customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
+"customGeoErrDownload" = "Unduh gagal"
+"customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
 
 [pages.inbounds]
 "allTimeTraffic" = "Total Lalu Lintas"

+ 41 - 0
web/translation/translate.ja_JP.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
 "getDatabaseError" = "データベースの取得中にエラーが発生しました"
 "getConfigError" = "設定ファイルの取得中にエラーが発生しました"
+"customGeoTitle" = "カスタム GeoSite / GeoIP"
+"customGeoAdd" = "追加"
+"customGeoType" = "種類"
+"customGeoAlias" = "エイリアス"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "有効"
+"customGeoLastUpdated" = "最終更新"
+"customGeoExtColumn" = "ルーティング (ext:…)"
+"customGeoToastUpdateAll" = "すべてのカスタムソースを更新しました"
+"customGeoActions" = "操作"
+"customGeoEdit" = "編集"
+"customGeoDelete" = "削除"
+"customGeoDownload" = "今すぐ更新"
+"customGeoModalAdd" = "カスタム geo を追加"
+"customGeoModalEdit" = "カスタム geo を編集"
+"customGeoModalSave" = "保存"
+"customGeoDeleteConfirm" = "このカスタム geo ソースを削除しますか?"
+"customGeoRoutingHint" = "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。"
+"customGeoInvalidId" = "無効なリソース ID"
+"customGeoAliasesError" = "カスタム geo エイリアスの読み込みに失敗しました"
+"customGeoValidationAlias" = "エイリアスは小文字・数字・- と _ のみ使用できます"
+"customGeoValidationUrl" = "URL は http:// または https:// で始めてください"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(カスタム)"
+"customGeoToastList" = "カスタム geo 一覧"
+"customGeoToastAdd" = "カスタム geo を追加"
+"customGeoToastUpdate" = "カスタム geo を更新"
+"customGeoToastDelete" = "カスタム geofile「{{ .fileName }}」を削除しました"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」を更新しました"
+"customGeoErrInvalidType" = "種類は geosite または geoip である必要があります"
+"customGeoErrAliasRequired" = "エイリアスが必要です"
+"customGeoErrAliasPattern" = "エイリアスに使用できない文字が含まれています"
+"customGeoErrAliasReserved" = "このエイリアスは予約されています"
+"customGeoErrUrlRequired" = "URL が必要です"
+"customGeoErrInvalidUrl" = "URL が無効です"
+"customGeoErrUrlScheme" = "URL は http または https を使用してください"
+"customGeoErrUrlHost" = "URL のホストが無効です"
+"customGeoErrDuplicateAlias" = "この種類ですでにこのエイリアスが使われています"
+"customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
+"customGeoErrDownload" = "ダウンロードに失敗しました"
+"customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
 
 [pages.inbounds]
 "allTimeTraffic" = "総トラフィック"

+ 41 - 0
web/translation/translate.pt_BR.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
 "getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
 "getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
+"customGeoTitle" = "GeoSite / GeoIP personalizados"
+"customGeoAdd" = "Adicionar"
+"customGeoType" = "Tipo"
+"customGeoAlias" = "Alias"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Ativado"
+"customGeoLastUpdated" = "Última atualização"
+"customGeoExtColumn" = "Roteamento (ext:…)"
+"customGeoToastUpdateAll" = "Todas as fontes personalizadas foram atualizadas"
+"customGeoActions" = "Ações"
+"customGeoEdit" = "Editar"
+"customGeoDelete" = "Excluir"
+"customGeoDownload" = "Atualizar agora"
+"customGeoModalAdd" = "Adicionar geo personalizado"
+"customGeoModalEdit" = "Editar geo personalizado"
+"customGeoModalSave" = "Salvar"
+"customGeoDeleteConfirm" = "Excluir esta fonte geo personalizada?"
+"customGeoRoutingHint" = "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag)."
+"customGeoInvalidId" = "ID de recurso inválido"
+"customGeoAliasesError" = "Falha ao carregar aliases geo personalizados"
+"customGeoValidationAlias" = "O alias só pode conter letras minúsculas, dígitos, - e _"
+"customGeoValidationUrl" = "A URL deve começar com http:// ou https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (personalizado)"
+"customGeoToastList" = "Lista de geo personalizado"
+"customGeoToastAdd" = "Adicionar geo personalizado"
+"customGeoToastUpdate" = "Atualizar geo personalizado"
+"customGeoToastDelete" = "Geofile personalizado “{{ .fileName }}” excluído"
+"customGeoToastDownload" = "Geofile “{{ .fileName }}” atualizado"
+"customGeoErrInvalidType" = "O tipo deve ser geosite ou geoip"
+"customGeoErrAliasRequired" = "Alias é obrigatório"
+"customGeoErrAliasPattern" = "O alias contém caracteres não permitidos"
+"customGeoErrAliasReserved" = "Este alias é reservado"
+"customGeoErrUrlRequired" = "URL é obrigatória"
+"customGeoErrInvalidUrl" = "URL inválida"
+"customGeoErrUrlScheme" = "A URL deve usar http ou https"
+"customGeoErrUrlHost" = "Host da URL inválido"
+"customGeoErrDuplicateAlias" = "Este alias já está em uso para este tipo"
+"customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
+"customGeoErrDownload" = "Falha no download"
+"customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tráfego Total"

+ 41 - 0
web/translation/translate.ru_RU.toml

@@ -150,6 +150,47 @@
 "geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
 "geofilesUpdateAll" = "Обновить все"
 "geofileUpdatePopover" = "Геофайлы успешно обновлены"
+"customGeoTitle" = "Пользовательские GeoSite / GeoIP"
+"customGeoAdd" = "Добавить"
+"customGeoType" = "Тип"
+"customGeoAlias" = "Псевдоним"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Включено"
+"customGeoLastUpdated" = "Обновлено"
+"customGeoExtColumn" = "Маршрутизация (ext:…)"
+"customGeoToastUpdateAll" = "Все пользовательские источники обновлены"
+"customGeoActions" = "Действия"
+"customGeoEdit" = "Изменить"
+"customGeoDelete" = "Удалить"
+"customGeoDownload" = "Обновить сейчас"
+"customGeoModalAdd" = "Добавить источник"
+"customGeoModalEdit" = "Изменить источник"
+"customGeoModalSave" = "Сохранить"
+"customGeoDeleteConfirm" = "Удалить этот пользовательский источник?"
+"customGeoRoutingHint" = "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег)."
+"customGeoInvalidId" = "Некорректный идентификатор"
+"customGeoAliasesError" = "Не удалось загрузить список пользовательских geo"
+"customGeoValidationAlias" = "Псевдоним: только a-z, цифры, - и _"
+"customGeoValidationUrl" = "URL должен начинаться с http:// или https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (свой)"
+"customGeoToastList" = "Список пользовательских geo"
+"customGeoToastAdd" = "Добавить пользовательский geo"
+"customGeoToastUpdate" = "Изменить пользовательский geo"
+"customGeoToastDelete" = "Пользовательский geo-файл «{{ .fileName }}» удалён"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» обновлен"
+"customGeoErrInvalidType" = "Тип должен быть geosite или geoip"
+"customGeoErrAliasRequired" = "Укажите псевдоним"
+"customGeoErrAliasPattern" = "Псевдоним содержит недопустимые символы"
+"customGeoErrAliasReserved" = "Этот псевдоним зарезервирован"
+"customGeoErrUrlRequired" = "Укажите URL"
+"customGeoErrInvalidUrl" = "Некорректный URL"
+"customGeoErrUrlScheme" = "URL должен использовать http или https"
+"customGeoErrUrlHost" = "Некорректный хост URL"
+"customGeoErrDuplicateAlias" = "Такой псевдоним уже используется для этого типа"
+"customGeoErrNotFound" = "Источник не найден"
+"customGeoErrDownload" = "Ошибка загрузки"
+"customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
 "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
 "logs" = "Журнал"
 "config" = "Конфигурация"

+ 41 - 0
web/translation/translate.tr_TR.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
 "getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
 "getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
+"customGeoTitle" = "Özel GeoSite / GeoIP"
+"customGeoAdd" = "Ekle"
+"customGeoType" = "Tür"
+"customGeoAlias" = "Takma ad"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Etkin"
+"customGeoLastUpdated" = "Son güncelleme"
+"customGeoExtColumn" = "Yönlendirme (ext:…)"
+"customGeoToastUpdateAll" = "Tüm özel kaynaklar güncellendi"
+"customGeoActions" = "İşlemler"
+"customGeoEdit" = "Düzenle"
+"customGeoDelete" = "Sil"
+"customGeoDownload" = "Şimdi güncelle"
+"customGeoModalAdd" = "Özel geo ekle"
+"customGeoModalEdit" = "Özel geo düzenle"
+"customGeoModalSave" = "Kaydet"
+"customGeoDeleteConfirm" = "Bu özel geo kaynağını silinsin mi?"
+"customGeoRoutingHint" = "Yönlendirme kurallarında değer sütununu ext:dosya.dat:etiket olarak kullanın (etiketi değiştirin)."
+"customGeoInvalidId" = "Geçersiz kaynak kimliği"
+"customGeoAliasesError" = "Özel geo takma adları yüklenemedi"
+"customGeoValidationAlias" = "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir"
+"customGeoValidationUrl" = "URL http:// veya https:// ile başlamalıdır"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (özel)"
+"customGeoToastList" = "Özel geo listesi"
+"customGeoToastAdd" = "Özel geo ekle"
+"customGeoToastUpdate" = "Özel geo güncelle"
+"customGeoToastDelete" = "Özel geofile \"{{ .fileName }}\" silindi"
+"customGeoToastDownload" = "\"{{ .fileName }}\" geofile güncellendi"
+"customGeoErrInvalidType" = "Tür geosite veya geoip olmalıdır"
+"customGeoErrAliasRequired" = "Takma ad gerekli"
+"customGeoErrAliasPattern" = "Takma ad izin verilmeyen karakterler içeriyor"
+"customGeoErrAliasReserved" = "Bu takma ad ayrılmış"
+"customGeoErrUrlRequired" = "URL gerekli"
+"customGeoErrInvalidUrl" = "URL geçersiz"
+"customGeoErrUrlScheme" = "URL http veya https kullanmalıdır"
+"customGeoErrUrlHost" = "URL ana bilgisayarı geçersiz"
+"customGeoErrDuplicateAlias" = "Bu takma ad bu tür için zaten kullanılıyor"
+"customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
+"customGeoErrDownload" = "İndirme başarısız"
+"customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
 
 [pages.inbounds]
 "allTimeTraffic" = "Toplam Trafik"

+ 41 - 0
web/translation/translate.uk_UA.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Виникла помилка під час читання бази даних"
 "getDatabaseError" = "Виникла помилка під час отримання бази даних"
 "getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
+"customGeoTitle" = "Користувацькі GeoSite / GeoIP"
+"customGeoAdd" = "Додати"
+"customGeoType" = "Тип"
+"customGeoAlias" = "Псевдонім"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Увімкнено"
+"customGeoLastUpdated" = "Оновлено"
+"customGeoExtColumn" = "Маршрутизація (ext:…)"
+"customGeoToastUpdateAll" = "Усі користувацькі джерела оновлено"
+"customGeoActions" = "Дії"
+"customGeoEdit" = "Змінити"
+"customGeoDelete" = "Видалити"
+"customGeoDownload" = "Оновити зараз"
+"customGeoModalAdd" = "Додати користувацький geo"
+"customGeoModalEdit" = "Змінити користувацький geo"
+"customGeoModalSave" = "Зберегти"
+"customGeoDeleteConfirm" = "Видалити це джерело geo?"
+"customGeoRoutingHint" = "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег)."
+"customGeoInvalidId" = "Некоректний ідентифікатор ресурсу"
+"customGeoAliasesError" = "Не вдалося завантажити псевдоніми geo"
+"customGeoValidationAlias" = "Псевдонім: лише a-z, цифри, - і _"
+"customGeoValidationUrl" = "URL має починатися з http:// або https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (власний)"
+"customGeoToastList" = "Список користувацьких geo"
+"customGeoToastAdd" = "Додати користувацький geo"
+"customGeoToastUpdate" = "Оновити користувацький geo"
+"customGeoToastDelete" = "Користувацький geofile «{{ .fileName }}» видалено"
+"customGeoToastDownload" = "Geofile «{{ .fileName }}» оновлено"
+"customGeoErrInvalidType" = "Тип має бути geosite або geoip"
+"customGeoErrAliasRequired" = "Потрібен псевдонім"
+"customGeoErrAliasPattern" = "Псевдонім містить недопустимі символи"
+"customGeoErrAliasReserved" = "Цей псевдонім зарезервовано"
+"customGeoErrUrlRequired" = "Потрібен URL"
+"customGeoErrInvalidUrl" = "Некоректний URL"
+"customGeoErrUrlScheme" = "URL має використовувати http або https"
+"customGeoErrUrlHost" = "Некоректний хост URL"
+"customGeoErrDuplicateAlias" = "Цей псевдонім уже використовується для цього типу"
+"customGeoErrNotFound" = "Джерело geo не знайдено"
+"customGeoErrDownload" = "Помилка завантаження"
+"customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
 
 [pages.inbounds]
 "allTimeTraffic" = "Загальний трафік"

+ 41 - 0
web/translation/translate.vi_VN.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
 "getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
 "getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
+"customGeoTitle" = "GeoSite / GeoIP tùy chỉnh"
+"customGeoAdd" = "Thêm"
+"customGeoType" = "Loại"
+"customGeoAlias" = "Bí danh"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "Bật"
+"customGeoLastUpdated" = "Cập nhật lần cuối"
+"customGeoExtColumn" = "Định tuyến (ext:…)"
+"customGeoToastUpdateAll" = "Đã cập nhật tất cả nguồn tùy chỉnh"
+"customGeoActions" = "Thao tác"
+"customGeoEdit" = "Sửa"
+"customGeoDelete" = "Xóa"
+"customGeoDownload" = "Cập nhật ngay"
+"customGeoModalAdd" = "Thêm geo tùy chỉnh"
+"customGeoModalEdit" = "Sửa geo tùy chỉnh"
+"customGeoModalSave" = "Lưu"
+"customGeoDeleteConfirm" = "Xóa nguồn geo tùy chỉnh này?"
+"customGeoRoutingHint" = "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag)."
+"customGeoInvalidId" = "ID tài nguyên không hợp lệ"
+"customGeoAliasesError" = "Không tải được bí danh geo tùy chỉnh"
+"customGeoValidationAlias" = "Bí danh chỉ gồm chữ thường, số, - và _"
+"customGeoValidationUrl" = "URL phải bắt đầu bằng http:// hoặc https://"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = " (tùy chỉnh)"
+"customGeoToastList" = "Danh sách geo tùy chỉnh"
+"customGeoToastAdd" = "Thêm geo tùy chỉnh"
+"customGeoToastUpdate" = "Cập nhật geo tùy chỉnh"
+"customGeoToastDelete" = "Đã xóa geofile tùy chỉnh “{{ .fileName }}”"
+"customGeoToastDownload" = "Đã cập nhật geofile “{{ .fileName }}”"
+"customGeoErrInvalidType" = "Loại phải là geosite hoặc geoip"
+"customGeoErrAliasRequired" = "Cần bí danh"
+"customGeoErrAliasPattern" = "Bí danh có ký tự không hợp lệ"
+"customGeoErrAliasReserved" = "Bí danh này được dành riêng"
+"customGeoErrUrlRequired" = "Cần URL"
+"customGeoErrInvalidUrl" = "URL không hợp lệ"
+"customGeoErrUrlScheme" = "URL phải dùng http hoặc https"
+"customGeoErrUrlHost" = "Máy chủ URL không hợp lệ"
+"customGeoErrDuplicateAlias" = "Bí danh này đã dùng cho loại này"
+"customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
+"customGeoErrDownload" = "Tải xuống thất bại"
+"customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tổng Lưu Lượng"

+ 41 - 0
web/translation/translate.zh_CN.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "读取数据库时出错"
 "getDatabaseError" = "检索数据库时出错"
 "getConfigError" = "检索配置文件时出错"
+"customGeoTitle" = "自定义 GeoSite / GeoIP"
+"customGeoAdd" = "添加"
+"customGeoType" = "类型"
+"customGeoAlias" = "别名"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "启用"
+"customGeoLastUpdated" = "上次更新"
+"customGeoExtColumn" = "路由 (ext:…)"
+"customGeoToastUpdateAll" = "所有自定义来源已更新"
+"customGeoActions" = "操作"
+"customGeoEdit" = "编辑"
+"customGeoDelete" = "删除"
+"customGeoDownload" = "立即更新"
+"customGeoModalAdd" = "添加自定义 geo"
+"customGeoModalEdit" = "编辑自定义 geo"
+"customGeoModalSave" = "保存"
+"customGeoDeleteConfirm" = "删除此自定义 geo 源?"
+"customGeoRoutingHint" = "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。"
+"customGeoInvalidId" = "无效的资源 ID"
+"customGeoAliasesError" = "加载自定义 geo 别名失败"
+"customGeoValidationAlias" = "别名只能包含小写字母、数字、- 和 _"
+"customGeoValidationUrl" = "URL 必须以 http:// 或 https:// 开头"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(自定义)"
+"customGeoToastList" = "自定义 geo 列表"
+"customGeoToastAdd" = "添加自定义 geo"
+"customGeoToastUpdate" = "更新自定义 geo"
+"customGeoToastDelete" = "自定义 geofile「{{ .fileName }}」已删除"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
+"customGeoErrInvalidType" = "类型必须是 geosite 或 geoip"
+"customGeoErrAliasRequired" = "请填写别名"
+"customGeoErrAliasPattern" = "别名包含不允许的字符"
+"customGeoErrAliasReserved" = "该别名已保留"
+"customGeoErrUrlRequired" = "请填写 URL"
+"customGeoErrInvalidUrl" = "URL 无效"
+"customGeoErrUrlScheme" = "URL 必须使用 http 或 https"
+"customGeoErrUrlHost" = "URL 主机无效"
+"customGeoErrDuplicateAlias" = "此类型下已使用该别名"
+"customGeoErrNotFound" = "未找到自定义 geo 源"
+"customGeoErrDownload" = "下载失败"
+"customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
 
 [pages.inbounds]
 "allTimeTraffic" = "累计总流量"

+ 41 - 0
web/translation/translate.zh_TW.toml

@@ -164,6 +164,47 @@
 "readDatabaseError" = "讀取資料庫時發生錯誤"
 "getDatabaseError" = "檢索資料庫時發生錯誤"
 "getConfigError" = "檢索設定檔時發生錯誤"
+"customGeoTitle" = "自訂 GeoSite / GeoIP"
+"customGeoAdd" = "新增"
+"customGeoType" = "類型"
+"customGeoAlias" = "別名"
+"customGeoUrl" = "URL"
+"customGeoEnabled" = "啟用"
+"customGeoLastUpdated" = "上次更新"
+"customGeoExtColumn" = "路由 (ext:…)"
+"customGeoToastUpdateAll" = "所有自訂來源已更新"
+"customGeoActions" = "操作"
+"customGeoEdit" = "編輯"
+"customGeoDelete" = "刪除"
+"customGeoDownload" = "立即更新"
+"customGeoModalAdd" = "新增自訂 geo"
+"customGeoModalEdit" = "編輯自訂 geo"
+"customGeoModalSave" = "儲存"
+"customGeoDeleteConfirm" = "刪除此自訂 geo 來源?"
+"customGeoRoutingHint" = "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。"
+"customGeoInvalidId" = "無效的資源 ID"
+"customGeoAliasesError" = "載入自訂 geo 別名失敗"
+"customGeoValidationAlias" = "別名只能包含小寫字母、數字、- 和 _"
+"customGeoValidationUrl" = "URL 必須以 http:// 或 https:// 開頭"
+"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
+"customGeoAliasLabelSuffix" = "(自訂)"
+"customGeoToastList" = "自訂 geo 清單"
+"customGeoToastAdd" = "新增自訂 geo"
+"customGeoToastUpdate" = "更新自訂 geo"
+"customGeoToastDelete" = "自訂 geofile「{{ .fileName }}」已刪除"
+"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
+"customGeoErrInvalidType" = "類型必須是 geosite 或 geoip"
+"customGeoErrAliasRequired" = "請填寫別名"
+"customGeoErrAliasPattern" = "別名包含不允許的字元"
+"customGeoErrAliasReserved" = "此別名已保留"
+"customGeoErrUrlRequired" = "請填寫 URL"
+"customGeoErrInvalidUrl" = "URL 無效"
+"customGeoErrUrlScheme" = "URL 必須使用 http 或 https"
+"customGeoErrUrlHost" = "URL 主機無效"
+"customGeoErrDuplicateAlias" = "此類型已使用該別名"
+"customGeoErrNotFound" = "找不到自訂 geo 來源"
+"customGeoErrDownload" = "下載失敗"
+"customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
 
 [pages.inbounds]
 "allTimeTraffic" = "累計總流量"

+ 8 - 4
web/web.go

@@ -101,9 +101,10 @@ type Server struct {
 	api   *controller.APIController
 	ws    *controller.WebSocketController
 
-	xrayService    service.XrayService
-	settingService service.SettingService
-	tgbotService   service.Tgbot
+	xrayService      service.XrayService
+	settingService   service.SettingService
+	tgbotService     service.Tgbot
+	customGeoService *service.CustomGeoService
 
 	wsHub *websocket.Hub
 
@@ -268,7 +269,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	s.index = controller.NewIndexController(g)
 	s.panel = controller.NewXUIController(g)
-	s.api = controller.NewAPIController(g)
+	s.api = controller.NewAPIController(g, s.customGeoService)
 
 	// Initialize WebSocket hub
 	s.wsHub = websocket.NewHub()
@@ -295,6 +296,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
 func (s *Server) startTask() {
+	s.customGeoService.EnsureOnStartup()
 	err := s.xrayService.RestartXray(true)
 	if err != nil {
 		logger.Warning("start xray failed:", err)
@@ -388,6 +390,8 @@ func (s *Server) Start() (err error) {
 	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
 	s.cron.Start()
 
+	s.customGeoService = service.NewCustomGeoService()
+
 	engine, err := s.initRouter()
 	if err != nil {
 		return err

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio