feat: unify email identity and administrator security
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -52,7 +52,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END, lower(COALESCE(NULLIF(u.email,''),u.login_name)), lower(u.display_name), u.created_at`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
@@ -108,20 +108,19 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
primaryEmail, err := cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = loginName
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
@@ -131,8 +130,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
if role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
@@ -161,7 +160,7 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, primaryEmail, primaryEmail, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -190,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
@@ -218,16 +218,29 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
if existing.Role == "admin" && role != "admin" {
|
||||
badRequest(w, errors.New("唯一管理员不能降级"))
|
||||
return
|
||||
}
|
||||
if existing.Role != "admin" && role == "admin" {
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
primaryEmail := existing.Email
|
||||
loginName := existing.LoginName
|
||||
if requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
if strings.TrimSpace(emailInput) != "" {
|
||||
primaryEmail, err = cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
loginName = primaryEmail
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
if current == nil || (current.Role != "admin" && existing.Role == "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
@@ -239,6 +252,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" && disabled {
|
||||
badRequest(w, errors.New("唯一管理员不能停用"))
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||
if req.MailboxLimitOverride != nil {
|
||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
@@ -294,14 +311,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
loginName, primaryEmail, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("登录名已被使用"))
|
||||
badRequest(w, errors.New("主登录邮箱已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
@@ -317,6 +330,11 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if existing.Role == "admin" {
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.AdminEmail = primaryEmail
|
||||
})
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
@@ -575,11 +593,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusForbidden, "管理员只能由安装流程创建")
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
@@ -617,12 +632,16 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
|
||||
ownerEmailInput := req.OwnerEmail
|
||||
if strings.TrimSpace(ownerEmailInput) == "" && strings.Contains(strings.TrimSpace(req.OwnerLoginName), "@") {
|
||||
ownerEmailInput = req.OwnerLoginName
|
||||
}
|
||||
ownerEmail, err := cleanPrimaryEmail(firstNonEmpty(ownerEmailInput, address))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -631,11 +650,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
userID = newID("usr")
|
||||
ownerDisplayName := displayName
|
||||
if !strings.EqualFold(ownerLoginName, address) {
|
||||
ownerDisplayName = ownerLoginName
|
||||
if !strings.EqualFold(ownerEmail, address) {
|
||||
ownerDisplayName = ownerEmail
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
|
||||
+248
-104
@@ -33,6 +33,7 @@ type App struct {
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
turnstileURL string
|
||||
}
|
||||
|
||||
func (a *App) config() Config {
|
||||
@@ -92,6 +93,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||
@@ -185,6 +190,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, code_hash)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -939,81 +952,11 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
|
||||
// migrateLegacyBootstrapMailbox used to remove implicit bootstrap mailboxes.
|
||||
// Administrators now use a real mailbox as their primary login address, so old
|
||||
// bootstrap mailboxes must be preserved and normalized by the admin identity
|
||||
// migration instead of deleted.
|
||||
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `
|
||||
SELECT mb.id, mb.domain_id
|
||||
FROM mailboxes mb
|
||||
JOIN users u ON u.id=mb.user_id
|
||||
WHERE mb.address=?
|
||||
AND mb.display_name='LanQin Admin'
|
||||
AND u.email=?
|
||||
AND u.role='admin'`, adminEmail, adminEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyMailbox struct {
|
||||
id string
|
||||
domainID string
|
||||
}
|
||||
items := []legacyMailbox{}
|
||||
for rows.Next() {
|
||||
var item legacyMailbox
|
||||
if err := rows.Scan(&item.id, &item.domainID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for messageRows.Next() {
|
||||
var messageID string
|
||||
if err := messageRows.Scan(&messageID); err != nil {
|
||||
messageRows.Close()
|
||||
return err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := messageRows.Err(); err != nil {
|
||||
messageRows.Close()
|
||||
return err
|
||||
}
|
||||
if err := messageRows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessage(ctx, messageID)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `
|
||||
DELETE FROM domains
|
||||
WHERE id=?
|
||||
AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1412,7 +1355,11 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
adminEmail, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||
if err != nil {
|
||||
return errors.New("LANQIN_ADMIN_EMAIL must be set to a valid email for a new installation")
|
||||
}
|
||||
|
||||
adminPassword := cfg.AdminPassword
|
||||
@@ -1430,25 +1377,8 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return errors.New("invalid admin email")
|
||||
}
|
||||
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminEmail, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||
@@ -1483,18 +1413,221 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
return a.migrateConfiguredAdministratorIdentity(ctx)
|
||||
}
|
||||
|
||||
func (a *App) migrateConfiguredAdministratorIdentity(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
if adminUsername := normalizeLoginName(cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
LoginName string `json:"loginName,omitempty"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE role='admin' ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminEmail := normalizeEmail(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
admins := []adminUser{}
|
||||
for rows.Next() {
|
||||
var item adminUser
|
||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
admins = append(admins, item)
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
if configuredEmail := normalizeEmail(cfg.AdminEmail); configuredEmail != "" {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE email=? LIMIT 1`, configuredEmail)
|
||||
var item adminUser
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||
admins = append(admins, item)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(admins) == 0 && strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername := normalizeLoginName(cfg.AdminUsername)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,password_hash,created_at FROM users WHERE login_name=? OR email=? ORDER BY created_at,id LIMIT 1`, adminUsername, adminUsername)
|
||||
var item adminUser
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.PasswordHash, &item.CreatedAt); err == nil {
|
||||
admins = append(admins, item)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
return errors.New("no administrator user found for identity migration")
|
||||
}
|
||||
keeper := admins[0]
|
||||
adminEmail, emailSource, err := a.resolveAdministratorEmail(ctx, cfg, keeper.ID, keeper.LoginName, keeper.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var conflictID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND id<>? LIMIT 1`, adminEmail, keeper.ID).Scan(&conflictID); err == nil {
|
||||
return fmt.Errorf("admin email %s already belongs to user %s", adminEmail, conflictID)
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
demoted := []adminUser{}
|
||||
for _, admin := range admins[1:] {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE users SET role='user', updated_at=? WHERE id=?`, now, admin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
admin.PasswordHash = ""
|
||||
demoted = append(demoted, admin)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE users SET login_name=?, email=?, role='admin', disabled=0, updated_at=? WHERE id=?`,
|
||||
adminEmail, adminEmail, now, keeper.ID); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return fmt.Errorf("admin identity migration conflict: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
parts := strings.SplitN(adminEmail, "@", 2)
|
||||
localPart := parts[0]
|
||||
domainName := normalizeDomain(parts[1])
|
||||
var domainID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
domainID, err = a.createDomainTx(ctx, tx, domainName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mailboxCreated := false
|
||||
var mailboxID, mailboxUserID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id,user_id FROM mailboxes WHERE address=?`, adminEmail).Scan(&mailboxID, &mailboxUserID); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
mailboxID, err = a.createMailboxWithPasswordHashTx(ctx, tx, keeper.ID, domainID, localPart, adminEmail, keeper.PasswordHash, 1024, "active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxCreated = true
|
||||
} else if mailboxUserID != keeper.ID {
|
||||
return fmt.Errorf("admin mailbox %s already belongs to user %s", adminEmail, mailboxUserID)
|
||||
}
|
||||
result := map[string]any{
|
||||
"adminUserId": keeper.ID,
|
||||
"adminEmail": adminEmail,
|
||||
"emailSource": emailSource,
|
||||
"previousEmail": keeper.Email,
|
||||
"demotedAdmins": demoted,
|
||||
"mailboxId": mailboxID,
|
||||
"mailboxCreated": mailboxCreated,
|
||||
"migratedAt": now,
|
||||
}
|
||||
raw, _ := json.Marshal(result)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, "adminIdentityMigrationResult", string(raw), now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.updateConfig(func(current *Config) {
|
||||
current.AdminEmail = adminEmail
|
||||
if current.MailDomain == "" {
|
||||
current.MailDomain = domainName
|
||||
}
|
||||
})
|
||||
a.log.Info("administrator identity migration complete", "adminEmail", adminEmail, "adminUserId", keeper.ID, "demotedAdmins", len(demoted), "mailboxCreated", mailboxCreated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) resolveAdministratorEmail(ctx context.Context, cfg Config, userID, loginName, existingEmail string) (string, string, error) {
|
||||
// Once initialized, the database identity is authoritative. This keeps an
|
||||
// administrator email changed in the UI from reverting to the installer value.
|
||||
if email, err := cleanPrimaryEmail(existingEmail); err == nil {
|
||||
return email, "existing_admin_email", nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.AdminEmail) != "" {
|
||||
email, err := cleanPrimaryEmail(cfg.AdminEmail)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid LANQIN_ADMIN_EMAIL: %w", err)
|
||||
}
|
||||
return email, "configured_admin_email", nil
|
||||
}
|
||||
|
||||
preferredLocalPart := normalizeLocalPart(cfg.AdminUsername)
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = normalizeLocalPart(loginName)
|
||||
}
|
||||
if preferredLocalPart == "" || strings.Contains(preferredLocalPart, "@") {
|
||||
preferredLocalPart = "admin"
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT address FROM mailboxes WHERE user_id=? ORDER BY CASE WHEN lower(local_part)=? THEN 0 WHEN lower(local_part)='admin' THEN 1 ELSE 2 END, created_at, id`, userID, preferredLocalPart)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for rows.Next() {
|
||||
var address string
|
||||
if err := rows.Scan(&address); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if email, err := cleanPrimaryEmail(address); err == nil {
|
||||
rows.Close()
|
||||
return email, "existing_admin_mailbox", nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if domain := normalizeDomain(cfg.MailDomain); validMailDomain(domain) {
|
||||
return preferredLocalPart + "@" + domain, "configured_mail_domain", nil
|
||||
}
|
||||
var onlyDomain string
|
||||
var domainCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MIN(name),'') FROM domains`).Scan(&domainCount, &onlyDomain); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if domainCount == 1 && validMailDomain(onlyDomain) {
|
||||
return preferredLocalPart + "@" + normalizeDomain(onlyDomain), "single_existing_domain", nil
|
||||
}
|
||||
publicDomain := normalizeDomain(cfg.PublicHostname)
|
||||
if strings.HasPrefix(publicDomain, "mail.") {
|
||||
publicDomain = strings.TrimPrefix(publicDomain, "mail.")
|
||||
}
|
||||
if validMailDomain(publicDomain) && !strings.HasSuffix(publicDomain, ".local") {
|
||||
return preferredLocalPart + "@" + publicDomain, "public_hostname", nil
|
||||
}
|
||||
return "", "", errors.New("cannot determine administrator email; set LANQIN_ADMIN_EMAIL or LANQIN_MAIL_DOMAIN before updating")
|
||||
}
|
||||
|
||||
func validMailDomain(domain string) bool {
|
||||
domain = normalizeDomain(domain)
|
||||
return domain != "" && strings.Contains(domain, ".") && !strings.ContainsAny(domain, "@/ :")
|
||||
}
|
||||
|
||||
func (a *App) enforceSingleAdministratorIndex(ctx context.Context) error {
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users(role) WHERE role='admin'`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1616,13 +1749,24 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC()
|
||||
systemDomain := normalizeDomain(cfg.MailDomain)
|
||||
if systemDomain == "" && strings.Contains(cfg.AdminEmail, "@") {
|
||||
systemDomain = normalizeDomain(strings.SplitN(cfg.AdminEmail, "@", 2)[1])
|
||||
}
|
||||
if systemDomain == "" {
|
||||
systemDomain = normalizeDomain(cfg.PublicHostname)
|
||||
}
|
||||
if systemDomain == "" {
|
||||
systemDomain = "lanqin.local"
|
||||
}
|
||||
systemAddress := "system@" + systemDomain
|
||||
subject := "欢迎使用 NewSzxcn 邮箱"
|
||||
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
||||
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
|
||||
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
|
||||
rendered := renderMailTemplate(tpl, templateRenderData{
|
||||
To: cfg.AdminEmail,
|
||||
From: "system@lanqin.local",
|
||||
From: systemAddress,
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Time: now,
|
||||
@@ -1633,9 +1777,9 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
MailboxID: mailboxID,
|
||||
FolderID: folderID,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
|
||||
MessageID: fmt.Sprintf("<%s@%s>", newID("msg"), systemDomain),
|
||||
Subject: subject,
|
||||
From: "system@lanqin.local",
|
||||
From: systemAddress,
|
||||
FromName: "NewSzxcn 邮箱",
|
||||
To: []string{cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
|
||||
@@ -413,6 +413,20 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
|
||||
return group
|
||||
}
|
||||
|
||||
func setRegularPermissionGroupForTest(t *testing.T, a *App, permissions []string, limits PermissionLimits) PermissionGroup {
|
||||
t.Helper()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(context.Background(), `UPDATE permission_groups SET permissions_json=?, limits_json=?, updated_at=? WHERE id=?`,
|
||||
encodePermissions(permissions), encodePermissionLimits(limits), now, PermissionGroupRegular); err != nil {
|
||||
t.Fatalf("set regular permission group fixture: %v", err)
|
||||
}
|
||||
group, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||
if err != nil {
|
||||
t.Fatalf("load regular permission group fixture: %v", err)
|
||||
}
|
||||
return *group
|
||||
}
|
||||
|
||||
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
return map[string]any{
|
||||
"publicHostname": settings.PublicHostname,
|
||||
@@ -1279,7 +1293,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, MaxMailboxCount: 9, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
|
||||
@@ -1333,7 +1347,7 @@ func TestPermissionGroupMailLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
func TestOpenRegistrationAtomicallyCreatesLoginUserAndMailbox(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
@@ -1345,32 +1359,92 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
a.updateConfig(func(cfg *Config) { cfg.OpenRegistration = true })
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
registration := map[string]string{
|
||||
"email": "newuser@lanqin.local",
|
||||
"displayName": "New User",
|
||||
"password": "Password123!",
|
||||
"domainId": domainID,
|
||||
"localPart": "newuser",
|
||||
}
|
||||
var registered struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" {
|
||||
if code := client.do("POST", "/api/auth/register", registration, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@lanqin.local" || registered.User.Role != "user" {
|
||||
t.Fatalf("register code=%d user=%+v", code, registered.User)
|
||||
}
|
||||
var me struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" {
|
||||
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@lanqin.local" {
|
||||
t.Fatalf("me code=%d user=%+v", code, me.User)
|
||||
}
|
||||
var mine struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
|
||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "newuser@lanqin.local" {
|
||||
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
|
||||
}
|
||||
|
||||
another := &testClient{t: t, server: ts}
|
||||
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK {
|
||||
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@lanqin.local", "password": "Password123!"}, &out); code != http.StatusOK {
|
||||
t.Fatalf("login registered user code=%d body=%v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
|
||||
func TestTurnstileRetainedForLoginAndRegistration(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
verifyCalls := 0
|
||||
verifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Form.Get("secret") != "secret-key" || r.Form.Get("response") == "" {
|
||||
t.Fatalf("turnstile form secret=%q response=%q", r.Form.Get("secret"), r.Form.Get("response"))
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"success": r.Form.Get("response") == "valid-token"})
|
||||
}))
|
||||
defer verifyServer.Close()
|
||||
a.turnstileURL = verifyServer.URL
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.OpenRegistration = true
|
||||
cfg.TurnstileEnabled = true
|
||||
cfg.TurnstileSiteKey = "site-key"
|
||||
cfg.TurnstileSecretKey = "secret-key"
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
client := &testClient{t: t, server: ts}
|
||||
|
||||
var public PublicSettings
|
||||
if code := client.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.TurnstileEnabled || public.TurnstileSiteKey != "site-key" {
|
||||
t.Fatalf("public turnstile settings code=%d settings=%+v", code, public)
|
||||
}
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("login without turnstile code=%d", code)
|
||||
}
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!", "turnstileToken": "valid-token"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login with turnstile code=%d body=%v", code, login)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
registerClient := &testClient{t: t, server: ts}
|
||||
registerPayload := map[string]string{"email": "turnstile-user@lanqin.local", "displayName": "Turnstile User", "password": "Password123!", "domainId": domainID, "localPart": "turnstile-user"}
|
||||
if code := registerClient.do("POST", "/api/auth/register", registerPayload, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("register without turnstile code=%d", code)
|
||||
}
|
||||
registerPayload["turnstileToken"] = "valid-token"
|
||||
var registered map[string]any
|
||||
if code := registerClient.do("POST", "/api/auth/register", registerPayload, ®istered); code != http.StatusCreated {
|
||||
t.Fatalf("register with turnstile code=%d body=%v", code, registered)
|
||||
}
|
||||
if verifyCalls != 2 {
|
||||
t.Fatalf("turnstile verifier calls=%d, want 2", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBootstrapMailboxMigrationKeepsAdminMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
@@ -1413,15 +1487,15 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin user count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy mailbox count=%d err=%v", count, err)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin mailbox count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy domain count=%d err=%v", count, err)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin domain count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
func TestConfiguredAdminEmailCreatesMailboxAndRejectsUsernameLogin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
@@ -1430,6 +1504,7 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminUsername: "admin",
|
||||
AdminEmail: "root@example.test",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
@@ -1444,8 +1519,15 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domains != 0 || mailboxes != 0 {
|
||||
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
|
||||
if domains != 1 || mailboxes != 1 {
|
||||
t.Fatalf("admin email bootstrap domains=%d mailboxes=%d", domains, mailboxes)
|
||||
}
|
||||
var welcomeFrom, welcomeMessageID string
|
||||
if err := a.db.QueryRow(`SELECT from_addr,message_id FROM messages ORDER BY created_at LIMIT 1`).Scan(&welcomeFrom, &welcomeMessageID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if welcomeFrom != "system@example.test" || !strings.HasSuffix(welcomeMessageID, "@example.test>") {
|
||||
t.Fatalf("welcome message retained placeholder domain: from=%q messageId=%q", welcomeFrom, welcomeMessageID)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -1454,33 +1536,339 @@ func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("username login code=%d", code)
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy username login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin email login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "rootadmin",
|
||||
"email": "root@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("rename administrator code=%d", code)
|
||||
t.Fatalf("admin display update code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "root@example.test",
|
||||
"email": "not-an-email",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusBadRequest {
|
||||
t.Fatalf("email-shaped login name code=%d", code)
|
||||
t.Fatalf("invalid primary email update code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorPrimaryEmailPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "root@example.test",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(a.Router())
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "root@example.test", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"email": "owner@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("admin email update code=%d", code)
|
||||
}
|
||||
if a.config().AdminEmail != "owner@example.test" {
|
||||
t.Fatalf("runtime admin email=%q", a.config().AdminEmail)
|
||||
}
|
||||
ts.Close()
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldLogin := &testClient{t: t, server: ts}
|
||||
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("old username login code=%d", code)
|
||||
restarted, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLogin := &testClient{t: t, server: ts}
|
||||
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("renamed username login code=%d", code)
|
||||
t.Cleanup(func() { _ = restarted.Close() })
|
||||
var email, loginName string
|
||||
if err := restarted.db.QueryRow(`SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "owner@example.test" || loginName != "owner@example.test" || restarted.config().AdminEmail != "owner@example.test" {
|
||||
t.Fatalf("administrator identity reverted after restart: email=%q login=%q config=%q", email, loginName, restarted.config().AdminEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAdminIdentityMigrationKeepsEarliestAdminAndRecordsResult(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
a.updateConfig(func(cfg *Config) {
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminEmail = "admin@example.test"
|
||||
})
|
||||
keeperHash, err := bcrypt.GenerateFromPassword([]byte("OriginalPass123!"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
demotedHash, err := bcrypt.GenerateFromPassword([]byte("OtherPass123!"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_users_single_admin`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC()
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin', password_hash=?, two_factor_secret='legacy-secret', two_factor_enabled=1, created_at=?, updated_at=? WHERE role='admin'`,
|
||||
string(keeperHash), now.Add(-2*time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES('usr_second_admin','second','second@example.test','Second Admin','admin',?,0,?,?)`, string(demotedHash), now.Add(-time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := a.migrateConfiguredAdministratorIdentity(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.enforceSingleAdministratorIndex(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var adminCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil || adminCount != 1 {
|
||||
t.Fatalf("admin count=%d err=%v", adminCount, err)
|
||||
}
|
||||
var email, loginName, passwordHash, twoFactorSecret string
|
||||
var enabled int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT email,login_name,password_hash,two_factor_secret,two_factor_enabled FROM users WHERE role='admin'`).Scan(&email, &loginName, &passwordHash, &twoFactorSecret, &enabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "admin@example.test" || loginName != "admin@example.test" || twoFactorSecret != "legacy-secret" || enabled != 1 {
|
||||
t.Fatalf("admin identity not migrated safely email=%q login=%q secret=%q enabled=%d", email, loginName, twoFactorSecret, enabled)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("OriginalPass123!")); err != nil {
|
||||
t.Fatalf("admin password hash was not preserved: %v", err)
|
||||
}
|
||||
var secondRole string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT role FROM users WHERE id='usr_second_admin'`).Scan(&secondRole); err != nil || secondRole != "user" {
|
||||
t.Fatalf("second admin role=%q err=%v", secondRole, err)
|
||||
}
|
||||
var mailboxCount int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address='admin@example.test'`).Scan(&mailboxCount); err != nil || mailboxCount != 1 {
|
||||
t.Fatalf("admin mailbox count=%d err=%v", mailboxCount, err)
|
||||
}
|
||||
var rawResult string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&rawResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rawResult, `"adminEmail":"admin@example.test"`) || !strings.Contains(rawResult, `"id":"usr_second_admin"`) {
|
||||
t.Fatalf("migration result not recorded: %s", rawResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyWebUpdateResolvesAdminEmailFromExistingMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "bootstrap@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopTestWorkers(a)
|
||||
ctx := context.Background()
|
||||
var adminID, passwordHash string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id,password_hash FROM users WHERE role='admin'`).Scan(&adminID, &passwordHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domainID, err := a.createDomainTx(ctx, nil, "example.test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHash(ctx, adminID, domainID, "admin", "admin@example.test", passwordHash, 1024, "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE address='bootstrap@lanqin.local'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name='admin', email='admin' WHERE id=?`, adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Old installations had only LANQIN_ADMIN_USERNAME. A webpage update starts
|
||||
// the new image directly, without running the interactive installer first.
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminEmail = ""
|
||||
cfg.MailDomain = ""
|
||||
updated, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = updated.Close() })
|
||||
var email, loginName string
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT email,login_name FROM users WHERE role='admin'`).Scan(&email, &loginName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "admin@example.test" || loginName != "admin@example.test" {
|
||||
t.Fatalf("legacy administrator resolved incorrectly: email=%q login=%q", email, loginName)
|
||||
}
|
||||
if updated.config().AdminEmail != "admin@example.test" {
|
||||
t.Fatalf("runtime administrator email was not synchronized: %q", updated.config().AdminEmail)
|
||||
}
|
||||
var wrongDomainCount int
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email LIKE '%@lanqin.local'`).Scan(&wrongDomainCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrongDomainCount != 0 {
|
||||
t.Fatalf("web update created a lanqin.local administrator: %d", wrongDomainCount)
|
||||
}
|
||||
var migrationResult string
|
||||
if err := updated.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='adminIdentityMigrationResult'`).Scan(&migrationResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(migrationResult, `"emailSource":"existing_admin_mailbox"`) {
|
||||
t.Fatalf("unexpected administrator email source: %s", migrationResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyPrimaryEmailCanLoginSecondaryMailboxCannot(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
primary := createTestMailbox(t, admin, domainID, "primary-login", "Primary Login", "Password123!", nil)
|
||||
secondary := createTestMailbox(t, admin, domainID, "secondary-login", "Secondary Login", "MailboxOnly123!", map[string]any{"ownerEmail": primary.Address})
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("primary email login code=%d", code)
|
||||
}
|
||||
secondaryLogin := &testClient{t: t, server: ts}
|
||||
if code := secondaryLogin.do("POST", "/api/auth/login", map[string]string{"email": secondary.Address, "password": "MailboxOnly123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("secondary mailbox should not login code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAPICannotCreateOrPromoteAdministrator(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
var errBody map[string]any
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "new-admin@lanqin.local",
|
||||
"displayName": "New Admin",
|
||||
"role": "admin",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("create admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
var user AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "regular@lanqin.local",
|
||||
"displayName": "Regular",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &user); code != http.StatusCreated {
|
||||
t.Fatalf("create user code=%d user=%+v", code, user)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+user.ID, map[string]any{
|
||||
"email": user.Email,
|
||||
"displayName": user.DisplayName,
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("promote admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsersListOrdersAdministratorThenAZPrimaryEmail(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
for _, user := range []struct {
|
||||
email string
|
||||
displayName string
|
||||
}{
|
||||
{"zeta@lanqin.local", "Zeta"},
|
||||
{"Alpha@lanqin.local", "Alpha"},
|
||||
{"bravo@lanqin.local", "Bravo"},
|
||||
} {
|
||||
var created AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": user.email,
|
||||
"displayName": user.displayName,
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
}, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create user %s code=%d user=%+v", user.email, code, created)
|
||||
}
|
||||
}
|
||||
|
||||
var users struct {
|
||||
Items []AdminUser `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
|
||||
t.Fatalf("list users code=%d users=%+v", code, users.Items)
|
||||
}
|
||||
if len(users.Items) < 4 {
|
||||
t.Fatalf("expected at least 4 users, got %+v", users.Items)
|
||||
}
|
||||
got := []string{users.Items[0].Email, users.Items[1].Email, users.Items[2].Email, users.Items[3].Email}
|
||||
want := []string{"admin@lanqin.local", "alpha@lanqin.local", "bravo@lanqin.local", "zeta@lanqin.local"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("users order=%v want prefix=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,7 +1936,7 @@ func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
limits.MaxMailboxCount = 1
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), limits)
|
||||
setRegularPermissionGroupForTest(t, a, regularUserDefaultPermissions(), limits)
|
||||
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "bob", "displayName": "Bob"}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox count limit code=%d body=%v", code, errBody)
|
||||
}
|
||||
@@ -1705,6 +2093,138 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMailboxBulkMoveToCustomFolderKeepsMailboxIsolation(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var domainList struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
|
||||
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
|
||||
}
|
||||
domainID := domainList.Items[0].ID
|
||||
primary := createTestMailbox(t, admin, domainID, "bulk-primary", "Bulk Primary", "Password123!", nil)
|
||||
secondary := createTestMailbox(t, admin, domainID, "bulk-secondary", "Bulk Secondary", "Password456!", map[string]any{"ownerEmail": primary.Address})
|
||||
otherUserMailbox := createTestMailbox(t, admin, domainID, "bulk-other", "Bulk Other", "Password789!", nil)
|
||||
if primary.UserID != secondary.UserID {
|
||||
t.Fatalf("primary and secondary should share owner: primary=%s secondary=%s", primary.UserID, secondary.UserID)
|
||||
}
|
||||
if primary.UserID == otherUserMailbox.UserID {
|
||||
t.Fatalf("other mailbox should belong to a different user")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
primaryInboxID, err := a.ensureFolder(ctx, primary.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondaryInboxID, err := a.ensureFolder(ctx, secondary.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInboxID, err := a.ensureFolder(ctx, otherUserMailbox.ID, "Inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
insertMessage := func(id, mailboxID, folderID, subject string) {
|
||||
t.Helper()
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, mailboxID, folderID, "", id+"-uid", "<"+id+"@example.test>", subject, "sender@example.test", "", jsonEncode([]string{"recipient@example.test"}), "[]", "[]", now, now, subject, "", "", 0, 0, 0, 0, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insertMessage("msg_bulk_primary_move", primary.ID, primaryInboxID, "bulk move primary")
|
||||
insertMessage("msg_bulk_secondary_move", secondary.ID, secondaryInboxID, "bulk move secondary")
|
||||
insertMessage("msg_bulk_other_stays", otherUserMailbox.ID, otherInboxID, "bulk move other")
|
||||
|
||||
userClient := &testClient{t: t, server: ts}
|
||||
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
|
||||
var allInbox struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := userClient.do("GET", "/api/mail/messages?mailboxId=all&folder=Inbox&q=bulk%20move", nil, &allInbox); code != http.StatusOK || len(allInbox.Items) != 2 {
|
||||
t.Fatalf("all inbox code=%d items=%+v", code, allInbox.Items)
|
||||
}
|
||||
messageIDs := make([]string, 0, len(allInbox.Items)+1)
|
||||
for _, item := range allInbox.Items {
|
||||
messageIDs = append(messageIDs, item.ID)
|
||||
}
|
||||
messageIDs = append(messageIDs, "msg_bulk_other_stays")
|
||||
var moved struct {
|
||||
OK bool `json:"ok"`
|
||||
Moved int `json:"moved"`
|
||||
Failed int `json:"failed"`
|
||||
Message string
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if code := userClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": messageIDs, "folder": "跨邮箱项目"}, &moved); code != http.StatusOK {
|
||||
t.Fatalf("bulk move code=%d body=%+v", code, moved)
|
||||
}
|
||||
if moved.OK || moved.Moved != 2 || moved.Failed != 1 || !strings.Contains(moved.Message, "已移动 2 封邮件,1 封失败") || len(moved.Items) != 3 {
|
||||
t.Fatalf("bulk move summary=%+v", moved)
|
||||
}
|
||||
|
||||
var primaryTargetID, secondaryTargetID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "跨邮箱项目").Scan(&primaryTargetID); err != nil {
|
||||
t.Fatalf("primary target folder: %v", err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "跨邮箱项目").Scan(&secondaryTargetID); err != nil {
|
||||
t.Fatalf("secondary target folder: %v", err)
|
||||
}
|
||||
var primaryFolderID, secondaryFolderID, otherFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_primary_move").Scan(&primaryFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_secondary_move").Scan(&secondaryFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_bulk_other_stays").Scan(&otherFolderID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if primaryFolderID != primaryTargetID || secondaryFolderID != secondaryTargetID {
|
||||
t.Fatalf("messages moved to wrong folders primary=%s want=%s secondary=%s want=%s", primaryFolderID, primaryTargetID, secondaryFolderID, secondaryTargetID)
|
||||
}
|
||||
if otherFolderID != otherInboxID {
|
||||
t.Fatalf("other user's message moved: folder=%s want=%s", otherFolderID, otherInboxID)
|
||||
}
|
||||
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": otherUserMailbox.Address, "password": "Password789!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("other login code=%d", code)
|
||||
}
|
||||
var forbidden struct {
|
||||
OK bool `json:"ok"`
|
||||
Moved int `json:"moved"`
|
||||
Failed int `json:"failed"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if code := otherClient.do("POST", "/api/mail/messages/bulk-move", map[string]any{"ids": []string{"msg_bulk_primary_move"}, "folder": "Inbox"}, &forbidden); code != http.StatusOK || forbidden.OK || forbidden.Moved != 0 || forbidden.Failed != 1 || len(forbidden.Items) != 1 || forbidden.Items[0].Message != "邮件不存在或无权访问" {
|
||||
t.Fatalf("other user bulk move primary message code=%d body=%+v", code, forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomMailFoldersCreateAndMove(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -3338,7 +3858,7 @@ func TestAdminSendAuditAccessAndFilters(t *testing.T) {
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("regular send audit code=%d", code)
|
||||
}
|
||||
updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
setRegularPermissionGroupForTest(t, a, []string{PermissionAdminOverview}, defaultPermissionLimits())
|
||||
if code := regular.do("GET", "/api/admin/send-audit", nil, nil); code != http.StatusForbidden {
|
||||
t.Fatalf("admin access without messages permission code=%d", code)
|
||||
}
|
||||
@@ -4168,11 +4688,15 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enabled struct {
|
||||
User User `json:"user"`
|
||||
User User `json:"user"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("enable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
if len(enabled.RecoveryCodes) != 8 {
|
||||
t.Fatalf("recovery codes=%+v", enabled.RecoveryCodes)
|
||||
}
|
||||
|
||||
fresh := &testClient{t: t, server: ts}
|
||||
var challenge struct {
|
||||
@@ -4185,14 +4709,28 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong challenge status=%d body=%v", status, out)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &login); status != http.StatusOK || fresh.cookie == nil {
|
||||
t.Fatalf("recovery login status=%d body=%v cookie=%v", status, login, fresh.cookie)
|
||||
}
|
||||
reused := &testClient{t: t, server: ts}
|
||||
if status := reused.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
|
||||
t.Fatalf("reused challenge status=%d challenge=%+v", status, challenge)
|
||||
}
|
||||
if status := reused.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": enabled.RecoveryCodes[0]}, &out); status != http.StatusUnauthorized {
|
||||
t.Fatalf("reused recovery status=%d body=%v", status, out)
|
||||
}
|
||||
totpClient := &testClient{t: t, server: ts}
|
||||
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" {
|
||||
t.Fatalf("totp challenge status=%d challenge=%+v", status, challenge)
|
||||
}
|
||||
code, err = generateTOTP(setup.Secret, a.now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil {
|
||||
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie)
|
||||
if status := totpClient.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || totpClient.cookie == nil {
|
||||
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, totpClient.cookie)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
|
||||
if status := totpClient.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("disable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
}
|
||||
@@ -4275,9 +4813,20 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
|
||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
|
||||
var regularUpdateErr map[string]any
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||
"name": "Changed Regular",
|
||||
"description": "Should not change",
|
||||
"permissions": []string{PermissionAdminOverview},
|
||||
}, ®ularUpdateErr); code != http.StatusForbidden {
|
||||
t.Fatalf("regular system permission group update should be forbidden code=%d body=%v", code, regularUpdateErr)
|
||||
}
|
||||
regularGroup, err := a.permissionGroupByID(context.Background(), PermissionGroupRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionMailAccess) || userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||
t.Fatalf("regular group should stay locked with default permissions=%+v", regularGroup)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||
@@ -4356,7 +4905,7 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
}, &plainUser); code != http.StatusCreated {
|
||||
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
|
||||
}
|
||||
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionMailAccess) || userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
|
||||
}
|
||||
|
||||
@@ -4562,7 +5111,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
|
||||
t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
|
||||
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess), defaultPermissionLimits())
|
||||
noAccess := &testClient{t: t, server: ts}
|
||||
if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no access login code=%d", code)
|
||||
@@ -4571,7 +5120,7 @@ func TestRegularUserMailPermissionsAreEnforced(t *testing.T) {
|
||||
t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
|
||||
setRegularPermissionGroupForTest(t, a, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend), defaultPermissionLimits())
|
||||
noSend := &testClient{t: t, server: ts}
|
||||
if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("no send login code=%d", code)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
||||
respondError(w, http.StatusUnauthorized, "验证码错误")
|
||||
return
|
||||
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
|
||||
if consumeErr != nil || !ok {
|
||||
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
@@ -50,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
emailInput := req.Email
|
||||
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
|
||||
emailInput = req.LoginName
|
||||
}
|
||||
email, err := cleanPrimaryEmail(emailInput)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||
if err != nil || user.Disabled {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
@@ -107,8 +109,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
email, err := cleanPrimaryEmail(req.Email)
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return
|
||||
}
|
||||
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.Split(email, "@")[0]
|
||||
badRequest(w, errors.New("请输入显示名称"))
|
||||
return
|
||||
}
|
||||
if len([]rune(displayName)) > 80 {
|
||||
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
|
||||
if mailboxLocalPart == "" {
|
||||
mailboxLocalPart = normalizeLocalPart(parts[0])
|
||||
}
|
||||
mailboxDomainID := strings.TrimSpace(req.DomainID)
|
||||
var mailboxDomain string
|
||||
if mailboxDomainID != "" {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
badRequest(w, errors.New("所选邮箱域名不可用"))
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
|
||||
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
|
||||
return
|
||||
}
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
if item == mailboxLocalPart {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
return
|
||||
@@ -138,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
@@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -156,38 +207,6 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a mailbox for the registered user
|
||||
var mailboxDomainID string
|
||||
var mailboxLocalPart string
|
||||
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
|
||||
// User selected a specific domain and local part
|
||||
mailboxDomainID = strings.TrimSpace(req.DomainID)
|
||||
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
|
||||
} else {
|
||||
// Auto-detect: use the first active domain and email local part
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
|
||||
mailboxDomainID = ""
|
||||
}
|
||||
if mailboxDomainID != "" {
|
||||
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
|
||||
}
|
||||
}
|
||||
if mailboxDomainID != "" && mailboxLocalPart != "" {
|
||||
// Check reserved prefixes
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[mailboxLocalPart] {
|
||||
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
|
||||
return
|
||||
}
|
||||
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
|
||||
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
@@ -205,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
MailDomain string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
@@ -72,7 +73,8 @@ func LoadConfig() Config {
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "")),
|
||||
MailDomain: normalizeDomain(getenv("LANQIN_MAIL_DOMAIN", "")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
|
||||
@@ -2156,6 +2156,74 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folder, err := normalizeFolderNameForUser(req.Folder)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(req.IDs))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range req.IDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
badRequest(w, errors.New("请选择要移动的邮件"))
|
||||
return
|
||||
}
|
||||
type itemResult struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
results := make([]itemResult, 0, len(ids))
|
||||
folderByMailbox := map[string]string{}
|
||||
moved := 0
|
||||
for _, id := range ids {
|
||||
msg, err := a.loadMessageForRequest(r, id, false)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
|
||||
continue
|
||||
}
|
||||
folderID := folderByMailbox[msg.MailboxID]
|
||||
if folderID == "" {
|
||||
folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
|
||||
continue
|
||||
}
|
||||
folderByMailbox[msg.MailboxID] = folderID
|
||||
}
|
||||
if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||
a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败,请稍后重试"})
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
|
||||
}
|
||||
failed := len(results) - moved
|
||||
message := fmt.Sprintf("已移动 %d 封邮件", moved)
|
||||
if failed > 0 {
|
||||
message = fmt.Sprintf("已移动 %d 封邮件,%d 封失败", moved, failed)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
|
||||
}
|
||||
|
||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
|
||||
@@ -809,7 +809,7 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own
|
||||
return "", errors.New("invalid owner email")
|
||||
}
|
||||
var existing string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
if intBool(existingSystem) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
|
||||
@@ -316,15 +316,15 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||
|
||||
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并设置主登录邮箱、显示名称和状态。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号主登录邮箱、显示名称、状态、邮箱数量上限和自定义权限配置。", Category: "账号管理"},
|
||||
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
||||
|
||||
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsView, Label: "查看权限配置", Description: "查看内置和自定义权限配置、权限目录和使用人数。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配置", Description: "创建自定义权限配置。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配置", Description: "修改自定义权限配置名称、说明、功能权限和额度。", Category: "权限配置"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配置", Description: "删除未被账号使用的自定义权限配置。", Category: "权限配置"},
|
||||
|
||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||
@@ -455,7 +455,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
{
|
||||
ID: PermissionGroupSuperAdmin,
|
||||
Name: "管理员",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过自定义权限配置分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
@@ -1031,14 +1031,7 @@ func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGr
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
return u != nil && u.Role == "admin"
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
|
||||
@@ -128,6 +128,7 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/bulk-move", a.handleBulkMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
@@ -344,10 +345,9 @@ func bearerToken(r *http.Request) string {
|
||||
}
|
||||
|
||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
loginName := normalizeLoginName(email)
|
||||
email = normalizeEmail(email)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
||||
FROM users WHERE login_name=? OR email=?
|
||||
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
|
||||
FROM users WHERE email=? LIMIT 1`, email)
|
||||
var u User
|
||||
var passwordHash string
|
||||
var disabled, twoFactorEnabled int
|
||||
|
||||
@@ -31,7 +31,11 @@ func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error
|
||||
if ip := normalizeRemoteIP(remoteIP); ip != "" {
|
||||
form.Set("remoteip", ip)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode()))
|
||||
verifyURL := strings.TrimSpace(a.turnstileURL)
|
||||
if verifyURL == "" {
|
||||
verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,25 @@ func newTOTPSecret() (string, error) {
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func newTwoFactorRecoveryCode() (string, error) {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
|
||||
if len(value) > 10 {
|
||||
value = value[:10]
|
||||
}
|
||||
return value[:5] + "-" + value[5:], nil
|
||||
}
|
||||
|
||||
func normalizeRecoveryCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
return code
|
||||
}
|
||||
|
||||
func totpProvisioningURI(issuer, account, secret string) string {
|
||||
issuer = strings.TrimSpace(issuer)
|
||||
account = strings.TrimSpace(account)
|
||||
@@ -121,6 +140,57 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id)
|
||||
}
|
||||
|
||||
func (a *App) generateTwoFactorRecoveryCodes(ctx context.Context, tx *sql.Tx, userID string) ([]string, error) {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
codes := make([]string, 0, 8)
|
||||
for len(codes) < 8 {
|
||||
code, err := newTwoFactorRecoveryCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO two_factor_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)`,
|
||||
newID("rcv"), userID, hashToken(normalized), now)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (a *App) consumeTwoFactorRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
|
||||
normalized := normalizeRecoveryCode(code)
|
||||
if len(normalized) < 8 {
|
||||
return false, nil
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM two_factor_recovery_codes WHERE user_id=? AND code_hash=? AND used_at=''`, userID, hashToken(normalized)).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE two_factor_recovery_codes SET used_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
@@ -212,7 +282,22 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
recoveryCodes, err := a.generateTwoFactorRecoveryCodes(r.Context(), tx, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate recovery codes")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication")
|
||||
return
|
||||
}
|
||||
@@ -221,7 +306,7 @@ func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": updated, "recoveryCodes": recoveryCodes})
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -250,7 +335,21 @@ func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM two_factor_recovery_codes WHERE user_id=?`, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,6 +205,21 @@ func cleanUsername(value string) (string, error) {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func cleanPrimaryEmail(value string) (string, error) {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", errors.New("邮箱地址无效")
|
||||
}
|
||||
if len([]rune(email)) > 254 {
|
||||
return "", errors.New("邮箱地址不能超过 254 个字符")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
|
||||
Reference in New Issue
Block a user