Commit bd0801a8 authored by PMExtra's avatar PMExtra
Browse files

feat(registration): add email domain whitelist policy

parent ba6de4c4
...@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -77,6 +77,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
...@@ -130,12 +131,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -130,12 +131,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求 // UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct { type UpdateSettingsRequest struct {
// 注册设置 // 注册设置
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
// 邮件服务设置 // 邮件服务设置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
...@@ -426,50 +428,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -426,50 +428,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
settings := &service.SystemSettings{ settings := &service.SystemSettings{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled,
PromoCodeEnabled: req.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: req.PasswordResetEnabled, PromoCodeEnabled: req.PromoCodeEnabled,
InvitationCodeEnabled: req.InvitationCodeEnabled, PasswordResetEnabled: req.PasswordResetEnabled,
TotpEnabled: req.TotpEnabled, InvitationCodeEnabled: req.InvitationCodeEnabled,
SMTPHost: req.SMTPHost, TotpEnabled: req.TotpEnabled,
SMTPPort: req.SMTPPort, SMTPHost: req.SMTPHost,
SMTPUsername: req.SMTPUsername, SMTPPort: req.SMTPPort,
SMTPPassword: req.SMTPPassword, SMTPUsername: req.SMTPUsername,
SMTPFrom: req.SMTPFrom, SMTPPassword: req.SMTPPassword,
SMTPFromName: req.SMTPFromName, SMTPFrom: req.SMTPFrom,
SMTPUseTLS: req.SMTPUseTLS, SMTPFromName: req.SMTPFromName,
TurnstileEnabled: req.TurnstileEnabled, SMTPUseTLS: req.SMTPUseTLS,
TurnstileSiteKey: req.TurnstileSiteKey, TurnstileEnabled: req.TurnstileEnabled,
TurnstileSecretKey: req.TurnstileSecretKey, TurnstileSiteKey: req.TurnstileSiteKey,
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, TurnstileSecretKey: req.TurnstileSecretKey,
LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
SiteName: req.SiteName, LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
SiteLogo: req.SiteLogo, SiteName: req.SiteName,
SiteSubtitle: req.SiteSubtitle, SiteLogo: req.SiteLogo,
APIBaseURL: req.APIBaseURL, SiteSubtitle: req.SiteSubtitle,
ContactInfo: req.ContactInfo, APIBaseURL: req.APIBaseURL,
DocURL: req.DocURL, ContactInfo: req.ContactInfo,
HomeContent: req.HomeContent, DocURL: req.DocURL,
HideCcsImportButton: req.HideCcsImportButton, HomeContent: req.HomeContent,
PurchaseSubscriptionEnabled: purchaseEnabled, HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionURL: purchaseURL, PurchaseSubscriptionEnabled: purchaseEnabled,
SoraClientEnabled: req.SoraClientEnabled, PurchaseSubscriptionURL: purchaseURL,
CustomMenuItems: customMenuJSON, SoraClientEnabled: req.SoraClientEnabled,
DefaultConcurrency: req.DefaultConcurrency, CustomMenuItems: customMenuJSON,
DefaultBalance: req.DefaultBalance, DefaultConcurrency: req.DefaultConcurrency,
DefaultSubscriptions: defaultSubscriptions, DefaultBalance: req.DefaultBalance,
EnableModelFallback: req.EnableModelFallback, DefaultSubscriptions: defaultSubscriptions,
FallbackModelAnthropic: req.FallbackModelAnthropic, EnableModelFallback: req.EnableModelFallback,
FallbackModelOpenAI: req.FallbackModelOpenAI, FallbackModelAnthropic: req.FallbackModelAnthropic,
FallbackModelGemini: req.FallbackModelGemini, FallbackModelOpenAI: req.FallbackModelOpenAI,
FallbackModelAntigravity: req.FallbackModelAntigravity, FallbackModelGemini: req.FallbackModelGemini,
EnableIdentityPatch: req.EnableIdentityPatch, FallbackModelAntigravity: req.FallbackModelAntigravity,
IdentityPatchPrompt: req.IdentityPatchPrompt, EnableIdentityPatch: req.EnableIdentityPatch,
MinClaudeCodeVersion: req.MinClaudeCodeVersion, IdentityPatchPrompt: req.IdentityPatchPrompt,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, MinClaudeCodeVersion: req.MinClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
OpsMonitoringEnabled: func() bool { OpsMonitoringEnabled: func() bool {
if req.OpsMonitoringEnabled != nil { if req.OpsMonitoringEnabled != nil {
return *req.OpsMonitoringEnabled return *req.OpsMonitoringEnabled
...@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -520,6 +523,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.Success(c, dto.SystemSettings{ response.Success(c, dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled, RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
...@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -598,6 +602,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EmailVerifyEnabled != after.EmailVerifyEnabled { if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
changed = append(changed, "email_verify_enabled") changed = append(changed, "email_verify_enabled")
} }
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
changed = append(changed, "registration_email_suffix_whitelist")
}
if before.PasswordResetEnabled != after.PasswordResetEnabled { if before.PasswordResetEnabled != after.PasswordResetEnabled {
changed = append(changed, "password_reset_enabled") changed = append(changed, "password_reset_enabled")
} }
...@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto ...@@ -747,6 +754,18 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
return normalized return normalized
} }
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
......
...@@ -17,13 +17,14 @@ type CustomMenuItem struct { ...@@ -17,13 +17,14 @@ type CustomMenuItem struct {
// SystemSettings represents the admin settings API response payload. // SystemSettings represents the admin settings API response payload.
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
...@@ -88,28 +89,29 @@ type DefaultSubscriptionSetting struct { ...@@ -88,28 +89,29 @@ type DefaultSubscriptionSetting struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileEnabled bool `json:"turnstile_enabled"`
SiteName string `json:"site_name"` TurnstileSiteKey string `json:"turnstile_site_key"`
SiteLogo string `json:"site_logo"` SiteName string `json:"site_name"`
SiteSubtitle string `json:"site_subtitle"` SiteLogo string `json:"site_logo"`
APIBaseURL string `json:"api_base_url"` SiteSubtitle string `json:"site_subtitle"`
ContactInfo string `json:"contact_info"` APIBaseURL string `json:"api_base_url"`
DocURL string `json:"doc_url"` ContactInfo string `json:"contact_info"`
HomeContent string `json:"home_content"` DocURL string `json:"doc_url"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HomeContent string `json:"home_content"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
SoraClientEnabled bool `json:"sora_client_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"` SoraClientEnabled bool `json:"sora_client_enabled"`
Version string `json:"version"`
} }
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段) // SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
......
...@@ -32,27 +32,28 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -32,27 +32,28 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
} }
response.Success(c, dto.PublicSettings{ response.Success(c, dto.PublicSettings{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: settings.PasswordResetEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileEnabled: settings.TurnstileEnabled,
SiteName: settings.SiteName, TurnstileSiteKey: settings.TurnstileSiteKey,
SiteLogo: settings.SiteLogo, SiteName: settings.SiteName,
SiteSubtitle: settings.SiteSubtitle, SiteLogo: settings.SiteLogo,
APIBaseURL: settings.APIBaseURL, SiteSubtitle: settings.SiteSubtitle,
ContactInfo: settings.ContactInfo, APIBaseURL: settings.APIBaseURL,
DocURL: settings.DocURL, ContactInfo: settings.ContactInfo,
HomeContent: settings.HomeContent, DocURL: settings.DocURL,
HideCcsImportButton: settings.HideCcsImportButton, HomeContent: settings.HomeContent,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
SoraClientEnabled: settings.SoraClientEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: h.version, SoraClientEnabled: settings.SoraClientEnabled,
Version: h.version,
}) })
} }
...@@ -446,9 +446,10 @@ func TestAPIContracts(t *testing.T) { ...@@ -446,9 +446,10 @@ func TestAPIContracts(t *testing.T) {
setup: func(t *testing.T, deps *contractDeps) { setup: func(t *testing.T, deps *contractDeps) {
t.Helper() t.Helper()
deps.settingRepo.SetAll(map[string]string{ deps.settingRepo.SetAll(map[string]string{
service.SettingKeyRegistrationEnabled: "true", service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyEmailVerifyEnabled: "false", service.SettingKeyEmailVerifyEnabled: "false",
service.SettingKeyPromoCodeEnabled: "true", service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
service.SettingKeyPromoCodeEnabled: "true",
service.SettingKeySMTPHost: "smtp.example.com", service.SettingKeySMTPHost: "smtp.example.com",
service.SettingKeySMTPPort: "587", service.SettingKeySMTPPort: "587",
...@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) { ...@@ -487,6 +488,7 @@ func TestAPIContracts(t *testing.T) {
"data": { "data": {
"registration_enabled": true, "registration_enabled": true,
"email_verify_enabled": false, "email_verify_enabled": false,
"registration_email_suffix_whitelist": [],
"promo_code_enabled": true, "promo_code_enabled": true,
"password_reset_enabled": false, "password_reset_enabled": false,
"totp_enabled": false, "totp_enabled": false,
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strconv"
"strings" "strings"
"time" "time"
...@@ -33,6 +34,7 @@ var ( ...@@ -33,6 +34,7 @@ var (
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
...@@ -115,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw ...@@ -115,6 +117,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
if isReservedEmail(email) { if isReservedEmail(email) {
return "", nil, ErrEmailReserved return "", nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return "", nil, err
}
// 检查是否需要邀请码 // 检查是否需要邀请码
var invitationRedeemCode *RedeemCode var invitationRedeemCode *RedeemCode
...@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { ...@@ -241,6 +246,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
if isReservedEmail(email) { if isReservedEmail(email) {
return ErrEmailReserved return ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
...@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S ...@@ -279,6 +287,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
if isReservedEmail(email) { if isReservedEmail(email) {
return nil, ErrEmailReserved return nil, ErrEmailReserved
} }
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return nil, err
}
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
...@@ -624,6 +635,32 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int ...@@ -624,6 +635,32 @@ func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int
} }
} }
func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email string) error {
if s.settingService == nil {
return nil
}
whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx)
if !IsRegistrationEmailSuffixAllowed(email, whitelist) {
return buildEmailSuffixNotAllowedError(whitelist)
}
return nil
}
func buildEmailSuffixNotAllowedError(whitelist []string) error {
if len(whitelist) == 0 {
return ErrEmailSuffixNotAllowed
}
allowed := strings.Join(whitelist, ", ")
return infraerrors.BadRequest(
"EMAIL_SUFFIX_NOT_ALLOWED",
fmt.Sprintf("email suffix is not allowed, allowed suffixes: %s", allowed),
).WithMetadata(map[string]string{
"allowed_suffixes": strings.Join(whitelist, ","),
"allowed_suffix_count": strconv.Itoa(len(whitelist)),
})
}
// ValidateToken 验证JWT token并返回用户声明 // ValidateToken 验证JWT token并返回用户声明
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
// 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。 // 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。
......
...@@ -9,6 +9,7 @@ import ( ...@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) { ...@@ -231,6 +232,51 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
require.ErrorIs(t, err, ErrEmailReserved) require.ErrorIs(t, err, ErrEmailReserved)
} }
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
_, _, err := service.Register(context.Background(), "user@other.com", "password")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"])
}
func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
repo := &userRepoStub{nextID: 8}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["example.com"]`,
}, nil)
_, user, err := service.Register(context.Background(), "user@example.com", "password")
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, int64(8), user.ID)
}
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
}, nil)
err := service.SendVerifyCode(context.Background(), "user@other.com")
require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
appErr := infraerrors.FromError(err)
require.Contains(t, appErr.Message, "@example.com")
require.Contains(t, appErr.Message, "@company.com")
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
}
func TestAuthService_Register_CreateError(t *testing.T) { func TestAuthService_Register_CreateError(t *testing.T) {
repo := &userRepoStub{createErr: errors.New("create failed")} repo := &userRepoStub{createErr: errors.New("create failed")}
service := newAuthService(repo, map[string]string{ service := newAuthService(repo, map[string]string{
...@@ -402,7 +448,7 @@ func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) { ...@@ -402,7 +448,7 @@ func TestAuthService_Register_AssignsDefaultSubscriptions(t *testing.T) {
repo := &userRepoStub{nextID: 42} repo := &userRepoStub{nextID: 42}
assigner := &defaultSubscriptionAssignerStub{} assigner := &defaultSubscriptionAssignerStub{}
service := newAuthService(repo, map[string]string{ service := newAuthService(repo, map[string]string{
SettingKeyRegistrationEnabled: "true", SettingKeyRegistrationEnabled: "true",
SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`, SettingKeyDefaultSubscriptions: `[{"group_id":11,"validity_days":30},{"group_id":12,"validity_days":7}]`,
}, nil) }, nil)
service.defaultSubAssigner = assigner service.defaultSubAssigner = assigner
......
...@@ -74,11 +74,12 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" ...@@ -74,11 +74,12 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
// Setting keys // Setting keys
const ( const (
// 注册设置 // 注册设置
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
// 邮件服务设置 // 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
......
package service
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var registrationEmailDomainPattern = regexp.MustCompile(
`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`,
)
// RegistrationEmailSuffix extracts normalized suffix in "@domain" form.
func RegistrationEmailSuffix(email string) string {
_, domain, ok := splitEmailForPolicy(email)
if !ok {
return ""
}
return "@" + domain
}
// IsRegistrationEmailSuffixAllowed checks whether an email is allowed by suffix whitelist.
// Empty whitelist means allow all.
func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool {
if len(whitelist) == 0 {
return true
}
suffix := RegistrationEmailSuffix(email)
if suffix == "" {
return false
}
for _, allowed := range whitelist {
if suffix == allowed {
return true
}
}
return false
}
// NormalizeRegistrationEmailSuffixWhitelist normalizes and validates suffix whitelist items.
func NormalizeRegistrationEmailSuffixWhitelist(raw []string) ([]string, error) {
return normalizeRegistrationEmailSuffixWhitelist(raw, true)
}
// ParseRegistrationEmailSuffixWhitelist parses persisted JSON into normalized suffixes.
// Invalid entries are ignored to keep old misconfigurations from breaking runtime reads.
func ParseRegistrationEmailSuffixWhitelist(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []string{}
}
normalized, _ := normalizeRegistrationEmailSuffixWhitelist(items, false)
if len(normalized) == 0 {
return []string{}
}
return normalized
}
func normalizeRegistrationEmailSuffixWhitelist(raw []string, strict bool) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
normalized, err := normalizeRegistrationEmailSuffix(item)
if err != nil {
if strict {
return nil, err
}
continue
}
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func normalizeRegistrationEmailSuffix(raw string) (string, error) {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return "", nil
}
domain := value
if strings.Contains(value, "@") {
if !strings.HasPrefix(value, "@") || strings.Count(value, "@") != 1 {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
domain = strings.TrimPrefix(value, "@")
}
if domain == "" || strings.Contains(domain, "@") || !registrationEmailDomainPattern.MatchString(domain) {
return "", fmt.Errorf("invalid email suffix: %q", raw)
}
return "@" + domain, nil
}
func splitEmailForPolicy(raw string) (local string, domain string, ok bool) {
email := strings.ToLower(strings.TrimSpace(raw))
local, domain, found := strings.Cut(email, "@")
if !found || local == "" || domain == "" || strings.Contains(domain, "@") {
return "", "", false
}
return local, domain, true
}
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeRegistrationEmailSuffixWhitelist(t *testing.T) {
got, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"example.com", "@EXAMPLE.COM", " @foo.bar "})
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestNormalizeRegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
_, err := NormalizeRegistrationEmailSuffixWhitelist([]string{"@invalid_domain"})
require.Error(t, err)
}
func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) {
got := ParseRegistrationEmailSuffixWhitelist(`["example.com","@foo.bar","@invalid_domain"]`)
require.Equal(t, []string{"@example.com", "@foo.bar"}, got)
}
func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"}))
require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"}))
require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{}))
}
...@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -108,6 +108,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
keys := []string{ keys := []string{
SettingKeyRegistrationEnabled, SettingKeyRegistrationEnabled,
SettingKeyEmailVerifyEnabled, SettingKeyEmailVerifyEnabled,
SettingKeyRegistrationEmailSuffixWhitelist,
SettingKeyPromoCodeEnabled, SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled, SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled, SettingKeyInvitationCodeEnabled,
...@@ -144,29 +145,33 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -144,29 +145,33 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
// Password reset requires email verification to be enabled // Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
settings[SettingKeyRegistrationEmailSuffixWhitelist],
)
return &PublicSettings{ return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
PasswordResetEnabled: passwordResetEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", PasswordResetEnabled: passwordResetEnabled,
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteLogo: settings[SettingKeySiteLogo], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteLogo: settings[SettingKeySiteLogo],
APIBaseURL: settings[SettingKeyAPIBaseURL], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ContactInfo: settings[SettingKeyContactInfo], APIBaseURL: settings[SettingKeyAPIBaseURL],
DocURL: settings[SettingKeyDocURL], ContactInfo: settings[SettingKeyContactInfo],
HomeContent: settings[SettingKeyHomeContent], DocURL: settings[SettingKeyDocURL],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HomeContent: settings[SettingKeyHomeContent],
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
CustomMenuItems: settings[SettingKeyCustomMenuItems], SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
LinuxDoOAuthEnabled: linuxDoEnabled, CustomMenuItems: settings[SettingKeyCustomMenuItems],
LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
...@@ -196,51 +201,53 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -196,51 +201,53 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
// Return a struct that matches the frontend's expected format // Return a struct that matches the frontend's expected format
return &struct { return &struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PasswordResetEnabled bool `json:"password_reset_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"`
TotpEnabled bool `json:"totp_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"` TotpEnabled bool `json:"totp_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` TurnstileEnabled bool `json:"turnstile_enabled"`
SiteName string `json:"site_name"` TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
SiteLogo string `json:"site_logo,omitempty"` SiteName string `json:"site_name"`
SiteSubtitle string `json:"site_subtitle,omitempty"` SiteLogo string `json:"site_logo,omitempty"`
APIBaseURL string `json:"api_base_url,omitempty"` SiteSubtitle string `json:"site_subtitle,omitempty"`
ContactInfo string `json:"contact_info,omitempty"` APIBaseURL string `json:"api_base_url,omitempty"`
DocURL string `json:"doc_url,omitempty"` ContactInfo string `json:"contact_info,omitempty"`
HomeContent string `json:"home_content,omitempty"` DocURL string `json:"doc_url,omitempty"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HomeContent string `json:"home_content,omitempty"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"` SoraClientEnabled bool `json:"sora_client_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` CustomMenuItems json.RawMessage `json:"custom_menu_items"`
Version string `json:"version,omitempty"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version,omitempty"`
}{ }{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PasswordResetEnabled: settings.PasswordResetEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TotpEnabled: settings.TotpEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileEnabled: settings.TurnstileEnabled,
SiteName: settings.SiteName, TurnstileSiteKey: settings.TurnstileSiteKey,
SiteLogo: settings.SiteLogo, SiteName: settings.SiteName,
SiteSubtitle: settings.SiteSubtitle, SiteLogo: settings.SiteLogo,
APIBaseURL: settings.APIBaseURL, SiteSubtitle: settings.SiteSubtitle,
ContactInfo: settings.ContactInfo, APIBaseURL: settings.APIBaseURL,
DocURL: settings.DocURL, ContactInfo: settings.ContactInfo,
HomeContent: settings.HomeContent, DocURL: settings.DocURL,
HideCcsImportButton: settings.HideCcsImportButton, HomeContent: settings.HomeContent,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
SoraClientEnabled: settings.SoraClientEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), SoraClientEnabled: settings.SoraClientEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
Version: s.version, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version,
}, nil }, nil
} }
...@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -356,12 +363,25 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil { if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
return err return err
} }
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
}
if normalizedWhitelist == nil {
normalizedWhitelist = []string{}
}
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
updates := make(map[string]string) updates := make(map[string]string)
// 注册设置 // 注册设置
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return fmt.Errorf("marshal registration email suffix whitelist: %w", err)
}
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
...@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { ...@@ -514,6 +534,15 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
return value == "true" return value == "true"
} }
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
if err != nil {
return []string{}
}
return ParseRegistrationEmailSuffixWhitelist(value)
}
// IsPromoCodeEnabled 检查是否启用优惠码功能 // IsPromoCodeEnabled 检查是否启用优惠码功能
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
...@@ -617,20 +646,21 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -617,20 +646,21 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 初始化默认设置 // 初始化默认设置
defaults := map[string]string{ defaults := map[string]string{
SettingKeyRegistrationEnabled: "true", SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false", SettingKeyEmailVerifyEnabled: "false",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeySiteName: "Sub2API", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeySiteLogo: "", SettingKeySiteName: "Sub2API",
SettingKeyPurchaseSubscriptionEnabled: "false", SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionURL: "", SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeySoraClientEnabled: "false", SettingKeyPurchaseSubscriptionURL: "",
SettingKeyCustomMenuItems: "[]", SettingKeySoraClientEnabled: "false",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyCustomMenuItems: "[]",
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeySMTPPort: "587", SettingKeyDefaultSubscriptions: "[]",
SettingKeySMTPUseTLS: "false", SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
// Model fallback defaults // Model fallback defaults
SettingKeyEnableModelFallback: "false", SettingKeyEnableModelFallback: "false",
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
...@@ -661,33 +691,34 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -661,33 +691,34 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
result := &SystemSettings{ result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled, EmailVerifyEnabled: emailVerifyEnabled,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true", InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost], TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPUsername: settings[SettingKeySMTPUsername], SMTPHost: settings[SettingKeySMTPHost],
SMTPFrom: settings[SettingKeySMTPFrom], SMTPUsername: settings[SettingKeySMTPUsername],
SMTPFromName: settings[SettingKeySMTPFromName], SMTPFrom: settings[SettingKeySMTPFrom],
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", SMTPFromName: settings[SettingKeySMTPFromName],
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
SiteLogo: settings[SettingKeySiteLogo], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteLogo: settings[SettingKeySiteLogo],
APIBaseURL: settings[SettingKeyAPIBaseURL], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ContactInfo: settings[SettingKeyContactInfo], APIBaseURL: settings[SettingKeyAPIBaseURL],
DocURL: settings[SettingKeyDocURL], ContactInfo: settings[SettingKeyContactInfo],
HomeContent: settings[SettingKeyHomeContent], DocURL: settings[SettingKeyDocURL],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HomeContent: settings[SettingKeyHomeContent],
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
CustomMenuItems: settings[SettingKeyCustomMenuItems], SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
CustomMenuItems: settings[SettingKeyCustomMenuItems],
} }
// 解析整数类型 // 解析整数类型
......
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type settingPublicRepoStub struct {
values map[string]string
}
func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
panic("unexpected Get call")
}
func (s *settingPublicRepoStub) GetValue(ctx context.Context, key string) (string, error) {
panic("unexpected GetValue call")
}
func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) error {
panic("unexpected Set call")
}
func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *settingPublicRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *settingPublicRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *settingPublicRepoStub) Delete(ctx context.Context, key string) error {
panic("unexpected Delete call")
}
func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelist(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "true",
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","@invalid_domain",""]`,
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"@example.com", "@foo.bar"}, settings.RegistrationEmailSuffixWhitelist)
}
...@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou ...@@ -172,6 +172,28 @@ func TestSettingService_UpdateSettings_DefaultSubscriptions_RejectsDuplicateGrou
require.Nil(t, repo.updates) require.Nil(t, repo.updates)
} }
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Normalized(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"example.com", "@EXAMPLE.com", " @foo.bar "},
})
require.NoError(t, err)
require.Equal(t, `["@example.com","@foo.bar"]`, repo.updates[SettingKeyRegistrationEmailSuffixWhitelist])
}
func TestSettingService_UpdateSettings_RegistrationEmailSuffixWhitelist_Invalid(t *testing.T) {
repo := &settingUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
RegistrationEmailSuffixWhitelist: []string{"@invalid_domain"},
})
require.Error(t, err)
require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err))
}
func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) { func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`) got := parseDefaultSubscriptions(`[{"group_id":11,"validity_days":30},{"group_id":11,"validity_days":60},{"group_id":0,"validity_days":10},{"group_id":12,"validity_days":99999}]`)
require.Equal(t, []DefaultSubscriptionSetting{ require.Equal(t, []DefaultSubscriptionSetting{
......
package service package service
type SystemSettings struct { type SystemSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool RegistrationEmailSuffixWhitelist []string
PasswordResetEnabled bool PromoCodeEnabled bool
InvitationCodeEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
...@@ -76,22 +77,23 @@ type DefaultSubscriptionSetting struct { ...@@ -76,22 +77,23 @@ type DefaultSubscriptionSetting struct {
} }
type PublicSettings struct { type PublicSettings struct {
RegistrationEnabled bool RegistrationEnabled bool
EmailVerifyEnabled bool EmailVerifyEnabled bool
PromoCodeEnabled bool RegistrationEmailSuffixWhitelist []string
PasswordResetEnabled bool PromoCodeEnabled bool
InvitationCodeEnabled bool PasswordResetEnabled bool
TotpEnabled bool // TOTP 双因素认证 InvitationCodeEnabled bool
TurnstileEnabled bool TotpEnabled bool // TOTP 双因素认证
TurnstileSiteKey string TurnstileEnabled bool
SiteName string TurnstileSiteKey string
SiteLogo string SiteName string
SiteSubtitle string SiteLogo string
APIBaseURL string SiteSubtitle string
ContactInfo string APIBaseURL string
DocURL string ContactInfo string
HomeContent string DocURL string
HideCcsImportButton bool HomeContent string
HideCcsImportButton bool
PurchaseSubscriptionEnabled bool PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string PurchaseSubscriptionURL string
......
...@@ -18,6 +18,7 @@ export interface SystemSettings { ...@@ -18,6 +18,7 @@ export interface SystemSettings {
// Registration settings // Registration settings
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean
...@@ -86,6 +87,7 @@ export interface SystemSettings { ...@@ -86,6 +87,7 @@ export interface SystemSettings {
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
registration_enabled?: boolean registration_enabled?: boolean
email_verify_enabled?: boolean email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean promo_code_enabled?: boolean
password_reset_enabled?: boolean password_reset_enabled?: boolean
invitation_code_enabled?: boolean invitation_code_enabled?: boolean
......
...@@ -312,6 +312,9 @@ export default { ...@@ -312,6 +312,9 @@ export default {
passwordMinLength: 'Password must be at least 6 characters', passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.', loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.', registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
loginSuccess: 'Login successful! Welcome back.', loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.', reloginRequired: 'Session expired. Please log in again.',
...@@ -3528,6 +3531,11 @@ export default { ...@@ -3528,6 +3531,11 @@ export default {
enableRegistrationHint: 'Allow new users to register', enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification', emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations', emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
'Only email addresses from the specified domains can register (for example, @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
promoCode: 'Promo Code', promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration', promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration', invitationCode: 'Invitation Code Registration',
......
...@@ -312,6 +312,8 @@ export default { ...@@ -312,6 +312,8 @@ export default {
passwordMinLength: '密码至少需要 6 个字符', passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。', loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。', registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
loginSuccess: '登录成功!欢迎回来。', loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。', reloginRequired: '会话已过期,请重新登录。',
...@@ -3698,6 +3700,11 @@ export default { ...@@ -3698,6 +3700,11 @@ export default {
enableRegistrationHint: '允许新用户注册', enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证', emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱', emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
'仅允许使用指定域名的邮箱注册账号(例如 @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: '留空则不限制',
promoCode: '优惠码', promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码', promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册', invitationCode: '邀请码注册',
......
...@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
return { return {
registration_enabled: false, registration_enabled: false,
email_verify_enabled: false, email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true, promo_code_enabled: true,
password_reset_enabled: false, password_reset_enabled: false,
invitation_code_enabled: false, invitation_code_enabled: false,
......
...@@ -87,6 +87,7 @@ export interface CustomMenuItem { ...@@ -87,6 +87,7 @@ export interface CustomMenuItem {
export interface PublicSettings { export interface PublicSettings {
registration_enabled: boolean registration_enabled: boolean
email_verify_enabled: boolean email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean promo_code_enabled: boolean
password_reset_enabled: boolean password_reset_enabled: boolean
invitation_code_enabled: boolean invitation_code_enabled: boolean
......
import { describe, expect, it } from 'vitest'
import { buildAuthErrorMessage } from '@/utils/authError'
describe('buildAuthErrorMessage', () => {
it('prefers response detail message when available', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
detail: 'detailed message',
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('detailed message')
})
it('falls back to response message when detail is unavailable', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('plain message')
})
it('falls back to error.message when response payload is unavailable', () => {
const message = buildAuthErrorMessage(
{
message: 'error message'
},
{ fallback: 'fallback' }
)
expect(message).toBe('error message')
})
it('uses fallback when no message can be extracted', () => {
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
})
})
import { describe, expect, it } from 'vitest'
import {
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
normalizeRegistrationEmailSuffixWhitelist,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
expect(
normalizeRegistrationEmailSuffixDomains([
'@example.com',
'Example.com',
'',
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
])
).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
})
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
expect(
normalizeRegistrationEmailSuffixWhitelist([
'@Example.com',
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
])
).toEqual(['@example.com', '@foo.bar'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
})
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
})
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment