mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
fix(clients): reject spaces, '/', '\' and control chars in client email
Client emails containing a slash broke the path-param routes (edit/delete/view returned 404 / "client not found"), leaving stale records that could only be cleared with manual SQLite edits. Validate the email on both the backend (Create + Update, which also covers the bulk paths) and the frontend (Zod) so these characters are rejected at save time with a clear, localized message across all 13 locales. Closes #4695
This commit is contained in:
parent
d1882c7f29
commit
a0865a67fd
16 changed files with 74 additions and 1 deletions
|
|
@ -119,8 +119,21 @@ export const GroupSummarySchema = z.object({
|
||||||
|
|
||||||
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
||||||
|
|
||||||
|
export function emailHasForbiddenChars(value: string): boolean {
|
||||||
|
if (value.includes('/') || value.includes('\\') || value.includes(' ')) return true;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i);
|
||||||
|
if (code < 0x20 || code === 0x7f) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export const ClientFormSchema = z.object({
|
export const ClientFormSchema = z.object({
|
||||||
email: z.string().trim().min(1, 'pages.clients.email'),
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'pages.clients.email')
|
||||||
|
.refine((v) => !emailHasForbiddenChars(v), 'pages.clients.emailInvalidChars'),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
uuid: z.string(),
|
uuid: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,15 @@ type ClientCreatePayload struct {
|
||||||
InboundIds []int `json:"inboundIds"`
|
InboundIds []int `json:"inboundIds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateClientEmail(email string) error {
|
||||||
|
for _, r := range email {
|
||||||
|
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
|
||||||
|
return common.NewError("client email contains an invalid character:", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return false, common.NewError("empty payload")
|
return false, common.NewError("empty payload")
|
||||||
|
|
@ -416,6 +425,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||||
if strings.TrimSpace(client.Email) == "" {
|
if strings.TrimSpace(client.Email) == "" {
|
||||||
return false, common.NewError("client email is required")
|
return false, common.NewError("client email is required")
|
||||||
}
|
}
|
||||||
|
if err := validateClientEmail(client.Email); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
if len(payload.InboundIds) == 0 {
|
if len(payload.InboundIds) == 0 {
|
||||||
return false, common.NewError("at least one inbound is required")
|
return false, common.NewError("at least one inbound is required")
|
||||||
}
|
}
|
||||||
|
|
@ -581,6 +593,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||||
if strings.TrimSpace(updated.Email) == "" {
|
if strings.TrimSpace(updated.Email) == "" {
|
||||||
return false, common.NewError("client email is required")
|
return false, common.NewError("client email is required")
|
||||||
}
|
}
|
||||||
|
if err := validateClientEmail(updated.Email); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
if updated.SubID == "" {
|
if updated.SubID == "" {
|
||||||
updated.SubID = existing.SubID
|
updated.SubID = existing.SubID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
web/service/client_email_validation_test.go
Normal file
32
web/service/client_email_validation_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateClientEmail(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"alice",
|
||||||
|
"alice@example.com",
|
||||||
|
"user-123_test.name",
|
||||||
|
"имя",
|
||||||
|
}
|
||||||
|
for _, email := range valid {
|
||||||
|
if err := validateClientEmail(email); err != nil {
|
||||||
|
t.Errorf("validateClientEmail(%q) = %v, want nil", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"i6dui/",
|
||||||
|
"a/b",
|
||||||
|
"client with spaces",
|
||||||
|
"back\\slash",
|
||||||
|
"tab\there",
|
||||||
|
"new\nline",
|
||||||
|
"\x7fdelete",
|
||||||
|
}
|
||||||
|
for _, email := range invalid {
|
||||||
|
if err := validateClientEmail(email); err == nil {
|
||||||
|
t.Errorf("validateClientEmail(%q) = nil, want error", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "معرّف الاشتراك",
|
"subId": "معرّف الاشتراك",
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"email": "البريد",
|
"email": "البريد",
|
||||||
|
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||||
"group": "المجموعة",
|
"group": "المجموعة",
|
||||||
"groupDesc": "تسمية منطقية لتجميع العملاء (مثل فريق، عميل، منطقة). يمكن تصفيتها من شريط الأدوات.",
|
"groupDesc": "تسمية منطقية لتجميع العملاء (مثل فريق، عميل، منطقة). يمكن تصفيتها من شريط الأدوات.",
|
||||||
"groupPlaceholder": "مثلاً customer-a",
|
"groupPlaceholder": "مثلاً customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "Subscription ID",
|
"subId": "Subscription ID",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
|
||||||
"group": "Group",
|
"group": "Group",
|
||||||
"groupDesc": "Logical label used to bucket related clients (e.g. team, customer, region). Filterable from the toolbar.",
|
"groupDesc": "Logical label used to bucket related clients (e.g. team, customer, region). Filterable from the toolbar.",
|
||||||
"groupPlaceholder": "e.g. customer-a",
|
"groupPlaceholder": "e.g. customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID de suscripción",
|
"subId": "ID de suscripción",
|
||||||
"online": "En línea",
|
"online": "En línea",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control",
|
||||||
"group": "Grupo",
|
"group": "Grupo",
|
||||||
"groupDesc": "Etiqueta lógica para agrupar clientes relacionados (p. ej. equipo, cliente, región). Filtrable desde la barra de herramientas.",
|
"groupDesc": "Etiqueta lógica para agrupar clientes relacionados (p. ej. equipo, cliente, región). Filtrable desde la barra de herramientas.",
|
||||||
"groupPlaceholder": "p. ej. customer-a",
|
"groupPlaceholder": "p. ej. customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "شناسه اشتراک",
|
"subId": "شناسه اشتراک",
|
||||||
"online": "آنلاین",
|
"online": "آنلاین",
|
||||||
"email": "ایمیل",
|
"email": "ایمیل",
|
||||||
|
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||||
"group": "گروه",
|
"group": "گروه",
|
||||||
"groupDesc": "برچسبی منطقی برای دستهبندی کاربران مرتبط (مثل تیم، مشتری، منطقه). از نوار ابزار قابل فیلتر است.",
|
"groupDesc": "برچسبی منطقی برای دستهبندی کاربران مرتبط (مثل تیم، مشتری، منطقه). از نوار ابزار قابل فیلتر است.",
|
||||||
"groupPlaceholder": "مثلاً customer-a",
|
"groupPlaceholder": "مثلاً customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID Langganan",
|
"subId": "ID Langganan",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
|
||||||
"group": "Grup",
|
"group": "Grup",
|
||||||
"groupDesc": "Label logis untuk mengelompokkan klien terkait (mis. tim, pelanggan, wilayah). Dapat difilter dari toolbar.",
|
"groupDesc": "Label logis untuk mengelompokkan klien terkait (mis. tim, pelanggan, wilayah). Dapat difilter dari toolbar.",
|
||||||
"groupPlaceholder": "mis. customer-a",
|
"groupPlaceholder": "mis. customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "サブスクリプション ID",
|
"subId": "サブスクリプション ID",
|
||||||
"online": "オンライン",
|
"online": "オンライン",
|
||||||
"email": "メール",
|
"email": "メール",
|
||||||
|
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||||
"group": "グループ",
|
"group": "グループ",
|
||||||
"groupDesc": "関連クライアントをまとめる論理ラベル(チーム、顧客、地域など)。ツールバーからフィルタ可能。",
|
"groupDesc": "関連クライアントをまとめる論理ラベル(チーム、顧客、地域など)。ツールバーからフィルタ可能。",
|
||||||
"groupPlaceholder": "例: customer-a",
|
"groupPlaceholder": "例: customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID da assinatura",
|
"subId": "ID da assinatura",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle",
|
||||||
"group": "Grupo",
|
"group": "Grupo",
|
||||||
"groupDesc": "Rótulo lógico para agrupar clientes relacionados (ex.: equipe, cliente, região). Filtrável pela barra de ferramentas.",
|
"groupDesc": "Rótulo lógico para agrupar clientes relacionados (ex.: equipe, cliente, região). Filtrável pela barra de ferramentas.",
|
||||||
"groupPlaceholder": "ex.: customer-a",
|
"groupPlaceholder": "ex.: customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID подписки",
|
"subId": "ID подписки",
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||||
"group": "Группа",
|
"group": "Группа",
|
||||||
"groupDesc": "Логическая метка для группировки связанных клиентов (например, команда, клиент, регион). Фильтруется из панели инструментов.",
|
"groupDesc": "Логическая метка для группировки связанных клиентов (например, команда, клиент, регион). Фильтруется из панели инструментов.",
|
||||||
"groupPlaceholder": "например, customer-a",
|
"groupPlaceholder": "например, customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "Abonelik ID'si",
|
"subId": "Abonelik ID'si",
|
||||||
"online": "Çevrimiçi",
|
"online": "Çevrimiçi",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
|
||||||
"group": "Grup",
|
"group": "Grup",
|
||||||
"groupDesc": "İlgili istemcileri gruplamak için mantıksal etiket (ekip, müşteri, bölge). Araç çubuğundan filtrelenebilir.",
|
"groupDesc": "İlgili istemcileri gruplamak için mantıksal etiket (ekip, müşteri, bölge). Araç çubuğundan filtrelenebilir.",
|
||||||
"groupPlaceholder": "örn. customer-a",
|
"groupPlaceholder": "örn. customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID підписки",
|
"subId": "ID підписки",
|
||||||
"online": "У мережі",
|
"online": "У мережі",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
||||||
"group": "Група",
|
"group": "Група",
|
||||||
"groupDesc": "Логічна мітка для групування пов'язаних клієнтів (напр. команда, клієнт, регіон). Фільтрується з панелі інструментів.",
|
"groupDesc": "Логічна мітка для групування пов'язаних клієнтів (напр. команда, клієнт, регіон). Фільтрується з панелі інструментів.",
|
||||||
"groupPlaceholder": "напр. customer-a",
|
"groupPlaceholder": "напр. customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "ID đăng ký",
|
"subId": "ID đăng ký",
|
||||||
"online": "Trực tuyến",
|
"online": "Trực tuyến",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
|
||||||
"group": "Nhóm",
|
"group": "Nhóm",
|
||||||
"groupDesc": "Nhãn logic để gom các client liên quan (nhóm, khách hàng, khu vực). Có thể lọc từ thanh công cụ.",
|
"groupDesc": "Nhãn logic để gom các client liên quan (nhóm, khách hàng, khu vực). Có thể lọc từ thanh công cụ.",
|
||||||
"groupPlaceholder": "ví dụ customer-a",
|
"groupPlaceholder": "ví dụ customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "订阅 ID",
|
"subId": "订阅 ID",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||||
"group": "分组",
|
"group": "分组",
|
||||||
"groupDesc": "用于对相关客户端进行分桶的逻辑标签(如团队、客户、地区)。可从工具栏筛选。",
|
"groupDesc": "用于对相关客户端进行分桶的逻辑标签(如团队、客户、地区)。可从工具栏筛选。",
|
||||||
"groupPlaceholder": "如 customer-a",
|
"groupPlaceholder": "如 customer-a",
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@
|
||||||
"subId": "訂閱 ID",
|
"subId": "訂閱 ID",
|
||||||
"online": "上線",
|
"online": "上線",
|
||||||
"email": "電子郵件",
|
"email": "電子郵件",
|
||||||
|
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||||
"group": "群組",
|
"group": "群組",
|
||||||
"groupDesc": "用於將相關客戶端歸類的邏輯標籤(如團隊、客戶、地區)。可從工具列篩選。",
|
"groupDesc": "用於將相關客戶端歸類的邏輯標籤(如團隊、客戶、地區)。可從工具列篩選。",
|
||||||
"groupPlaceholder": "如 customer-a",
|
"groupPlaceholder": "如 customer-a",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue