mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
fix(clients): reject dot names and recover legacy clients (#7062)
Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
This commit is contained in:
committed by
GitHub
parent
2541080c91
commit
cd176faf4e
@@ -105,8 +105,10 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/del": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/clients/update": {http.MethodPost: {}},
|
||||
"/server/restartXrayService": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
|
||||
@@ -53,6 +53,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/list/paged", a.listPaged)
|
||||
g.GET("/get", a.get)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.GET("/get/subId/:subId", a.getBySubId)
|
||||
g.GET("/get/tgId/:tgId", a.getByTgId)
|
||||
@@ -63,7 +64,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/renewalPreview", a.renewalPreview)
|
||||
g.POST("/update", a.update)
|
||||
g.POST("/update/:email", a.update)
|
||||
g.POST("/del", a.delete)
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
@@ -159,8 +162,24 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
// Query aliases keep legacy dot-segment names addressable without changing path clients.
|
||||
func clientEmailParam(c *gin.Context) (string, bool) {
|
||||
email := c.Param("email")
|
||||
if email == "" {
|
||||
email = c.Query("email")
|
||||
}
|
||||
if email == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), errors.New("client email is required"))
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
@@ -244,7 +263,10 @@ func (a *ClientController) create(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
@@ -273,7 +295,10 @@ func (a *ClientController) update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
|
||||
// Flagged before the error check: a partly-applied delete already removed
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func seedLegacyDotClient(t *testing.T, email string) (*gin.Engine, model.ClientRecord, model.Inbound) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dir)
|
||||
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
rec := model.ClientRecord{Email: email, UUID: "11111111-2222-3333-4444-555555555555", SubID: "legacy-dot-sub", Enable: true}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := json.Marshal(map[string]any{
|
||||
"clients": []model.Client{*rec.ToClient()},
|
||||
"decryption": "none",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ib := model.Inbound{Tag: "legacy-dot", Port: 443, Protocol: model.VLESS, Settings: string(settings), StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
if err := db.Create(&ib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{Email: email, InboundId: ib.Id, Up: 111, Down: 222, Enable: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
c.Next()
|
||||
})
|
||||
NewClientController(router.Group("/base/panel/api/clients"))
|
||||
return router, rec, ib
|
||||
}
|
||||
|
||||
func dotClientRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/base/panel/api/clients/"+path, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func assertDotClientSuccess(t *testing.T, w *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
var msg entity.Msg
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusOK || !msg.Success {
|
||||
t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasRecoversLegacyDotName(t *testing.T) {
|
||||
for _, email := range []string{".", ".."} {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
router, rec, ib := seedLegacyDotClient(t, email)
|
||||
query := "?email=" + url.QueryEscape(email)
|
||||
w := dotClientRequest(t, router, http.MethodGet, "get"+query, nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
var full struct {
|
||||
Obj struct {
|
||||
Client model.ClientRecord `json:"client"`
|
||||
InboundIDs []int `json:"inboundIds"`
|
||||
UsedTraffic int64 `json:"usedTraffic"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if full.Obj.Client.Email != email || full.Obj.Client.UUID != rec.UUID || len(full.Obj.InboundIDs) != 1 || full.Obj.InboundIDs[0] != ib.Id || full.Obj.UsedTraffic != 333 {
|
||||
t.Fatalf("unexpected legacy payload: %s", w.Body.String())
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Email = "restored-client"
|
||||
w = dotClientRequest(t, router, http.MethodPost, "update"+query, updated)
|
||||
assertDotClientSuccess(t, w)
|
||||
db := database.GetDB()
|
||||
var after model.ClientRecord
|
||||
if err := db.First(&after, rec.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Email != updated.Email || after.UUID != rec.UUID || after.SubID != rec.SubID {
|
||||
t.Fatalf("rename changed identity: %+v", after)
|
||||
}
|
||||
if err := db.First(&ib, ib.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var settings struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(settings.Clients) != 1 || settings.Clients[0].Email != updated.Email || settings.Clients[0].ID != rec.UUID {
|
||||
t.Fatalf("rename lost inbound credentials: %s", ib.Settings)
|
||||
}
|
||||
var traffic xray.ClientTraffic
|
||||
if err := db.Where("email = ?", updated.Email).First(&traffic).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if traffic.Up != 111 || traffic.Down != 222 {
|
||||
t.Fatalf("rename lost usage: %+v", traffic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasDeletesLegacyDotName(t *testing.T) {
|
||||
for _, email := range []string{".", ".."} {
|
||||
for _, keep := range []bool{false, true} {
|
||||
t.Run(email+"/keep="+strconv.FormatBool(keep), func(t *testing.T) {
|
||||
router, rec, ib := seedLegacyDotClient(t, email)
|
||||
path := "del?email=" + url.QueryEscape(email)
|
||||
if keep {
|
||||
path += "&keepTraffic=1"
|
||||
}
|
||||
w := dotClientRequest(t, router, http.MethodPost, path, nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("client remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientInbound{}).Where("client_id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("attachment remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.First(&ib, ib.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var settings struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || len(settings.Clients) != 0 {
|
||||
t.Fatalf("inbound client remains: %s err=%v", ib.Settings, err)
|
||||
}
|
||||
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want int64
|
||||
if keep {
|
||||
want = 1
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("retained traffic count=%d keep=%v", count, keep)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasRequiresEmail(t *testing.T) {
|
||||
router, _, _ := seedLegacyDotClient(t, ".")
|
||||
for _, route := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "get"}, {http.MethodPost, "update"}, {http.MethodPost, "del"},
|
||||
} {
|
||||
w := dotClientRequest(t, router, route.method, route.path, map[string]any{"email": "must-not-be-used-as-target"})
|
||||
var msg entity.Msg
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Success || !strings.Contains(msg.Msg, "client email is required") {
|
||||
t.Fatalf("missing query accepted: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPathEmailTakesPrecedenceOverQuery(t *testing.T) {
|
||||
router, legacy, _ := seedLegacyDotClient(t, ".")
|
||||
normal := model.ClientRecord{Email: "audit-client", UUID: "normal-uuid", SubID: "normal-sub", Enable: true}
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&normal).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := dotClientRequest(t, router, http.MethodPost, "del/audit-client?email=.", nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", normal.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("path client remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", legacy.Id).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("query overrode the path identity: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -152,3 +152,43 @@ func TestClientGetBySubIDRequiresAdmin(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasTokenScopes(t *testing.T) {
|
||||
router, api := newAPIAuthTestEngine(t)
|
||||
NewClientController(router.Group("/base/panel/api/clients", api.checkAPIAuth, api.enforceTokenScope))
|
||||
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, route := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "get"},
|
||||
{http.MethodPost, "update"},
|
||||
{http.MethodPost, "del"},
|
||||
} {
|
||||
for _, scope := range []string{"", model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
|
||||
t.Run(route.path+"/scope="+scope, func(t *testing.T) {
|
||||
req := httptest.NewRequest(route.method, "/base/panel/api/clients/"+route.path+"?email=missing", strings.NewReader(`{"email":"restored-client"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
if scope != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+scope)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
want := http.StatusOK
|
||||
if scope == "" {
|
||||
want = http.StatusUnauthorized
|
||||
} else if scope == model.ApiScopeMonitor || (route.method == http.MethodGet && scope != model.ApiScopeAdmin) {
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("status = %d, want %d, body = %s", w.Code, want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ func hasForbiddenClientChar(s string) bool {
|
||||
}
|
||||
|
||||
func validateClientEmail(email string) error {
|
||||
if email == "." || email == ".." {
|
||||
return common.NewError("client email must not be '.' or '..'")
|
||||
}
|
||||
if hasForbiddenClientChar(email) {
|
||||
return common.NewError("client email contains an invalid character:", email)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ func TestValidateClientEmail(t *testing.T) {
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"user-123_test.name",
|
||||
"...",
|
||||
".alice",
|
||||
"alice.",
|
||||
"alice..example",
|
||||
"имя",
|
||||
}
|
||||
for _, email := range valid {
|
||||
@@ -16,6 +20,8 @@ func TestValidateClientEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
".",
|
||||
"..",
|
||||
"i6dui/",
|
||||
"a/b",
|
||||
"client with spaces",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
||||
"online": "متصل",
|
||||
"email": "البريد",
|
||||
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
|
||||
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||
"group": "المجموعة",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Client name cannot be exactly \".\" or \"..\"",
|
||||
"emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
|
||||
"subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters",
|
||||
"group": "Group",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.",
|
||||
"online": "En línea",
|
||||
"email": "Email",
|
||||
"emailReservedName": "El nombre del cliente no puede ser exactamente \".\" o \"..\"",
|
||||
"emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control",
|
||||
"subIdInvalidChars": "El ID de suscripción no puede contener espacios, '/', '\\' ni caracteres de control",
|
||||
"group": "Grupo",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
||||
"online": "آنلاین",
|
||||
"email": "ایمیل",
|
||||
"emailReservedName": "نام کاربر نمیتواند فقط «.» یا «..» باشد",
|
||||
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||
"group": "گروه",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Nama klien tidak boleh hanya \".\" atau \"..\"",
|
||||
"emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
|
||||
"subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
|
||||
"group": "Grup",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
||||
"online": "オンライン",
|
||||
"email": "メール",
|
||||
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
|
||||
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||
"group": "グループ",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "O nome do cliente não pode ser exatamente \".\" ou \"..\"",
|
||||
"emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle",
|
||||
"subIdInvalidChars": "O ID de assinatura não pode conter espaços, '/', '\\' ou caracteres de controle",
|
||||
"group": "Grupo",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
||||
"online": "В сети",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
|
||||
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||
"group": "Группа",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Slúži aj ako pozývací kód klienta do Telegram bota. Kto ho pošle botovi, priradí sa k tomuto klientovi. Použite dlhé náhodné ID. Krátke alebo odhadnuteľné ID si môže privlastniť niekto iný.",
|
||||
"online": "Online",
|
||||
"email": "Názov klienta",
|
||||
"emailReservedName": "Názov klienta nesmie byť presne \".\" ani \"..\"",
|
||||
"emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
|
||||
"subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
|
||||
"group": "Skupina",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.",
|
||||
"online": "Çevrimiçi",
|
||||
"email": "E-posta",
|
||||
"emailReservedName": "İstemci adı yalnızca \".\" veya \"..\" olamaz",
|
||||
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
|
||||
"subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez",
|
||||
"group": "Grup",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
||||
"online": "У мережі",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
|
||||
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
||||
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
||||
"group": "Група",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.",
|
||||
"online": "Trực tuyến",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Tên máy khách không được chỉ là \".\" hoặc \"..\"",
|
||||
"emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
|
||||
"subIdInvalidChars": "ID đăng ký không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
|
||||
"group": "Nhóm",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
||||
"online": "在线",
|
||||
"email": "邮箱",
|
||||
"emailReservedName": "客户端名称不能仅为“.”或“..”",
|
||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||
"group": "分组",
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
|
||||
"online": "上線",
|
||||
"email": "電子郵件",
|
||||
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
|
||||
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
||||
"group": "群組",
|
||||
|
||||
Reference in New Issue
Block a user