This commit is contained in:
Vladislav Tupikin 2026-03-31 23:42:37 +03:00 committed by GitHub
commit 1ef8816e4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1974 additions and 14 deletions

View file

@ -22,6 +22,14 @@
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية. كمشروع محسن من مشروع 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` يصطدمان بنفس الحجز.
## البدء السريع ## البدء السريع
``` ```

View file

@ -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. 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 ## Inicio Rápido
``` ```

View file

@ -22,6 +22,14 @@
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد. به عنوان یک نسخه بهبود یافته از پروژه اصلی 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` به یک رزرو یکسان می‌خورند.
## شروع سریع ## شروع سریع
``` ```

View file

@ -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. 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 ## Quick Start
```bash ```bash

View file

@ -22,6 +22,14 @@
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции. Как улучшенная версия оригинального проекта 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` попадают под одну и ту же зарезервированную запись.
## Быстрый старт ## Быстрый старт
``` ```

View file

@ -22,6 +22,14 @@
作为原始 X-UI 项目的增强版本3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。 作为原始 X-UI 项目的增强版本3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
## 自定义 GeoSite / GeoIPDAT
管理员可在面板中从 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` 视为同一保留项。
## 快速开始 ## 快速开始
``` ```

View file

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

View file

@ -104,6 +104,18 @@ type Setting struct {
Value string `json:"value" form:"value"` 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. // Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct { type Client struct {
ID string `json:"id"` // Unique client identifier ID string `json:"id"` // Unique client identifier

File diff suppressed because one or more lines are too long

View file

@ -18,9 +18,9 @@ type APIController struct {
} }
// NewAPIController creates a new APIController instance and initializes its routes. // 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 := &APIController{}
a.initRouter(g) a.initRouter(g, customGeo)
return a 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. // 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 // Main API group
api := g.Group("/panel/api") api := g.Group("/panel/api")
api.Use(a.checkAPIAuth) api.Use(a.checkAPIAuth)
@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
server := api.Group("/server") server := api.Group("/server")
a.serverController = NewServerController(server) a.serverController = NewServerController(server)
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes // Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot) api.GET("/backuptotgbot", a.BackuptoTgbot)
} }

View file

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

View file

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

View file

@ -2,6 +2,20 @@
{{ template "page/head_end" .}} {{ template "page/head_end" .}}
{{ template "page/body_start" .}} {{ 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-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
<a-sidebar></a-sidebar> <a-sidebar></a-sidebar>
<a-layout id="content-layout"> <a-layout id="content-layout">
@ -107,7 +121,7 @@
</a-row> </a-row>
</span> </span>
<template slot="content"> <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> </template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color" <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" /> :class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
@ -115,7 +129,7 @@
</template> </template>
</template> </template>
<template #actions> <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> <a-icon type="bars"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span> <span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
</a-space> </a-space>
@ -330,8 +344,65 @@
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button></div> "pages.index.geofilesUpdateAll" }}</a-button></div>
</a-collapse-panel> </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-collapse>
</a-modal> </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" <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
:class="themeSwitcher.currentTheme" width="800px" footer=""> :class="themeSwitcher.currentTheme" width="800px" footer="">
<template slot="title"> <template slot="title">
@ -872,6 +943,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({ const app = new Vue({
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
el: '#app', el: '#app',
@ -895,6 +972,25 @@
showAlert: false, showAlert: false,
showIp: false, showIp: false,
ipLimitEnable: 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: { methods: {
loading(spinning, tip = '{{ i18n "loading"}}') { loading(spinning, tip = '{{ i18n "loading"}}') {
@ -963,6 +1059,128 @@
return; return;
} }
versionModal.show(msg.obj); 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) { switchV2rayVersion(version) {
this.$confirm({ this.$confirm({

View file

@ -262,6 +262,7 @@
refreshing: false, refreshing: false,
restartResult: '', restartResult: '',
showAlert: false, showAlert: false,
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
advSettings: 'xraySetting', advSettings: 'xraySetting',
obsSettings: '', obsSettings: '',
cm: null, cm: null,
@ -1057,6 +1058,31 @@
}, },
showWarp() { showWarp() {
warpModal.show(); 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() { async mounted() {
@ -1064,6 +1090,7 @@
this.showAlert = true; this.showAlert = true;
} }
await this.getXraySetting(); await this.getXraySetting();
await this.loadCustomGeoAliases();
await this.getXrayResult(); await this.getXrayResult();
await this.getOutboundsTraffic(); await this.getOutboundsTraffic();

603
web/service/custom_geo.go Normal file
View file

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

View file

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

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات" "readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات" "getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
"getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات" "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] [pages.inbounds]
"allTimeTraffic" = "إجمالي حركة المرور" "allTimeTraffic" = "إجمالي حركة المرور"

View file

@ -150,6 +150,47 @@
"geofilesUpdateDialogDesc" = "This will update all geofiles." "geofilesUpdateDialogDesc" = "This will update all geofiles."
"geofilesUpdateAll" = "Update all" "geofilesUpdateAll" = "Update all"
"geofileUpdatePopover" = "Geofile updated successfully" "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" "dontRefresh" = "Installation is in progress, please do not refresh this page"
"logs" = "Logs" "logs" = "Logs"
"config" = "Config" "config" = "Config"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Ocurrió un error al leer la base de datos" "readDatabaseError" = "Ocurrió un error al leer la base de datos"
"getDatabaseError" = "Ocurrió un error al obtener 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" "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] [pages.inbounds]
"allTimeTraffic" = "Tráfico Total" "allTimeTraffic" = "Tráfico Total"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "خطا در خواندن پایگاه داده" "readDatabaseError" = "خطا در خواندن پایگاه داده"
"getDatabaseError" = "خطا در دریافت پایگاه داده" "getDatabaseError" = "خطا در دریافت پایگاه داده"
"getConfigError" = "خطا در دریافت فایل پیکربندی" "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] [pages.inbounds]
"allTimeTraffic" = "کل ترافیک" "allTimeTraffic" = "کل ترافیک"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Terjadi kesalahan saat membaca database" "readDatabaseError" = "Terjadi kesalahan saat membaca database"
"getDatabaseError" = "Terjadi kesalahan saat mengambil database" "getDatabaseError" = "Terjadi kesalahan saat mengambil database"
"getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi" "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] [pages.inbounds]
"allTimeTraffic" = "Total Lalu Lintas" "allTimeTraffic" = "Total Lalu Lintas"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました" "readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
"getDatabaseError" = "データベースの取得中にエラーが発生しました" "getDatabaseError" = "データベースの取得中にエラーが発生しました"
"getConfigError" = "設定ファイルの取得中にエラーが発生しました" "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] [pages.inbounds]
"allTimeTraffic" = "総トラフィック" "allTimeTraffic" = "総トラフィック"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Ocorreu um erro ao ler o banco de dados" "readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
"getDatabaseError" = "Ocorreu um erro ao recuperar 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" "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] [pages.inbounds]
"allTimeTraffic" = "Tráfego Total" "allTimeTraffic" = "Tráfego Total"

View file

@ -150,6 +150,47 @@
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы." "geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
"geofilesUpdateAll" = "Обновить все" "geofilesUpdateAll" = "Обновить все"
"geofileUpdatePopover" = "Геофайлы успешно обновлены" "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" = "Установка в процессе. Не обновляйте страницу" "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
"logs" = "Журнал" "logs" = "Журнал"
"config" = "Конфигурация" "config" = "Конфигурация"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Veritabanı okunurken bir hata oluştu" "readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
"getDatabaseError" = "Veritabanı alınırken 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" "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] [pages.inbounds]
"allTimeTraffic" = "Toplam Trafik" "allTimeTraffic" = "Toplam Trafik"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Виникла помилка під час читання бази даних" "readDatabaseError" = "Виникла помилка під час читання бази даних"
"getDatabaseError" = "Виникла помилка під час отримання бази даних" "getDatabaseError" = "Виникла помилка під час отримання бази даних"
"getConfigError" = "Виникла помилка під час отримання файлу конфігурації" "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] [pages.inbounds]
"allTimeTraffic" = "Загальний трафік" "allTimeTraffic" = "Загальний трафік"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu" "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" "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" "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] [pages.inbounds]
"allTimeTraffic" = "Tổng Lưu Lượng" "allTimeTraffic" = "Tổng Lưu Lượng"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "读取数据库时出错" "readDatabaseError" = "读取数据库时出错"
"getDatabaseError" = "检索数据库时出错" "getDatabaseError" = "检索数据库时出错"
"getConfigError" = "检索配置文件时出错" "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] [pages.inbounds]
"allTimeTraffic" = "累计总流量" "allTimeTraffic" = "累计总流量"

View file

@ -164,6 +164,47 @@
"readDatabaseError" = "讀取資料庫時發生錯誤" "readDatabaseError" = "讀取資料庫時發生錯誤"
"getDatabaseError" = "檢索資料庫時發生錯誤" "getDatabaseError" = "檢索資料庫時發生錯誤"
"getConfigError" = "檢索設定檔時發生錯誤" "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] [pages.inbounds]
"allTimeTraffic" = "累計總流量" "allTimeTraffic" = "累計總流量"

View file

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