mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-14 19:45:47 +00:00
feat: add custom geosite/geoip URL sources
Register DB model, panel API, index/xray UI, and i18n.
This commit is contained in:
parent
38d87230d3
commit
0b45732422
30 changed files with 1974 additions and 14 deletions
|
|
@ -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` يصطدمان بنفس الحجز.
|
||||
|
||||
## البدء السريع
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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` به یک رزرو یکسان میخورند.
|
||||
|
||||
## شروع سریع
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` попадают под одну и ту же зарезервированную запись.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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` 视为同一保留项。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func initModels() error {
|
|||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
|
|
@ -175,9 +176,8 @@ func GetDB() *gorm.DB {
|
|||
return db
|
||||
}
|
||||
|
||||
// IsNotFound checks if the given error is a GORM record not found error.
|
||||
func IsNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -18,9 +18,9 @@ type APIController struct {
|
|||
}
|
||||
|
||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||
func NewAPIController(g *gin.RouterGroup) *APIController {
|
||||
func NewAPIController(g *gin.RouterGroup, customGeo service.CustomGeoService) *APIController {
|
||||
a := &APIController{}
|
||||
a.initRouter(g)
|
||||
a.initRouter(g, customGeo)
|
||||
return a
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||
}
|
||||
|
||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo service.CustomGeoService) {
|
||||
// Main API group
|
||||
api := g.Group("/panel/api")
|
||||
api.Use(a.checkAPIAuth)
|
||||
|
|
@ -48,6 +48,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||
server := api.Group("/server")
|
||||
a.serverController = NewServerController(server)
|
||||
|
||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||
|
||||
// Extra routes
|
||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
|
|
|||
174
web/controller/custom_geo.go
Normal file
174
web/controller/custom_geo.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@
|
|||
{{ template "page/head_end" .}}
|
||||
|
||||
{{ template "page/body_start" .}}
|
||||
<style>
|
||||
body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
|
||||
background: var(--dark-color-surface-200, #222d42);
|
||||
border: 1px solid var(--dark-color-stroke, #2c3950);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
|
||||
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
|
||||
background: var(--dark-color-surface-700, #111929);
|
||||
border-color: var(--dark-color-stroke, #2c3950);
|
||||
}
|
||||
</style>
|
||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
|
||||
<a-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
|
|
@ -107,7 +121,7 @@
|
|||
</a-row>
|
||||
</span>
|
||||
<template slot="content">
|
||||
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
|
||||
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
|
||||
</template>
|
||||
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
|
||||
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
|
||||
|
|
@ -115,7 +129,7 @@
|
|||
</template>
|
||||
</template>
|
||||
<template #actions>
|
||||
<a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
||||
<a-space v-if="ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
|
||||
<a-icon type="bars"></a-icon>
|
||||
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
|
||||
</a-space>
|
||||
|
|
@ -330,8 +344,65 @@
|
|||
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
|
||||
"pages.index.geofilesUpdateAll" }}</a-button></div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert type="info" show-icon class="mb-10"
|
||||
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
|
||||
<div class="mb-10">
|
||||
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
|
||||
{{ i18n "pages.index.customGeoAdd" }}
|
||||
</a-button>
|
||||
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
|
||||
"pages.index.geofilesUpdateAll" }}</a-button>
|
||||
</div>
|
||||
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
|
||||
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
|
||||
<template slot="extDat" slot-scope="text, record">
|
||||
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
|
||||
</template>
|
||||
<template slot="lastUpdatedAt" slot-scope="text, record">
|
||||
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template slot="action" slot-scope="text, record">
|
||||
<a-space size="small">
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
|
||||
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
|
||||
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
|
||||
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-modal>
|
||||
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
|
||||
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
|
||||
:class="themeSwitcher.currentTheme">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
|
||||
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
|
||||
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
|
||||
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
|
||||
:class="themeSwitcher.currentTheme" width="800px" footer="">
|
||||
<template slot="title">
|
||||
|
|
@ -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({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#app',
|
||||
|
|
@ -895,6 +972,25 @@
|
|||
showAlert: false,
|
||||
showIp: false,
|
||||
ipLimitEnable: false,
|
||||
customGeoColumns,
|
||||
customGeoList: [],
|
||||
customGeoLoading: false,
|
||||
customGeoUpdatingAll: false,
|
||||
customGeoActionId: null,
|
||||
customGeoModal: {
|
||||
visible: false,
|
||||
editId: null,
|
||||
saving: false,
|
||||
form: {
|
||||
type: 'geosite',
|
||||
alias: '',
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
customGeoValidation: {
|
||||
alias: '{{ i18n "pages.index.customGeoValidationAlias" }}',
|
||||
url: '{{ i18n "pages.index.customGeoValidationUrl" }}',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
loading(spinning, tip = '{{ i18n "loading"}}') {
|
||||
|
|
@ -963,6 +1059,128 @@
|
|||
return;
|
||||
}
|
||||
versionModal.show(msg.obj);
|
||||
this.loadCustomGeo();
|
||||
},
|
||||
customGeoFormatTime(ts) {
|
||||
if (!ts) return '';
|
||||
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
|
||||
},
|
||||
customGeoExtDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
},
|
||||
async loadCustomGeo() {
|
||||
this.customGeoLoading = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg.success && Array.isArray(msg.obj)) {
|
||||
this.customGeoList = msg.obj;
|
||||
}
|
||||
} finally {
|
||||
this.customGeoLoading = false;
|
||||
}
|
||||
},
|
||||
openCustomGeoModal(record) {
|
||||
if (record) {
|
||||
this.customGeoModal.editId = record.id;
|
||||
this.customGeoModal.form = {
|
||||
type: record.type,
|
||||
alias: record.alias,
|
||||
url: record.url,
|
||||
};
|
||||
} else {
|
||||
this.customGeoModal.editId = null;
|
||||
this.customGeoModal.form = {
|
||||
type: 'geosite',
|
||||
alias: '',
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
this.customGeoModal.visible = true;
|
||||
},
|
||||
validateCustomGeoForm() {
|
||||
const f = this.customGeoModal.form;
|
||||
const re = /^[a-z0-9_-]+$/;
|
||||
if (!re.test(f.alias || '')) {
|
||||
this.$message.error(this.customGeoValidation.alias);
|
||||
return false;
|
||||
}
|
||||
const u = (f.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
this.$message.error(this.customGeoValidation.url);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
this.$message.error(this.customGeoValidation.url);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(this.customGeoValidation.url);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async submitCustomGeo() {
|
||||
if (!this.validateCustomGeoForm()) {
|
||||
return;
|
||||
}
|
||||
const f = this.customGeoModal.form;
|
||||
this.customGeoModal.saving = true;
|
||||
try {
|
||||
let msg;
|
||||
if (this.customGeoModal.editId) {
|
||||
msg = await HttpUtil.post(`/panel/api/custom-geo/update/${this.customGeoModal.editId}`, f);
|
||||
} else {
|
||||
msg = await HttpUtil.post('/panel/api/custom-geo/add', f);
|
||||
}
|
||||
if (msg && msg.success) {
|
||||
this.customGeoModal.visible = false;
|
||||
await this.loadCustomGeo();
|
||||
}
|
||||
} finally {
|
||||
this.customGeoModal.saving = false;
|
||||
}
|
||||
},
|
||||
confirmDeleteCustomGeo(record) {
|
||||
this.$confirm({
|
||||
title: '{{ i18n "pages.index.customGeoDelete" }}',
|
||||
content: '{{ i18n "pages.index.customGeoDeleteConfirm" }}',
|
||||
okText: '{{ i18n "confirm"}}',
|
||||
cancelText: '{{ i18n "cancel"}}',
|
||||
class: themeSwitcher.currentTheme,
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg.success) {
|
||||
await this.loadCustomGeo();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
async downloadCustomGeo(id) {
|
||||
this.customGeoActionId = id;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg.success) {
|
||||
await this.loadCustomGeo();
|
||||
}
|
||||
} finally {
|
||||
this.customGeoActionId = null;
|
||||
}
|
||||
},
|
||||
async updateAllCustomGeo() {
|
||||
this.customGeoUpdatingAll = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
|
||||
await this.loadCustomGeo();
|
||||
}
|
||||
} finally {
|
||||
this.customGeoUpdatingAll = false;
|
||||
}
|
||||
},
|
||||
switchV2rayVersion(version) {
|
||||
this.$confirm({
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@
|
|||
refreshing: false,
|
||||
restartResult: '',
|
||||
showAlert: false,
|
||||
customGeoAliasLabelSuffix: '{{ i18n "pages.index.customGeoAliasLabelSuffix" }}',
|
||||
advSettings: 'xraySetting',
|
||||
obsSettings: '',
|
||||
cm: null,
|
||||
|
|
@ -1057,6 +1058,31 @@
|
|||
},
|
||||
showWarp() {
|
||||
warpModal.show();
|
||||
},
|
||||
async loadCustomGeoAliases() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
|
||||
if (!msg.success) {
|
||||
console.warn('Failed to load custom geo aliases:', msg.msg || 'request failed');
|
||||
return;
|
||||
}
|
||||
if (!msg.obj) return;
|
||||
const { geoip = [], geosite = [] } = msg.obj;
|
||||
const geoSuffix = this.customGeoAliasLabelSuffix || '';
|
||||
geoip.forEach((x) => {
|
||||
this.settingsData.IPsOptions.push({
|
||||
label: x.alias + geoSuffix,
|
||||
value: x.extExample,
|
||||
});
|
||||
});
|
||||
geosite.forEach((x) => {
|
||||
const opt = { label: x.alias + geoSuffix, value: x.extExample };
|
||||
this.settingsData.DomainsOptions.push(opt);
|
||||
this.settingsData.BlockDomainsOptions.push(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load custom geo aliases:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
|
|
@ -1064,6 +1090,7 @@
|
|||
this.showAlert = true;
|
||||
}
|
||||
await this.getXraySetting();
|
||||
await this.loadCustomGeoAliases();
|
||||
await this.getXrayResult();
|
||||
await this.getOutboundsTraffic();
|
||||
|
||||
|
|
|
|||
603
web/service/custom_geo.go
Normal file
603
web/service/custom_geo.go
Normal 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
|
||||
}
|
||||
330
web/service/custom_geo_test.go
Normal file
330
web/service/custom_geo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "حدث خطأ أثناء قراءة قاعدة البيانات"
|
||||
"getDatabaseError" = "حدث خطأ أثناء استرجاع قاعدة البيانات"
|
||||
"getConfigError" = "حدث خطأ أثناء استرجاع ملف الإعدادات"
|
||||
"customGeoTitle" = "GeoSite / GeoIP مخصص"
|
||||
"customGeoAdd" = "إضافة"
|
||||
"customGeoType" = "النوع"
|
||||
"customGeoAlias" = "الاسم المستعار"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "مفعّل"
|
||||
"customGeoLastUpdated" = "آخر تحديث"
|
||||
"customGeoExtColumn" = "التوجيه (ext:…)"
|
||||
"customGeoToastUpdateAll" = "تم تحديث جميع المصادر المخصصة"
|
||||
"customGeoActions" = "إجراءات"
|
||||
"customGeoEdit" = "تعديل"
|
||||
"customGeoDelete" = "حذف"
|
||||
"customGeoDownload" = "تحديث الآن"
|
||||
"customGeoModalAdd" = "إضافة geo مخصص"
|
||||
"customGeoModalEdit" = "تعديل geo مخصص"
|
||||
"customGeoModalSave" = "حفظ"
|
||||
"customGeoDeleteConfirm" = "حذف مصدر geo المخصص هذا؟"
|
||||
"customGeoRoutingHint" = "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag)."
|
||||
"customGeoInvalidId" = "معرّف المورد غير صالح"
|
||||
"customGeoAliasesError" = "تعذّر تحميل أسماء geo المخصصة"
|
||||
"customGeoValidationAlias" = "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط"
|
||||
"customGeoValidationUrl" = "يجب أن يبدأ الرابط بـ http:// أو https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (مخصص)"
|
||||
"customGeoToastList" = "قائمة geo المخصص"
|
||||
"customGeoToastAdd" = "إضافة geo مخصص"
|
||||
"customGeoToastUpdate" = "تحديث geo مخصص"
|
||||
"customGeoToastDelete" = "تم حذف geofile «{{ .fileName }}» المخصص"
|
||||
"customGeoToastDownload" = "تم تحديث geofile «{{ .fileName }}»"
|
||||
"customGeoErrInvalidType" = "يجب أن يكون النوع geosite أو geoip"
|
||||
"customGeoErrAliasRequired" = "الاسم المستعار مطلوب"
|
||||
"customGeoErrAliasPattern" = "الاسم المستعار يحتوي على أحرف غير مسموحة"
|
||||
"customGeoErrAliasReserved" = "هذا الاسم محجوز"
|
||||
"customGeoErrUrlRequired" = "الرابط مطلوب"
|
||||
"customGeoErrInvalidUrl" = "الرابط غير صالح"
|
||||
"customGeoErrUrlScheme" = "يجب أن يستخدم الرابط http أو https"
|
||||
"customGeoErrUrlHost" = "مضيف الرابط غير صالح"
|
||||
"customGeoErrDuplicateAlias" = "هذا الاسم مستخدم مسبقاً لهذا النوع"
|
||||
"customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
|
||||
"customGeoErrDownload" = "فشل التنزيل"
|
||||
"customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "إجمالي حركة المرور"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,47 @@
|
|||
"geofilesUpdateDialogDesc" = "This will update all geofiles."
|
||||
"geofilesUpdateAll" = "Update all"
|
||||
"geofileUpdatePopover" = "Geofile updated successfully"
|
||||
"customGeoTitle" = "Custom GeoSite / GeoIP"
|
||||
"customGeoAdd" = "Add"
|
||||
"customGeoType" = "Type"
|
||||
"customGeoAlias" = "Alias"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Enabled"
|
||||
"customGeoLastUpdated" = "Last updated"
|
||||
"customGeoExtColumn" = "Routing (ext:…)"
|
||||
"customGeoToastUpdateAll" = "All custom geo sources updated"
|
||||
"customGeoActions" = "Actions"
|
||||
"customGeoEdit" = "Edit"
|
||||
"customGeoDelete" = "Delete"
|
||||
"customGeoDownload" = "Update now"
|
||||
"customGeoModalAdd" = "Add custom geo"
|
||||
"customGeoModalEdit" = "Edit custom geo"
|
||||
"customGeoModalSave" = "Save"
|
||||
"customGeoDeleteConfirm" = "Delete this custom geo source?"
|
||||
"customGeoRoutingHint" = "In routing rules use the value column as ext:file.dat:tag (replace tag)."
|
||||
"customGeoInvalidId" = "Invalid resource id"
|
||||
"customGeoAliasesError" = "Failed to load custom geo aliases"
|
||||
"customGeoValidationAlias" = "Alias may only contain lowercase letters, digits, - and _"
|
||||
"customGeoValidationUrl" = "URL must start with http:// or https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (custom)"
|
||||
"customGeoToastList" = "Custom geo list"
|
||||
"customGeoToastAdd" = "Add custom geo"
|
||||
"customGeoToastUpdate" = "Update custom geo"
|
||||
"customGeoToastDelete" = "Custom geo file “{{ .fileName }}” deleted"
|
||||
"customGeoToastDownload" = "Geofile “{{ .fileName }}” updated"
|
||||
"customGeoErrInvalidType" = "Type must be geosite or geoip"
|
||||
"customGeoErrAliasRequired" = "Alias is required"
|
||||
"customGeoErrAliasPattern" = "Alias must match allowed characters"
|
||||
"customGeoErrAliasReserved" = "This alias is reserved"
|
||||
"customGeoErrUrlRequired" = "URL is required"
|
||||
"customGeoErrInvalidUrl" = "URL is invalid"
|
||||
"customGeoErrUrlScheme" = "URL must use http or https"
|
||||
"customGeoErrUrlHost" = "URL host is invalid"
|
||||
"customGeoErrDuplicateAlias" = "This alias is already used for this type"
|
||||
"customGeoErrNotFound" = "Custom geo source not found"
|
||||
"customGeoErrDownload" = "Download failed"
|
||||
"customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
|
||||
"dontRefresh" = "Installation is in progress, please do not refresh this page"
|
||||
"logs" = "Logs"
|
||||
"config" = "Config"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Ocurrió un error al leer la base de datos"
|
||||
"getDatabaseError" = "Ocurrió un error al obtener la base de datos"
|
||||
"getConfigError" = "Ocurrió un error al obtener el archivo de configuración"
|
||||
"customGeoTitle" = "GeoSite / GeoIP personalizados"
|
||||
"customGeoAdd" = "Añadir"
|
||||
"customGeoType" = "Tipo"
|
||||
"customGeoAlias" = "Alias"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Activado"
|
||||
"customGeoLastUpdated" = "Última actualización"
|
||||
"customGeoExtColumn" = "Enrutamiento (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Todas las fuentes personalizadas se actualizaron"
|
||||
"customGeoActions" = "Acciones"
|
||||
"customGeoEdit" = "Editar"
|
||||
"customGeoDelete" = "Eliminar"
|
||||
"customGeoDownload" = "Actualizar ahora"
|
||||
"customGeoModalAdd" = "Añadir geo personalizado"
|
||||
"customGeoModalEdit" = "Editar geo personalizado"
|
||||
"customGeoModalSave" = "Guardar"
|
||||
"customGeoDeleteConfirm" = "¿Eliminar esta fuente geo personalizada?"
|
||||
"customGeoRoutingHint" = "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta)."
|
||||
"customGeoInvalidId" = "Id de recurso no válido"
|
||||
"customGeoAliasesError" = "No se pudieron cargar los alias geo personalizados"
|
||||
"customGeoValidationAlias" = "El alias solo puede contener letras minúsculas, dígitos, - y _"
|
||||
"customGeoValidationUrl" = "La URL debe comenzar con http:// o https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (personalizado)"
|
||||
"customGeoToastList" = "Lista de geo personalizado"
|
||||
"customGeoToastAdd" = "Añadir geo personalizado"
|
||||
"customGeoToastUpdate" = "Actualizar geo personalizado"
|
||||
"customGeoToastDelete" = "Geofile personalizado «{{ .fileName }}» eliminado"
|
||||
"customGeoToastDownload" = "Geofile «{{ .fileName }}» actualizado"
|
||||
"customGeoErrInvalidType" = "El tipo debe ser geosite o geoip"
|
||||
"customGeoErrAliasRequired" = "El alias es obligatorio"
|
||||
"customGeoErrAliasPattern" = "El alias contiene caracteres no permitidos"
|
||||
"customGeoErrAliasReserved" = "Este alias está reservado"
|
||||
"customGeoErrUrlRequired" = "La URL es obligatoria"
|
||||
"customGeoErrInvalidUrl" = "La URL no es válida"
|
||||
"customGeoErrUrlScheme" = "La URL debe usar http o https"
|
||||
"customGeoErrUrlHost" = "El host de la URL no es válido"
|
||||
"customGeoErrDuplicateAlias" = "Este alias ya se usa para este tipo"
|
||||
"customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
|
||||
"customGeoErrDownload" = "Error de descarga"
|
||||
"customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Tráfico Total"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "خطا در خواندن پایگاه داده"
|
||||
"getDatabaseError" = "خطا در دریافت پایگاه داده"
|
||||
"getConfigError" = "خطا در دریافت فایل پیکربندی"
|
||||
"customGeoTitle" = "GeoSite / GeoIP سفارشی"
|
||||
"customGeoAdd" = "افزودن"
|
||||
"customGeoType" = "نوع"
|
||||
"customGeoAlias" = "نام مستعار"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "فعال"
|
||||
"customGeoLastUpdated" = "آخرین بهروزرسانی"
|
||||
"customGeoExtColumn" = "مسیریابی (ext:…)"
|
||||
"customGeoToastUpdateAll" = "همه منابع سفارشی بهروزرسانی شدند"
|
||||
"customGeoActions" = "اقدامات"
|
||||
"customGeoEdit" = "ویرایش"
|
||||
"customGeoDelete" = "حذف"
|
||||
"customGeoDownload" = "بهروزرسانی اکنون"
|
||||
"customGeoModalAdd" = "افزودن geo سفارشی"
|
||||
"customGeoModalEdit" = "ویرایش geo سفارشی"
|
||||
"customGeoModalSave" = "ذخیره"
|
||||
"customGeoDeleteConfirm" = "این منبع geo سفارشی حذف شود؟"
|
||||
"customGeoRoutingHint" = "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید)."
|
||||
"customGeoInvalidId" = "شناسه منبع نامعتبر است"
|
||||
"customGeoAliasesError" = "بارگذاری نام مستعارهای geo سفارشی ناموفق بود"
|
||||
"customGeoValidationAlias" = "نام مستعار فقط حروف کوچک، اعداد، - و _"
|
||||
"customGeoValidationUrl" = "URL باید با http:// یا https:// شروع شود"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (سفارشی)"
|
||||
"customGeoToastList" = "فهرست geo سفارشی"
|
||||
"customGeoToastAdd" = "افزودن geo سفارشی"
|
||||
"customGeoToastUpdate" = "بهروزرسانی geo سفارشی"
|
||||
"customGeoToastDelete" = "geofile سفارشی «{{ .fileName }}» حذف شد"
|
||||
"customGeoToastDownload" = "geofile «{{ .fileName }}» بهروزرسانی شد"
|
||||
"customGeoErrInvalidType" = "نوع باید geosite یا geoip باشد"
|
||||
"customGeoErrAliasRequired" = "نام مستعار لازم است"
|
||||
"customGeoErrAliasPattern" = "نام مستعار دارای نویسه نامجاز است"
|
||||
"customGeoErrAliasReserved" = "این نام مستعار رزرو است"
|
||||
"customGeoErrUrlRequired" = "URL لازم است"
|
||||
"customGeoErrInvalidUrl" = "URL نامعتبر است"
|
||||
"customGeoErrUrlScheme" = "URL باید http یا https باشد"
|
||||
"customGeoErrUrlHost" = "میزبان URL نامعتبر است"
|
||||
"customGeoErrDuplicateAlias" = "این نام مستعار برای این نوع قبلاً استفاده شده است"
|
||||
"customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
|
||||
"customGeoErrDownload" = "بارگیری ناموفق بود"
|
||||
"customGeoErrUpdateAllIncomplete" = "بهروزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "کل ترافیک"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Terjadi kesalahan saat membaca database"
|
||||
"getDatabaseError" = "Terjadi kesalahan saat mengambil database"
|
||||
"getConfigError" = "Terjadi kesalahan saat mengambil file konfigurasi"
|
||||
"customGeoTitle" = "GeoSite / GeoIP kustom"
|
||||
"customGeoAdd" = "Tambah"
|
||||
"customGeoType" = "Jenis"
|
||||
"customGeoAlias" = "Alias"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Aktif"
|
||||
"customGeoLastUpdated" = "Terakhir diperbarui"
|
||||
"customGeoExtColumn" = "Routing (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Semua sumber kustom telah diperbarui"
|
||||
"customGeoActions" = "Aksi"
|
||||
"customGeoEdit" = "Edit"
|
||||
"customGeoDelete" = "Hapus"
|
||||
"customGeoDownload" = "Perbarui sekarang"
|
||||
"customGeoModalAdd" = "Tambah geo kustom"
|
||||
"customGeoModalEdit" = "Edit geo kustom"
|
||||
"customGeoModalSave" = "Simpan"
|
||||
"customGeoDeleteConfirm" = "Hapus sumber geo kustom ini?"
|
||||
"customGeoRoutingHint" = "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag)."
|
||||
"customGeoInvalidId" = "ID sumber tidak valid"
|
||||
"customGeoAliasesError" = "Gagal memuat alias geo kustom"
|
||||
"customGeoValidationAlias" = "Alias hanya huruf kecil, angka, - dan _"
|
||||
"customGeoValidationUrl" = "URL harus diawali http:// atau https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (kustom)"
|
||||
"customGeoToastList" = "Daftar geo kustom"
|
||||
"customGeoToastAdd" = "Tambah geo kustom"
|
||||
"customGeoToastUpdate" = "Perbarui geo kustom"
|
||||
"customGeoToastDelete" = "Geofile kustom “{{ .fileName }}” dihapus"
|
||||
"customGeoToastDownload" = "Geofile “{{ .fileName }}” diperbarui"
|
||||
"customGeoErrInvalidType" = "Jenis harus geosite atau geoip"
|
||||
"customGeoErrAliasRequired" = "Alias wajib diisi"
|
||||
"customGeoErrAliasPattern" = "Alias berisi karakter yang tidak diizinkan"
|
||||
"customGeoErrAliasReserved" = "Alias ini dicadangkan"
|
||||
"customGeoErrUrlRequired" = "URL wajib diisi"
|
||||
"customGeoErrInvalidUrl" = "URL tidak valid"
|
||||
"customGeoErrUrlScheme" = "URL harus memakai http atau https"
|
||||
"customGeoErrUrlHost" = "Host URL tidak valid"
|
||||
"customGeoErrDuplicateAlias" = "Alias ini sudah dipakai untuk jenis ini"
|
||||
"customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
|
||||
"customGeoErrDownload" = "Unduh gagal"
|
||||
"customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Total Lalu Lintas"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "データベースの読み取り中にエラーが発生しました"
|
||||
"getDatabaseError" = "データベースの取得中にエラーが発生しました"
|
||||
"getConfigError" = "設定ファイルの取得中にエラーが発生しました"
|
||||
"customGeoTitle" = "カスタム GeoSite / GeoIP"
|
||||
"customGeoAdd" = "追加"
|
||||
"customGeoType" = "種類"
|
||||
"customGeoAlias" = "エイリアス"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "有効"
|
||||
"customGeoLastUpdated" = "最終更新"
|
||||
"customGeoExtColumn" = "ルーティング (ext:…)"
|
||||
"customGeoToastUpdateAll" = "すべてのカスタムソースを更新しました"
|
||||
"customGeoActions" = "操作"
|
||||
"customGeoEdit" = "編集"
|
||||
"customGeoDelete" = "削除"
|
||||
"customGeoDownload" = "今すぐ更新"
|
||||
"customGeoModalAdd" = "カスタム geo を追加"
|
||||
"customGeoModalEdit" = "カスタム geo を編集"
|
||||
"customGeoModalSave" = "保存"
|
||||
"customGeoDeleteConfirm" = "このカスタム geo ソースを削除しますか?"
|
||||
"customGeoRoutingHint" = "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。"
|
||||
"customGeoInvalidId" = "無効なリソース ID"
|
||||
"customGeoAliasesError" = "カスタム geo エイリアスの読み込みに失敗しました"
|
||||
"customGeoValidationAlias" = "エイリアスは小文字・数字・- と _ のみ使用できます"
|
||||
"customGeoValidationUrl" = "URL は http:// または https:// で始めてください"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = "(カスタム)"
|
||||
"customGeoToastList" = "カスタム geo 一覧"
|
||||
"customGeoToastAdd" = "カスタム geo を追加"
|
||||
"customGeoToastUpdate" = "カスタム geo を更新"
|
||||
"customGeoToastDelete" = "カスタム geofile「{{ .fileName }}」を削除しました"
|
||||
"customGeoToastDownload" = "geofile「{{ .fileName }}」を更新しました"
|
||||
"customGeoErrInvalidType" = "種類は geosite または geoip である必要があります"
|
||||
"customGeoErrAliasRequired" = "エイリアスが必要です"
|
||||
"customGeoErrAliasPattern" = "エイリアスに使用できない文字が含まれています"
|
||||
"customGeoErrAliasReserved" = "このエイリアスは予約されています"
|
||||
"customGeoErrUrlRequired" = "URL が必要です"
|
||||
"customGeoErrInvalidUrl" = "URL が無効です"
|
||||
"customGeoErrUrlScheme" = "URL は http または https を使用してください"
|
||||
"customGeoErrUrlHost" = "URL のホストが無効です"
|
||||
"customGeoErrDuplicateAlias" = "この種類ですでにこのエイリアスが使われています"
|
||||
"customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
|
||||
"customGeoErrDownload" = "ダウンロードに失敗しました"
|
||||
"customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "総トラフィック"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Ocorreu um erro ao ler o banco de dados"
|
||||
"getDatabaseError" = "Ocorreu um erro ao recuperar o banco de dados"
|
||||
"getConfigError" = "Ocorreu um erro ao recuperar o arquivo de configuração"
|
||||
"customGeoTitle" = "GeoSite / GeoIP personalizados"
|
||||
"customGeoAdd" = "Adicionar"
|
||||
"customGeoType" = "Tipo"
|
||||
"customGeoAlias" = "Alias"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Ativado"
|
||||
"customGeoLastUpdated" = "Última atualização"
|
||||
"customGeoExtColumn" = "Roteamento (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Todas as fontes personalizadas foram atualizadas"
|
||||
"customGeoActions" = "Ações"
|
||||
"customGeoEdit" = "Editar"
|
||||
"customGeoDelete" = "Excluir"
|
||||
"customGeoDownload" = "Atualizar agora"
|
||||
"customGeoModalAdd" = "Adicionar geo personalizado"
|
||||
"customGeoModalEdit" = "Editar geo personalizado"
|
||||
"customGeoModalSave" = "Salvar"
|
||||
"customGeoDeleteConfirm" = "Excluir esta fonte geo personalizada?"
|
||||
"customGeoRoutingHint" = "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag)."
|
||||
"customGeoInvalidId" = "ID de recurso inválido"
|
||||
"customGeoAliasesError" = "Falha ao carregar aliases geo personalizados"
|
||||
"customGeoValidationAlias" = "O alias só pode conter letras minúsculas, dígitos, - e _"
|
||||
"customGeoValidationUrl" = "A URL deve começar com http:// ou https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (personalizado)"
|
||||
"customGeoToastList" = "Lista de geo personalizado"
|
||||
"customGeoToastAdd" = "Adicionar geo personalizado"
|
||||
"customGeoToastUpdate" = "Atualizar geo personalizado"
|
||||
"customGeoToastDelete" = "Geofile personalizado “{{ .fileName }}” excluído"
|
||||
"customGeoToastDownload" = "Geofile “{{ .fileName }}” atualizado"
|
||||
"customGeoErrInvalidType" = "O tipo deve ser geosite ou geoip"
|
||||
"customGeoErrAliasRequired" = "Alias é obrigatório"
|
||||
"customGeoErrAliasPattern" = "O alias contém caracteres não permitidos"
|
||||
"customGeoErrAliasReserved" = "Este alias é reservado"
|
||||
"customGeoErrUrlRequired" = "URL é obrigatória"
|
||||
"customGeoErrInvalidUrl" = "URL inválida"
|
||||
"customGeoErrUrlScheme" = "A URL deve usar http ou https"
|
||||
"customGeoErrUrlHost" = "Host da URL inválido"
|
||||
"customGeoErrDuplicateAlias" = "Este alias já está em uso para este tipo"
|
||||
"customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
|
||||
"customGeoErrDownload" = "Falha no download"
|
||||
"customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Tráfego Total"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,47 @@
|
|||
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
||||
"geofilesUpdateAll" = "Обновить все"
|
||||
"geofileUpdatePopover" = "Геофайлы успешно обновлены"
|
||||
"customGeoTitle" = "Пользовательские GeoSite / GeoIP"
|
||||
"customGeoAdd" = "Добавить"
|
||||
"customGeoType" = "Тип"
|
||||
"customGeoAlias" = "Псевдоним"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Включено"
|
||||
"customGeoLastUpdated" = "Обновлено"
|
||||
"customGeoExtColumn" = "Маршрутизация (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Все пользовательские источники обновлены"
|
||||
"customGeoActions" = "Действия"
|
||||
"customGeoEdit" = "Изменить"
|
||||
"customGeoDelete" = "Удалить"
|
||||
"customGeoDownload" = "Обновить сейчас"
|
||||
"customGeoModalAdd" = "Добавить источник"
|
||||
"customGeoModalEdit" = "Изменить источник"
|
||||
"customGeoModalSave" = "Сохранить"
|
||||
"customGeoDeleteConfirm" = "Удалить этот пользовательский источник?"
|
||||
"customGeoRoutingHint" = "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег)."
|
||||
"customGeoInvalidId" = "Некорректный идентификатор"
|
||||
"customGeoAliasesError" = "Не удалось загрузить список пользовательских geo"
|
||||
"customGeoValidationAlias" = "Псевдоним: только a-z, цифры, - и _"
|
||||
"customGeoValidationUrl" = "URL должен начинаться с http:// или https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (свой)"
|
||||
"customGeoToastList" = "Список пользовательских geo"
|
||||
"customGeoToastAdd" = "Добавить пользовательский geo"
|
||||
"customGeoToastUpdate" = "Изменить пользовательский geo"
|
||||
"customGeoToastDelete" = "Пользовательский geo-файл «{{ .fileName }}» удалён"
|
||||
"customGeoToastDownload" = "Geofile «{{ .fileName }}» обновлен"
|
||||
"customGeoErrInvalidType" = "Тип должен быть geosite или geoip"
|
||||
"customGeoErrAliasRequired" = "Укажите псевдоним"
|
||||
"customGeoErrAliasPattern" = "Псевдоним содержит недопустимые символы"
|
||||
"customGeoErrAliasReserved" = "Этот псевдоним зарезервирован"
|
||||
"customGeoErrUrlRequired" = "Укажите URL"
|
||||
"customGeoErrInvalidUrl" = "Некорректный URL"
|
||||
"customGeoErrUrlScheme" = "URL должен использовать http или https"
|
||||
"customGeoErrUrlHost" = "Некорректный хост URL"
|
||||
"customGeoErrDuplicateAlias" = "Такой псевдоним уже используется для этого типа"
|
||||
"customGeoErrNotFound" = "Источник не найден"
|
||||
"customGeoErrDownload" = "Ошибка загрузки"
|
||||
"customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
|
||||
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
|
||||
"logs" = "Журнал"
|
||||
"config" = "Конфигурация"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Veritabanı okunurken bir hata oluştu"
|
||||
"getDatabaseError" = "Veritabanı alınırken bir hata oluştu"
|
||||
"getConfigError" = "Yapılandırma dosyası alınırken bir hata oluştu"
|
||||
"customGeoTitle" = "Özel GeoSite / GeoIP"
|
||||
"customGeoAdd" = "Ekle"
|
||||
"customGeoType" = "Tür"
|
||||
"customGeoAlias" = "Takma ad"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Etkin"
|
||||
"customGeoLastUpdated" = "Son güncelleme"
|
||||
"customGeoExtColumn" = "Yönlendirme (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Tüm özel kaynaklar güncellendi"
|
||||
"customGeoActions" = "İşlemler"
|
||||
"customGeoEdit" = "Düzenle"
|
||||
"customGeoDelete" = "Sil"
|
||||
"customGeoDownload" = "Şimdi güncelle"
|
||||
"customGeoModalAdd" = "Özel geo ekle"
|
||||
"customGeoModalEdit" = "Özel geo düzenle"
|
||||
"customGeoModalSave" = "Kaydet"
|
||||
"customGeoDeleteConfirm" = "Bu özel geo kaynağını silinsin mi?"
|
||||
"customGeoRoutingHint" = "Yönlendirme kurallarında değer sütununu ext:dosya.dat:etiket olarak kullanın (etiketi değiştirin)."
|
||||
"customGeoInvalidId" = "Geçersiz kaynak kimliği"
|
||||
"customGeoAliasesError" = "Özel geo takma adları yüklenemedi"
|
||||
"customGeoValidationAlias" = "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir"
|
||||
"customGeoValidationUrl" = "URL http:// veya https:// ile başlamalıdır"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (özel)"
|
||||
"customGeoToastList" = "Özel geo listesi"
|
||||
"customGeoToastAdd" = "Özel geo ekle"
|
||||
"customGeoToastUpdate" = "Özel geo güncelle"
|
||||
"customGeoToastDelete" = "Özel geofile \"{{ .fileName }}\" silindi"
|
||||
"customGeoToastDownload" = "\"{{ .fileName }}\" geofile güncellendi"
|
||||
"customGeoErrInvalidType" = "Tür geosite veya geoip olmalıdır"
|
||||
"customGeoErrAliasRequired" = "Takma ad gerekli"
|
||||
"customGeoErrAliasPattern" = "Takma ad izin verilmeyen karakterler içeriyor"
|
||||
"customGeoErrAliasReserved" = "Bu takma ad ayrılmış"
|
||||
"customGeoErrUrlRequired" = "URL gerekli"
|
||||
"customGeoErrInvalidUrl" = "URL geçersiz"
|
||||
"customGeoErrUrlScheme" = "URL http veya https kullanmalıdır"
|
||||
"customGeoErrUrlHost" = "URL ana bilgisayarı geçersiz"
|
||||
"customGeoErrDuplicateAlias" = "Bu takma ad bu tür için zaten kullanılıyor"
|
||||
"customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
|
||||
"customGeoErrDownload" = "İndirme başarısız"
|
||||
"customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Toplam Trafik"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Виникла помилка під час читання бази даних"
|
||||
"getDatabaseError" = "Виникла помилка під час отримання бази даних"
|
||||
"getConfigError" = "Виникла помилка під час отримання файлу конфігурації"
|
||||
"customGeoTitle" = "Користувацькі GeoSite / GeoIP"
|
||||
"customGeoAdd" = "Додати"
|
||||
"customGeoType" = "Тип"
|
||||
"customGeoAlias" = "Псевдонім"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Увімкнено"
|
||||
"customGeoLastUpdated" = "Оновлено"
|
||||
"customGeoExtColumn" = "Маршрутизація (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Усі користувацькі джерела оновлено"
|
||||
"customGeoActions" = "Дії"
|
||||
"customGeoEdit" = "Змінити"
|
||||
"customGeoDelete" = "Видалити"
|
||||
"customGeoDownload" = "Оновити зараз"
|
||||
"customGeoModalAdd" = "Додати користувацький geo"
|
||||
"customGeoModalEdit" = "Змінити користувацький geo"
|
||||
"customGeoModalSave" = "Зберегти"
|
||||
"customGeoDeleteConfirm" = "Видалити це джерело geo?"
|
||||
"customGeoRoutingHint" = "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег)."
|
||||
"customGeoInvalidId" = "Некоректний ідентифікатор ресурсу"
|
||||
"customGeoAliasesError" = "Не вдалося завантажити псевдоніми geo"
|
||||
"customGeoValidationAlias" = "Псевдонім: лише a-z, цифри, - і _"
|
||||
"customGeoValidationUrl" = "URL має починатися з http:// або https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (власний)"
|
||||
"customGeoToastList" = "Список користувацьких geo"
|
||||
"customGeoToastAdd" = "Додати користувацький geo"
|
||||
"customGeoToastUpdate" = "Оновити користувацький geo"
|
||||
"customGeoToastDelete" = "Користувацький geofile «{{ .fileName }}» видалено"
|
||||
"customGeoToastDownload" = "Geofile «{{ .fileName }}» оновлено"
|
||||
"customGeoErrInvalidType" = "Тип має бути geosite або geoip"
|
||||
"customGeoErrAliasRequired" = "Потрібен псевдонім"
|
||||
"customGeoErrAliasPattern" = "Псевдонім містить недопустимі символи"
|
||||
"customGeoErrAliasReserved" = "Цей псевдонім зарезервовано"
|
||||
"customGeoErrUrlRequired" = "Потрібен URL"
|
||||
"customGeoErrInvalidUrl" = "Некоректний URL"
|
||||
"customGeoErrUrlScheme" = "URL має використовувати http або https"
|
||||
"customGeoErrUrlHost" = "Некоректний хост URL"
|
||||
"customGeoErrDuplicateAlias" = "Цей псевдонім уже використовується для цього типу"
|
||||
"customGeoErrNotFound" = "Джерело geo не знайдено"
|
||||
"customGeoErrDownload" = "Помилка завантаження"
|
||||
"customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Загальний трафік"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "Lỗi xảy ra khi đọc cơ sở dữ liệu"
|
||||
"getDatabaseError" = "Lỗi xảy ra khi truy xuất cơ sở dữ liệu"
|
||||
"getConfigError" = "Lỗi xảy ra khi truy xuất tệp cấu hình"
|
||||
"customGeoTitle" = "GeoSite / GeoIP tùy chỉnh"
|
||||
"customGeoAdd" = "Thêm"
|
||||
"customGeoType" = "Loại"
|
||||
"customGeoAlias" = "Bí danh"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "Bật"
|
||||
"customGeoLastUpdated" = "Cập nhật lần cuối"
|
||||
"customGeoExtColumn" = "Định tuyến (ext:…)"
|
||||
"customGeoToastUpdateAll" = "Đã cập nhật tất cả nguồn tùy chỉnh"
|
||||
"customGeoActions" = "Thao tác"
|
||||
"customGeoEdit" = "Sửa"
|
||||
"customGeoDelete" = "Xóa"
|
||||
"customGeoDownload" = "Cập nhật ngay"
|
||||
"customGeoModalAdd" = "Thêm geo tùy chỉnh"
|
||||
"customGeoModalEdit" = "Sửa geo tùy chỉnh"
|
||||
"customGeoModalSave" = "Lưu"
|
||||
"customGeoDeleteConfirm" = "Xóa nguồn geo tùy chỉnh này?"
|
||||
"customGeoRoutingHint" = "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag)."
|
||||
"customGeoInvalidId" = "ID tài nguyên không hợp lệ"
|
||||
"customGeoAliasesError" = "Không tải được bí danh geo tùy chỉnh"
|
||||
"customGeoValidationAlias" = "Bí danh chỉ gồm chữ thường, số, - và _"
|
||||
"customGeoValidationUrl" = "URL phải bắt đầu bằng http:// hoặc https://"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = " (tùy chỉnh)"
|
||||
"customGeoToastList" = "Danh sách geo tùy chỉnh"
|
||||
"customGeoToastAdd" = "Thêm geo tùy chỉnh"
|
||||
"customGeoToastUpdate" = "Cập nhật geo tùy chỉnh"
|
||||
"customGeoToastDelete" = "Đã xóa geofile tùy chỉnh “{{ .fileName }}”"
|
||||
"customGeoToastDownload" = "Đã cập nhật geofile “{{ .fileName }}”"
|
||||
"customGeoErrInvalidType" = "Loại phải là geosite hoặc geoip"
|
||||
"customGeoErrAliasRequired" = "Cần bí danh"
|
||||
"customGeoErrAliasPattern" = "Bí danh có ký tự không hợp lệ"
|
||||
"customGeoErrAliasReserved" = "Bí danh này được dành riêng"
|
||||
"customGeoErrUrlRequired" = "Cần URL"
|
||||
"customGeoErrInvalidUrl" = "URL không hợp lệ"
|
||||
"customGeoErrUrlScheme" = "URL phải dùng http hoặc https"
|
||||
"customGeoErrUrlHost" = "Máy chủ URL không hợp lệ"
|
||||
"customGeoErrDuplicateAlias" = "Bí danh này đã dùng cho loại này"
|
||||
"customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
|
||||
"customGeoErrDownload" = "Tải xuống thất bại"
|
||||
"customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Tổng Lưu Lượng"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "读取数据库时出错"
|
||||
"getDatabaseError" = "检索数据库时出错"
|
||||
"getConfigError" = "检索配置文件时出错"
|
||||
"customGeoTitle" = "自定义 GeoSite / GeoIP"
|
||||
"customGeoAdd" = "添加"
|
||||
"customGeoType" = "类型"
|
||||
"customGeoAlias" = "别名"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "启用"
|
||||
"customGeoLastUpdated" = "上次更新"
|
||||
"customGeoExtColumn" = "路由 (ext:…)"
|
||||
"customGeoToastUpdateAll" = "所有自定义来源已更新"
|
||||
"customGeoActions" = "操作"
|
||||
"customGeoEdit" = "编辑"
|
||||
"customGeoDelete" = "删除"
|
||||
"customGeoDownload" = "立即更新"
|
||||
"customGeoModalAdd" = "添加自定义 geo"
|
||||
"customGeoModalEdit" = "编辑自定义 geo"
|
||||
"customGeoModalSave" = "保存"
|
||||
"customGeoDeleteConfirm" = "删除此自定义 geo 源?"
|
||||
"customGeoRoutingHint" = "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。"
|
||||
"customGeoInvalidId" = "无效的资源 ID"
|
||||
"customGeoAliasesError" = "加载自定义 geo 别名失败"
|
||||
"customGeoValidationAlias" = "别名只能包含小写字母、数字、- 和 _"
|
||||
"customGeoValidationUrl" = "URL 必须以 http:// 或 https:// 开头"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = "(自定义)"
|
||||
"customGeoToastList" = "自定义 geo 列表"
|
||||
"customGeoToastAdd" = "添加自定义 geo"
|
||||
"customGeoToastUpdate" = "更新自定义 geo"
|
||||
"customGeoToastDelete" = "自定义 geofile「{{ .fileName }}」已删除"
|
||||
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
|
||||
"customGeoErrInvalidType" = "类型必须是 geosite 或 geoip"
|
||||
"customGeoErrAliasRequired" = "请填写别名"
|
||||
"customGeoErrAliasPattern" = "别名包含不允许的字符"
|
||||
"customGeoErrAliasReserved" = "该别名已保留"
|
||||
"customGeoErrUrlRequired" = "请填写 URL"
|
||||
"customGeoErrInvalidUrl" = "URL 无效"
|
||||
"customGeoErrUrlScheme" = "URL 必须使用 http 或 https"
|
||||
"customGeoErrUrlHost" = "URL 主机无效"
|
||||
"customGeoErrDuplicateAlias" = "此类型下已使用该别名"
|
||||
"customGeoErrNotFound" = "未找到自定义 geo 源"
|
||||
"customGeoErrDownload" = "下载失败"
|
||||
"customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "累计总流量"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,47 @@
|
|||
"readDatabaseError" = "讀取資料庫時發生錯誤"
|
||||
"getDatabaseError" = "檢索資料庫時發生錯誤"
|
||||
"getConfigError" = "檢索設定檔時發生錯誤"
|
||||
"customGeoTitle" = "自訂 GeoSite / GeoIP"
|
||||
"customGeoAdd" = "新增"
|
||||
"customGeoType" = "類型"
|
||||
"customGeoAlias" = "別名"
|
||||
"customGeoUrl" = "URL"
|
||||
"customGeoEnabled" = "啟用"
|
||||
"customGeoLastUpdated" = "上次更新"
|
||||
"customGeoExtColumn" = "路由 (ext:…)"
|
||||
"customGeoToastUpdateAll" = "所有自訂來源已更新"
|
||||
"customGeoActions" = "操作"
|
||||
"customGeoEdit" = "編輯"
|
||||
"customGeoDelete" = "刪除"
|
||||
"customGeoDownload" = "立即更新"
|
||||
"customGeoModalAdd" = "新增自訂 geo"
|
||||
"customGeoModalEdit" = "編輯自訂 geo"
|
||||
"customGeoModalSave" = "儲存"
|
||||
"customGeoDeleteConfirm" = "刪除此自訂 geo 來源?"
|
||||
"customGeoRoutingHint" = "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。"
|
||||
"customGeoInvalidId" = "無效的資源 ID"
|
||||
"customGeoAliasesError" = "載入自訂 geo 別名失敗"
|
||||
"customGeoValidationAlias" = "別名只能包含小寫字母、數字、- 和 _"
|
||||
"customGeoValidationUrl" = "URL 必須以 http:// 或 https:// 開頭"
|
||||
"customGeoAliasPlaceholder" = "a-z 0-9 _ -"
|
||||
"customGeoAliasLabelSuffix" = "(自訂)"
|
||||
"customGeoToastList" = "自訂 geo 清單"
|
||||
"customGeoToastAdd" = "新增自訂 geo"
|
||||
"customGeoToastUpdate" = "更新自訂 geo"
|
||||
"customGeoToastDelete" = "自訂 geofile「{{ .fileName }}」已刪除"
|
||||
"customGeoToastDownload" = "geofile「{{ .fileName }}」已更新"
|
||||
"customGeoErrInvalidType" = "類型必須是 geosite 或 geoip"
|
||||
"customGeoErrAliasRequired" = "請填寫別名"
|
||||
"customGeoErrAliasPattern" = "別名包含不允許的字元"
|
||||
"customGeoErrAliasReserved" = "此別名已保留"
|
||||
"customGeoErrUrlRequired" = "請填寫 URL"
|
||||
"customGeoErrInvalidUrl" = "URL 無效"
|
||||
"customGeoErrUrlScheme" = "URL 必須使用 http 或 https"
|
||||
"customGeoErrUrlHost" = "URL 主機無效"
|
||||
"customGeoErrDuplicateAlias" = "此類型已使用該別名"
|
||||
"customGeoErrNotFound" = "找不到自訂 geo 來源"
|
||||
"customGeoErrDownload" = "下載失敗"
|
||||
"customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
|
||||
|
||||
[pages.inbounds]
|
||||
"allTimeTraffic" = "累計總流量"
|
||||
|
|
|
|||
12
web/web.go
12
web/web.go
|
|
@ -101,9 +101,10 @@ type Server struct {
|
|||
api *controller.APIController
|
||||
ws *controller.WebSocketController
|
||||
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
tgbotService service.Tgbot
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
tgbotService service.Tgbot
|
||||
customGeoService service.CustomGeoService
|
||||
|
||||
wsHub *websocket.Hub
|
||||
|
||||
|
|
@ -268,7 +269,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
|
||||
s.index = controller.NewIndexController(g)
|
||||
s.panel = controller.NewXUIController(g)
|
||||
s.api = controller.NewAPIController(g)
|
||||
s.api = controller.NewAPIController(g, s.customGeoService)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
s.wsHub = websocket.NewHub()
|
||||
|
|
@ -295,6 +296,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||
func (s *Server) startTask() {
|
||||
s.customGeoService.EnsureOnStartup()
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
logger.Warning("start xray failed:", err)
|
||||
|
|
@ -388,6 +390,8 @@ func (s *Server) Start() (err error) {
|
|||
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
|
||||
s.cron.Start()
|
||||
|
||||
s.customGeoService = service.NewCustomGeoService()
|
||||
|
||||
engine, err := s.initRouter()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
Loading…
Reference in a new issue