Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
27abae21
Unverified
Commit
27abae21
authored
Mar 04, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 04, 2026
Browse files
Merge pull request #724 from PMExtra/feat/registration-email-domain-whitelist
feat(registration): add email domain whitelist policy
parents
0819c8a5
29fb447d
Changes
25
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/setting_handler.go
View file @
27abae21
...
@@ -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
...
...
backend/internal/handler/dto/settings.go
View file @
27abae21
...
@@ -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(响应用,不含敏感字段)
...
...
backend/internal/handler/setting_handler.go
View file @
27abae21
...
@@ -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
,
})
})
}
}
backend/internal/server/api_contract_test.go
View file @
27abae21
...
@@ -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,
...
...
backend/internal/service/auth_service.go
View file @
27abae21
...
@@ -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 风险。
...
...
backend/internal/service/auth_service_register_test.go
View file @
27abae21
...
@@ -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
...
...
backend/internal/service/domain_constants.go
View file @
27abae21
...
@@ -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服务器地址
...
...
backend/internal/service/registration_email_policy.go
0 → 100644
View file @
27abae21
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
}
backend/internal/service/registration_email_policy_test.go
0 → 100644
View file @
27abae21
//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
{}))
}
backend/internal/service/setting_service.go
View file @
27abae21
...
@@ -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
],
}
}
// 解析整数类型
// 解析整数类型
...
...
backend/internal/service/setting_service_public_test.go
0 → 100644
View file @
27abae21
//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
)
}
backend/internal/service/setting_service_update_test.go
View file @
27abae21
...
@@ -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
{
...
...
backend/internal/service/settings_view.go
View file @
27abae21
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
...
...
frontend/src/api/admin/settings.ts
View file @
27abae21
...
@@ -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
...
...
frontend/src/i18n/locales/en.ts
View file @
27abae21
...
@@ -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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
27abae21
...
@@ -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
:
'
邀请码注册
'
,
...
...
frontend/src/stores/app.ts
View file @
27abae21
...
@@ -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
,
...
...
frontend/src/types/index.ts
View file @
27abae21
...
@@ -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
...
...
frontend/src/utils/__tests__/authError.spec.ts
0 → 100644
View file @
27abae21
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
'
)
})
})
frontend/src/utils/__tests__/registrationEmailPolicy.spec.ts
0 → 100644
View file @
27abae21
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
)
})
})
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment