修复初始化状态并发竞争与邮箱字段兼容
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-30 23:45:48 +08:00
parent b365c2535f
commit 64749a578b
11 changed files with 43 additions and 29 deletions

View File

@ -12,10 +12,12 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
var IsInit bool
var legacyInitState atomic.Bool
var DevelopmentMode bool
type Config struct {
@ -197,7 +199,7 @@ func Init() error {
Instance.Domains = []string{Instance.Domain}
}
IsInit = Instance.IsSetupReady()
SetInitState(Instance.IsSetupReady())
PublishConfigSnapshot(Instance)
// 设置日志格式为json格式
@ -238,6 +240,15 @@ func (c *Config) IsSetupReady() bool {
return c != nil && c.SetupState == SetupStateReady
}
func SetInitState(value bool) {
legacyInitState.Store(value)
IsInit = value
}
func legacyInitReady() bool {
return legacyInitState.Load()
}
func ReadPrivateKey() (*ecdsa.PrivateKey, bool, error) {
privateKey, created, err := loadOrCreateACMEPrivateKey(filepath.Join(ROOT_PATH, "config", "ssl"), "account_private.pem")
if err != nil {

View File

@ -16,7 +16,7 @@ func IsSetupReady() bool {
if value != nil && value.SetupState.Valid() {
return value.SetupState == SetupStateReady
}
return IsInit
return legacyInitReady()
}
// PublishConfigSnapshot 发布一份深拷贝,供运行时只读访问。

View File

@ -99,8 +99,8 @@ func TestV1RouteDoesNotReceiveLegacyDeprecationHeaders(t *testing.T) {
func TestV1LoginUsesSameRequestOriginGuard(t *testing.T) {
oldInit := config.IsInit
t.Cleanup(func() { config.IsInit = oldInit })
config.IsInit = false
t.Cleanup(func() { config.SetInitState(oldInit) })
config.SetInitState(false)
handler := contextIterceptor(controllers.Login)
request := httptest.NewRequest(http.MethodPost, "http://mail.example.com/api/v1/login", nil)

View File

@ -47,10 +47,10 @@ func TestCSRFRejectsCrossOriginCookieRequest(t *testing.T) {
oldInit := config.IsInit
oldSession := session.Instance
defer func() {
config.IsInit = oldInit
config.SetInitState(oldInit)
session.Instance = oldSession
}()
config.IsInit = false
config.SetInitState(false)
session.Instance = scs.New()
session.Instance.Cookie.Name = "smail_session"
@ -81,10 +81,10 @@ func TestCSRFAllowsSameOriginCookieRequest(t *testing.T) {
oldInit := config.IsInit
oldSession := session.Instance
defer func() {
config.IsInit = oldInit
config.SetInitState(oldInit)
session.Instance = oldSession
}()
config.IsInit = false
config.SetInitState(false)
session.Instance = scs.New()
session.Instance.Cookie.Name = "smail_session"

View File

@ -25,12 +25,12 @@ func TestModifyPasswordRouteRejectsUnauthenticatedRequest(t *testing.T) {
oldSession := session.Instance
t.Cleanup(func() {
config.Instance = oldConfig
config.IsInit = oldInit
config.SetInitState(oldInit)
session.Instance = oldSession
})
config.Instance = &config.Config{}
config.IsInit = true
config.SetInitState(true)
session.Instance = scs.New()
mux := http.NewServeMux()
@ -66,7 +66,7 @@ func TestSuperAdminCannotChangeOwnPasswordAfterLogin(t *testing.T) {
_ = db.Instance.Close()
}
config.Instance = oldConfig
config.IsInit = oldInit
config.SetInitState(oldInit)
db.Instance = oldDB
session.Instance = oldSession
})
@ -75,7 +75,7 @@ func TestSuperAdminCannotChangeOwnPasswordAfterLogin(t *testing.T) {
DbType: config.DBTypeSQLite,
DbDSN: filepath.Join(testRoot, "super-admin-password.db"),
}
config.IsInit = true
config.SetInitState(true)
if err := db.Init(""); err != nil {
t.Fatal(err)
}

View File

@ -29,13 +29,13 @@ func TestContextInterceptorValidatesCurrentUserState(t *testing.T) {
_ = db.Instance.Close()
}
config.Instance = oldConfig
config.IsInit = oldInit
config.SetInitState(oldInit)
db.Instance = oldDB
session.Instance = oldSession
})
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "session-validation.db")}
config.IsInit = true
config.SetInitState(true)
if err := db.Init(""); err != nil {
t.Fatal(err)
}

View File

@ -21,9 +21,9 @@ func withSetupSecurity(t *testing.T, token string) *setupSecurity {
if err := os.Unsetenv("SMAIL_SETUP_ALLOWED_CIDRS"); err != nil {
t.Fatal(err)
}
config.IsInit = false
config.SetInitState(false)
t.Cleanup(func() {
config.IsInit = oldInit
config.SetInitState(oldInit)
_ = os.Setenv("SMAIL_SETUP_TOKEN", oldToken)
_ = os.Setenv("SMAIL_SETUP_ALLOWED_CIDRS", oldCIDRs)
})
@ -65,7 +65,7 @@ func TestSetupSecurityAcceptsTokenOnce(t *testing.T) {
calls := 0
handler := security.authorize(func(w http.ResponseWriter, r *http.Request) {
calls++
config.IsInit = true
config.SetInitState(true)
w.WriteHeader(http.StatusNoContent)
})
@ -77,7 +77,7 @@ func TestSetupSecurityAcceptsTokenOnce(t *testing.T) {
t.Fatalf("first request failed: code=%d calls=%d", recorder.Code, calls)
}
config.IsInit = false
config.SetInitState(false)
request = setupRequest(http.MethodPost)
request.Header.Set("X-Setup-Token", token)
recorder = httptest.NewRecorder()
@ -100,7 +100,7 @@ func TestSetupSecuritySerializesConcurrentTokenRequests(t *testing.T) {
callsMu.Unlock()
close(started)
<-continueRequest
config.IsInit = true
config.SetInitState(true)
w.WriteHeader(http.StatusNoContent)
})
@ -243,7 +243,7 @@ func TestSetupAPIRoutesBecomeInvalidAfterSetupCompletes(t *testing.T) {
calls := 0
registerSetupAPIRoutes(mux, security, setupOnlyPost(func(w http.ResponseWriter, r *http.Request) {
calls++
config.IsInit = true
config.SetInitState(true)
w.WriteHeader(http.StatusNoContent)
}))
@ -256,7 +256,7 @@ func TestSetupAPIRoutesBecomeInvalidAfterSetupCompletes(t *testing.T) {
t.Fatalf("first setup returned code=%d calls=%d", recorder.Code, calls)
}
config.IsInit = false
config.SetInitState(false)
for _, endpoint := range []string{"/api/setup", "/api/v1/setup"} {
request = httptest.NewRequest(http.MethodPost, endpoint, nil)
request.RemoteAddr = "127.0.0.1:12345"
@ -280,9 +280,9 @@ func TestSetupSecurityBootstrapCookieForAllowedRemote(t *testing.T) {
if err := os.Setenv("SMAIL_SETUP_ALLOWED_CIDRS", "192.168.2.0/24"); err != nil {
t.Fatal(err)
}
config.IsInit = false
config.SetInitState(false)
t.Cleanup(func() {
config.IsInit = oldInit
config.SetInitState(oldInit)
_ = os.Setenv("SMAIL_SETUP_TOKEN", oldToken)
_ = os.Setenv("SMAIL_SETUP_ALLOWED_CIDRS", oldCIDRs)
})

View File

@ -7,7 +7,7 @@ type UserEmail struct {
UserID int `xorm:"user_id int not null index('idx_eid') index('idx_user_email_user_primary')"`
EmailID int `xorm:"email_id not null index('idx_eid') index"`
Address string `xorm:"address varchar(254) null" json:"address,omitempty"`
AddressCanonical string `xorm:"address_canonical varchar(254) null unique" json:"-"`
AddressCanonical *string `xorm:"address_canonical varchar(254) null unique" json:"-"`
IsPrimary int8 `xorm:"is_primary tinyint(1) notnull default(0)" json:"is_primary"`
VerifiedAt *time.Time `xorm:"verified_at datetime null" json:"verified_at,omitempty"`
CreatedAt time.Time `xorm:"created_at datetime created" json:"created_at"`

View File

@ -135,10 +135,11 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T
t.Fatal(err)
}
verifiedAt := time.Now().UTC()
canonicalAddress := "alice@example.com"
if _, err := db.Instance.Insert(&models.UserEmail{
UserID: user.ID,
Address: "alice@example.com",
AddressCanonical: "alice@example.com",
AddressCanonical: &canonicalAddress,
VerifiedAt: &verifiedAt,
}); err != nil {
t.Fatal(err)

View File

@ -37,18 +37,20 @@ func TestResolveIdentifierUsesVerifiedFullEmailBinding(t *testing.T) {
t.Fatal(err)
}
verifiedAt := time.Now().UTC()
canonicalAddress := "alice@example.com"
if _, err := db.Instance.Insert(&models.UserEmail{
UserID: user.ID,
Address: "Alice@Example.com",
AddressCanonical: "alice@example.com",
AddressCanonical: &canonicalAddress,
VerifiedAt: &verifiedAt,
}); err != nil {
t.Fatal(err)
}
pendingAddress := "pending@example.com"
if _, err := db.Instance.Insert(&models.UserEmail{
UserID: user.ID,
Address: "pending@example.com",
AddressCanonical: "pending@example.com",
AddressCanonical: &pendingAddress,
}); err != nil {
t.Fatal(err)
}

View File

@ -18,7 +18,7 @@ func Finish() error {
return errors.Wrap(err)
}
if cfg.IsSetupReady() {
config.IsInit = true
config.SetInitState(true)
return nil
}
cfg.SetupState = config.SetupStateReady
@ -28,7 +28,7 @@ func Finish() error {
if err != nil {
return errors.Wrap(err)
}
config.IsInit = true
config.SetInitState(true)
// 初始化完成
select {
case signal.InitChan <- true: