Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.ar_EG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` يصطدمان بنفس الحجز.

## البدء السريع

```
Expand Down
8 changes: 8 additions & 0 deletions README.es_ES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
8 changes: 8 additions & 0 deletions README.fa_IR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` به یک رزرو یکسان می‌خورند.

## شروع سریع

```
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.ru_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` попадают под одну и ту же зарезервированную запись.

## Быстрый старт

```
Expand Down
8 changes: 8 additions & 0 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 视为同一保留项。

## 快速开始

```
Expand Down
4 changes: 2 additions & 2 deletions database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions database/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion web/assets/css/custom.min.css

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions web/controller/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
174 changes: 174 additions & 0 deletions web/controller/custom_geo.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 11 additions & 2 deletions web/controller/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading