diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 04adf28..fbf9813 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) { } func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { - rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,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), '') + 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.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at + 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`) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list users") @@ -64,7 +64,7 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created, mailboxCSV string - if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { + if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan users") return } @@ -154,8 +154,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)`, id, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil { + 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 { badRequest(w, err) return } @@ -598,7 +598,7 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerLoginName).Scan(&userID) + err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID) if errors.Is(err, sql.ErrNoRows) { passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { @@ -610,8 +610,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { if !strings.EqualFold(ownerLoginName, address) { ownerDisplayName = ownerLoginName } - _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now) + _, 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) if err != nil { badRequest(w, err) return @@ -1077,15 +1077,15 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) { } func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) { - row := a.db.QueryRowContext(ctx, `SELECT u.id,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), '') + row := a.db.QueryRowContext(ctx, `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 WHERE u.id=? - GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, 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`, id) var item AdminUser var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created, mailboxCSV string - if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { + if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil { return nil, err } item.Disabled = intBool(disabled) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index e2b5b23..3bb6dec 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -123,6 +123,7 @@ func (a *App) migrate(ctx context.Context) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, + login_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin','user')), @@ -601,6 +602,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.rebuildHTMLOnlyMessageSnippets(ctx); err != nil { return err } + if err := a.migrateUserLoginNames(ctx); err != nil { + return err + } if err := a.migrateUsersForTwoFactor(ctx); err != nil { return err } @@ -1054,6 +1058,99 @@ func (a *App) migrateUsersForTwoFactor(ctx context.Context) error { return nil } +func (a *App) migrateUserLoginNames(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`) + if err != nil { + return err + } + columns := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + columns[name] = true + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if !columns["login_name"] { + if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN login_name TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } + } + type loginUser struct { + id string + email string + loginName string + } + userRows, err := a.db.QueryContext(ctx, `SELECT id,email,login_name FROM users ORDER BY created_at,id`) + if err != nil { + return err + } + items := []loginUser{} + localCounts := map[string]int{} + used := map[string]bool{} + for userRows.Next() { + var item loginUser + if err := userRows.Scan(&item.id, &item.email, &item.loginName); err != nil { + userRows.Close() + return err + } + item.email = normalizeEmail(item.email) + item.loginName = normalizeLoginName(item.loginName) + if item.loginName != "" { + used[item.loginName] = true + } + if strings.Contains(item.email, "@") { + localCounts[strings.SplitN(item.email, "@", 2)[0]]++ + } + items = append(items, item) + } + if err := userRows.Err(); err != nil { + userRows.Close() + return err + } + if err := userRows.Close(); err != nil { + return err + } + now := a.now().UTC().Format(time.RFC3339Nano) + for _, item := range items { + if item.loginName != "" { + continue + } + candidate := item.email + if strings.Contains(item.email, "@") { + local := strings.SplitN(item.email, "@", 2)[0] + if localCounts[local] == 1 && !used[local] { + candidate = local + } + } + if candidate == "" { + candidate = normalizeLoginName(item.id) + } + base := candidate + for suffix := 2; used[candidate]; suffix++ { + candidate = fmt.Sprintf("%s-%d", base, suffix) + } + if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name=?, updated_at=? WHERE id=?`, candidate, now, item.id); err != nil { + return err + } + used[candidate] = true + } + _, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_name ON users(login_name) WHERE login_name <> ''`) + return err +} + func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`) if err != nil { @@ -1261,8 +1358,9 @@ func (a *App) seed(ctx context.Context) error { if adminEmail == "" || !strings.Contains(adminEmail, "@") { return errors.New("invalid admin email") } - if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { + 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 { return err } a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail) diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index e3e4829..fbb1a46 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -132,8 +132,8 @@ 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,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil { + if _, err := a.db.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, "该邮箱已被注册") return diff --git a/apps/api/internal/app/open_api_handlers.go b/apps/api/internal/app/open_api_handlers.go index 5afabc2..fc30ecb 100644 --- a/apps/api/internal/app/open_api_handlers.go +++ b/apps/api/internal/app/open_api_handlers.go @@ -826,7 +826,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 email=? AND disabled=0`, email).Scan(&existing) + err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing) if err == nil { return existing, nil } @@ -838,8 +838,8 @@ func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, own if displayName == "" { displayName = email } - _, err = tx.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", passwordHash, 0, now, now) + _, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now) return userID, err } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 632ca3e..da1e8bb 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -270,14 +270,14 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) { if err != nil || cookie.Value == "" { return nil, errors.New("no session") } - row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at + row := a.db.QueryRowContext(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 FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano)) var u User var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { + if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { return nil, err } u.Disabled = intBool(disabled) @@ -299,7 +299,7 @@ func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, err return nil, nil, errors.New("no api token") } now := a.now().UTC().Format(time.RFC3339Nano) - row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at + row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,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 FROM api_tokens at JOIN users u ON u.id=at.user_id WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now) var tokenID, scopesJSON string @@ -307,7 +307,7 @@ func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, err var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created string - if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { + if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { return nil, nil, err } u.Disabled = intBool(disabled) @@ -337,13 +337,16 @@ func bearerToken(r *http.Request) string { } func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE email=?`, email) + loginName := normalizeLoginName(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) var u User var passwordHash string var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { + if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, "", errNotFound } @@ -360,12 +363,12 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err } func (a *App) userByID(ctx context.Context, id string) (*User, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id) + row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id) var u User var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { + if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errNotFound } diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go index bd109b8..6ff8f2d 100644 --- a/apps/api/internal/app/two_factor.go +++ b/apps/api/internal/app/two_factor.go @@ -122,12 +122,12 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) { } func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id) + 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 var disabled, twoFactorEnabled int var mailboxLimitOverride sql.NullInt64 var secret, created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil { + if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, "", errNotFound }