//go:build mysql // +build mysql package mysql import ( "context" "errors" "fmt" "strings" "github.com/jmoiron/sqlx" "github.com/tinode/chat/server/store" ) const userIdentityMigrationLock = "CONCAT('im_user_identity:', LEFT(SHA2(DATABASE(), 256), 40))" type identityValue struct { TenantID int64 `db:"tenant_id"` UserID int64 `db:"user_id"` Value string `db:"value"` } // ensureUserIdentitySchema moves user phone/email out of the legacy tag store. // Topic tags are intentionally unaffected. func ensureUserIdentitySchema(db *sqlx.DB) error { ctx := context.Background() conn, err := db.Connx(ctx) if err != nil { return err } defer conn.Close() // PXC strict-mode clusters prohibit GET_LOCK; acquireMigrationLock falls back // to lock-free execution in that case. The migration body is idempotent. _, unlock, err := acquireMigrationLock(ctx, conn, userIdentityMigrationLock) if err != nil { return err } if unlock != nil { defer unlock() } usersExist, err := identityTableExists(ctx, conn, "im_users") if err != nil || !usersExist { return err } usersScoped, err := identityColumnExists(ctx, conn, "im_users", "tenant_id") if err != nil { return err } profilesExist, err := identityTableExists(ctx, conn, "im_user_profiles") if err != nil { return err } if !profilesExist { profileScope := "" profileUniqueKey := "user_id" if usersScoped { profileScope = "tenant_id BIGINT NOT NULL," profileUniqueKey = "tenant_id,user_id" } if _, err = conn.ExecContext(ctx, `CREATE TABLE im_user_profiles( id BIGINT NOT NULL,`+profileScope+`user_id BIGINT NOT NULL, avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',nickname VARCHAR(64) NOT NULL DEFAULT '', birth_date DATE,signature VARCHAR(256) NOT NULL DEFAULT '',gender VARCHAR(16) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',email VARCHAR(128) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '', friend_add_enabled TINYINT NOT NULL DEFAULT 1, friend_add_disabled_reason VARCHAR(255) NOT NULL DEFAULT '', friend_add_policy_version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id),UNIQUE INDEX uk_im_user_profiles_user(`+profileUniqueKey+`))`); err != nil { return err } profilesExist = true } if exists, err := identityColumnExists(ctx, conn, "im_users", "phone"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent"); err != nil { return err } } if exists, err := identityColumnExists(ctx, conn, "im_users", "username"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN username VARCHAR(32) NULL AFTER id"); err != nil { return err } } if err = ensureUserAccountNoSchema(ctx, conn, usersScoped); err != nil { return err } if profilesExist { if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region"); err != nil { return err } } if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_enabled"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_enabled TINYINT NOT NULL DEFAULT 1 AFTER public_account"); err != nil { return err } } if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_disabled_reason"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_disabled_reason VARCHAR(255) NOT NULL DEFAULT '' AFTER friend_add_enabled"); err != nil { return err } } if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "friend_add_policy_version"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN friend_add_policy_version INT NOT NULL DEFAULT 1 AFTER friend_add_disabled_reason"); err != nil { return err } } } if err = backfillUserPhones(ctx, conn, usersScoped); err != nil { return err } if err = backfillUsernames(ctx, conn, usersScoped); err != nil { return err } if profilesExist { if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil { return err } } indexName := "users_phone" if usersScoped { indexName = "uk_im_users_tenant_phone" } if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil { return err } else if !exists { columns := "phone" if usersScoped { columns = "tenant_id,phone" } if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil { return fmt.Errorf("create unique phone index: %w", err) } } if usersScoped { if exists, err := identityIndexExists(ctx, conn, "im_users", "users_phone"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX users_phone"); err != nil { return err } } } usernameIndex := "users_username" usernameColumns := "username" if usersScoped { usernameIndex = "uk_im_users_tenant_username" usernameColumns = "tenant_id,username" } if exists, err := identityIndexExists(ctx, conn, "im_users", usernameIndex); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(usernameIndex)+" ON im_users("+usernameColumns+")"); err != nil { return fmt.Errorf("create unique username index: %w", err) } } if usersScoped { if exists, err := identityIndexExists(ctx, conn, "im_users", "users_username"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX users_username"); err != nil { return err } } } if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil { return err } if err = ensureUserAccountSequence(ctx, conn, usersScoped); err != nil { return err } if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil { return err } if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "DROP TABLE im_usertags"); err != nil { return err } } if exists, err := identityColumnExists(ctx, conn, "im_users", "tags"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP COLUMN tags"); err != nil { return err } } return nil } func ensureUserAccountSequence(ctx context.Context, conn *sqlx.Conn, scoped bool) error { exists, err := identityColumnExists(ctx, conn, "im_users", "account_no") if err != nil || !exists { return err } if scoped { if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences( tenant_id BIGINT NOT NULL,id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(tenant_id,id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil { return err } _, err = conn.ExecContext(ctx, `INSERT INTO im_user_account_sequences(tenant_id,id,next_value) SELECT tenant_id,1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE tenant_id>0 GROUP BY tenant_id ON DUPLICATE KEY UPDATE next_value=GREATEST(next_value,VALUES(next_value))`) return err } if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences( id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil { return err } _, err = conn.ExecContext(ctx, `INSERT INTO im_user_account_sequences(id,next_value) SELECT 1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users ON DUPLICATE KEY UPDATE next_value=GREATEST(next_value,VALUES(next_value))`) return err } func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error { prefix := "0 AS tenant_id,u.id AS user_id," join := "a.userid=u.id" orderBy := "u.id" if scoped { prefix = "u.tenant_id AS tenant_id,u.id AS user_id," join = "a.tenant_id=u.tenant_id AND a.userid=u.id" orderBy = "u.tenant_id,u.id" } authExists, err := identityTableExists(ctx, conn, "im_auth") if err != nil { return err } selectAuth := "NULL" authJoin := "" if authExists { hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname") if err != nil { return err } hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme") if err != nil { return err } if hasUname { selectAuth = "CASE WHEN a.uname LIKE 'basic:%' THEN SUBSTRING(a.uname,7) ELSE a.uname END" authJoin = " LEFT JOIN im_auth AS a ON " + join if hasScheme { authJoin += " AND a.scheme='basic'" } } } var rows []identityValue query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " WHERE u.username IS NULL OR u.username='' ORDER BY " + orderBy if err = conn.SelectContext(ctx, &rows, query); err != nil { return err } seen := make(map[string]bool, len(rows)) for _, row := range rows { value := strings.TrimSpace(row.Value) if strings.HasPrefix(value, "+") || (len(value) == 11 && asciiDigits(value)) { if canonical, normalizeErr := normalizeUserPhone(value); normalizeErr == nil { value = canonical } } if value == "" || len(value) > 32 || seen[identityUniqueKey(row.TenantID, value, scoped)] { value = fmt.Sprintf("reserved_%d", row.UserID) } seen[identityUniqueKey(row.TenantID, value, scoped)] = true update := "UPDATE im_users SET username=? WHERE id=?" args := []any{value, row.UserID} if scoped { update = "UPDATE im_users SET username=? WHERE tenant_id=? AND id=?" args = []any{value, row.TenantID, row.UserID} } if _, err = conn.ExecContext(ctx, update, args...); err != nil { return err } } return nil } func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error { exists, err := identityTableExists(ctx, conn, "im_auth") if err != nil || !exists { return err } if hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme"); err != nil { return err } else if hasScheme { var unsupported int if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM im_auth WHERE scheme<>'basic'").Scan(&unsupported); err != nil { return err } if unsupported > 0 { return fmt.Errorf("mysql: im_auth contains %d non-basic records", unsupported) } } legacyComposite, err := identityIndexExists(ctx, conn, "im_auth", "auth_userid_scheme") if err != nil { return err } if legacyComposite { // The legacy composite index may currently support the userid foreign key. // Give the FK a replacement before dropping that index. if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "CREATE INDEX auth_userid_fk ON im_auth(userid)"); err != nil { return err } } } for _, index := range []string{"auth_uname", "auth_userid_scheme"} { if exists, err = identityIndexExists(ctx, conn, "im_auth", index); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX "+quoteIdent(index)); err != nil { return err } } } for _, column := range []string{"uname", "scheme"} { if exists, err = identityColumnExists(ctx, conn, "im_auth", column); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP COLUMN "+quoteIdent(column)); err != nil { return err } } } indexName := "auth_userid" columns := "userid" if scoped { indexName = "auth_tenant_userid" columns = "tenant_id,userid" } if exists, err = identityIndexExists(ctx, conn, "im_auth", indexName); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_auth("+columns+")"); err != nil { return err } } if !scoped { if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid_fk"); err != nil { return err } } } if scoped { if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid"); err != nil { return err } } } return nil } func asciiDigits(value string) bool { if value == "" { return false } for i := range value { if value[i] < '0' || value[i] > '9' { return false } } return true } func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error { candidates, err := identityCandidates(ctx, conn, scoped, "tel") if err != nil { return err } seen := make(map[string]bool) var existing []identityValue prefix := "0 AS tenant_id," if scoped { prefix = "tenant_id," } orderBy := "id" if scoped { orderBy = "tenant_id,id" } if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>'' ORDER BY "+orderBy); err != nil { return err } for _, item := range existing { canonical, err := normalizeUserPhone(item.Value) if err != nil { return fmt.Errorf("normalize existing phone for user %d: %w", item.UserID, err) } if canonical != item.Value { query := "UPDATE im_users SET phone=? WHERE id=?" args := []any{canonical, item.UserID} if scoped { query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=?" args = []any{canonical, item.TenantID, item.UserID} } if _, err = conn.ExecContext(ctx, query, args...); err != nil { return err } } seen[identityUniqueKey(item.TenantID, canonical, scoped)] = true } updatedUsers := make(map[string]bool) for _, item := range candidates { item.Value, err = normalizeUserPhone(item.Value) if err != nil { return fmt.Errorf("normalize legacy phone for user %d: %w", item.UserID, err) } userKey := identityUserKey(item.TenantID, item.UserID, scoped) valueKey := identityUniqueKey(item.TenantID, item.Value, scoped) if item.Value == "" || updatedUsers[userKey] || seen[valueKey] { continue } query := "UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')" args := []any{item.Value, item.UserID} if scoped { query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')" args = []any{item.Value, item.TenantID, item.UserID} } result, err := conn.ExecContext(ctx, query, args...) if err != nil { return err } if affected, _ := result.RowsAffected(); affected > 0 { updatedUsers[userKey] = true seen[valueKey] = true } } return nil } func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error { candidates, err := identityCandidates(ctx, conn, usersScoped, "email") if err != nil { return err } profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id") if err != nil { return err } if profilesScoped != usersScoped { return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch") } processed := make(map[string]bool) for _, item := range candidates { item.Value = strings.TrimSpace(strings.TrimPrefix(item.Value, "email:")) key := identityUserKey(item.TenantID, item.UserID, usersScoped) if item.Value == "" || processed[key] { continue } processed[key] = true id, err := nextSnowflakeID() if err != nil { return err } account := store.EncodeUid(item.UserID).UserId() if usersScoped { _, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,tenant_id,user_id,email,public_account) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`, id, item.TenantID, item.UserID, item.Value, account) } else { _, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,user_id,email,public_account) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`, id, item.UserID, item.Value, account) } if err != nil { return err } } return nil } func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) { var out []identityValue appendQuery := func(query string, args ...any) error { var rows []identityValue if err := conn.SelectContext(ctx, &rows, query, args...); err != nil { return err } out = append(out, rows...) return nil } prefix := "0 AS tenant_id,u.id AS user_id," credentialJoin := "c.userid=u.id" tagJoin := "ut.userid=u.id" if scoped { prefix = "u.tenant_id AS tenant_id,u.id AS user_id," credentialJoin = "c.tenant_id=u.tenant_id AND c.userid=u.id" tagJoin = "ut.tenant_id=u.tenant_id AND ut.userid=u.id" } if exists, err := identityTableExists(ctx, conn, "im_credentials"); err != nil { return nil, err } else if exists { if err = appendQuery("SELECT "+prefix+"c.value AS value FROM im_users AS u JOIN im_credentials AS c ON "+credentialJoin+ " WHERE c.method=? AND c.done=1 AND c.deletedat IS NULL ORDER BY u.id,c.updatedat DESC", method); err != nil { return nil, err } } if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil { return nil, err } else if exists { if err = appendQuery("SELECT "+prefix+"ut.tag AS value FROM im_users AS u JOIN im_usertags AS ut ON "+tagJoin+ " WHERE ut.tag LIKE ? ORDER BY u.id,ut.id", method+":%"); err != nil { return nil, err } } if method == "tel" { if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil { return nil, err } else if exists { hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname") if err != nil { return nil, err } hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme") if err != nil { return nil, err } if !hasUname || !hasScheme { return out, nil } authJoin := "a.userid=u.id" if scoped { authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id" } if err = appendQuery("SELECT " + prefix + "a.uname AS value FROM im_users AS u JOIN im_auth AS a ON " + authJoin + " WHERE a.scheme='basic' AND (a.uname LIKE '+%' OR a.uname LIKE 'basic:+%') ORDER BY u.id"); err != nil { return nil, err } } } return out, nil } func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error { if exists, err := identityColumnExists(ctx, conn, "im_users", "account_no"); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN account_no INT UNSIGNED NOT NULL DEFAULT 0 AFTER id"); err != nil { return err } } var start int64 = 999 if err := conn.QueryRowContext(ctx, "SELECT GREATEST(COALESCE(MAX(account_no), 999), 999) FROM im_users WHERE account_no>0").Scan(&start); err != nil { return err } if _, err := conn.ExecContext(ctx, ` UPDATE im_users AS u JOIN ( SELECT ordered.id, (@im_user_account_no := @im_user_account_no + 1) AS next_no FROM (SELECT id FROM im_users WHERE account_no=0 ORDER BY createdat, id) AS ordered CROSS JOIN (SELECT @im_user_account_no := ?) AS vars ) AS seq ON seq.id=u.id SET u.account_no=seq.next_no WHERE u.account_no=0`, start); err != nil { return err } indexName := "uk_im_users_account_no" columns := "account_no" if scoped { indexName = "uk_im_users_tenant_account_no" columns = "tenant_id,account_no" } if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil { return err } else if !exists { if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil { return fmt.Errorf("create unique user account number index: %w", err) } } if scoped { if exists, err := identityIndexExists(ctx, conn, "im_users", "uk_im_users_account_no"); err != nil { return err } else if exists { if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX uk_im_users_account_no"); err != nil { return err } } } return nil } func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) { var exists int err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?)`, table).Scan(&exists) return exists == 1, err } func identityColumnExists(ctx context.Context, conn *sqlx.Conn, table, column string) (bool, error) { var exists int err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?)`, table, column).Scan(&exists) return exists == 1, err } func identityIndexExists(ctx context.Context, conn *sqlx.Conn, table, index string) (bool, error) { var exists int err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`, table, index).Scan(&exists) return exists == 1, err } func identityUserKey(tenantID, userID int64, scoped bool) string { if scoped { return fmt.Sprintf("%d:%d", tenantID, userID) } return fmt.Sprintf("%d", userID) } func identityUniqueKey(tenantID int64, value string, scoped bool) string { if scoped { return fmt.Sprintf("%d:%s", tenantID, value) } return value }