diff --git a/server/auth_audit_test.go b/server/auth_audit_test.go index 9f2b1ee..4ad4bb0 100644 --- a/server/auth_audit_test.go +++ b/server/auth_audit_test.go @@ -95,22 +95,23 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) { } func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) { - req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202", nil) + req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil) query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "") if err != nil { t.Fatal(err) } - for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone) LIKE ?", "u.id IN (?,?)"} { + for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR)) LIKE ?", "u.id IN (?,?)", "u.account_no = ?"} { if !strings.Contains(query, clause) || !strings.Contains(countQuery, clause) { t.Fatalf("user query missing %q: %s", clause, query) } } - want := []any{int64(101), int64(202), "%alice%"} + want := []any{int64(101), int64(202), int64(1000), "%alice%"} if !reflect.DeepEqual(args, want) { t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want) } for _, field := range []string{ "AS avatar", + "u.account_no AS display_no", "AS register_type", "im_registration_invite_uses", "im_fileuploads AS avatar", diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index adb12b3..89dc2f7 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -9,6 +9,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "hash/fnv" "sort" "strconv" @@ -762,19 +763,21 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_users( - id BIGINT NOT NULL, - username VARCHAR(32) NOT NULL, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - state SMALLINT NOT NULL DEFAULT 0, - stateat DATETIME(3), - access JSON, - lastseen DATETIME, - useragent VARCHAR(255) DEFAULT '', - phone VARCHAR(32), - public JSON, - trusted JSON, + id BIGINT NOT NULL, + account_no INT UNSIGNED NOT NULL DEFAULT 0, + username VARCHAR(32) NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + state SMALLINT NOT NULL DEFAULT 0, + stateat DATETIME(3), + access JSON, + lastseen DATETIME, + useragent VARCHAR(255) DEFAULT '', + phone VARCHAR(32), + public JSON, + trusted JSON, PRIMARY KEY(id), + UNIQUE INDEX uk_im_users_account_no(account_no), UNIQUE INDEX users_username(username), UNIQUE INDEX users_phone(phone), INDEX users_state_stateat(state, stateat), @@ -2230,8 +2233,18 @@ func (a *adapter) UserCreate(user *t.User) error { user.Phone = canonicalPhone phone = canonicalPhone } - if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)", + releaseAccountNoLock, err := lockUserAccountNoSequence(ctx, tx) + if err != nil { + return err + } + defer releaseAccountNoLock() + accountNo, err := nextUserAccountNo(ctx, tx) + if err != nil { + return err + } + if _, err = tx.Exec("INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)", decoded_uid, + accountNo, user.Username, user.CreatedAt, user.UpdatedAt, @@ -2260,6 +2273,34 @@ func (a *adapter) UserCreate(user *t.User) error { return tx.Commit() } +func lockUserAccountNoSequence(ctx context.Context, tx *sqlx.Tx) (func(), error) { + var locked sql.NullInt64 + if err := tx.QueryRowContext(ctx, "SELECT GET_LOCK(CONCAT(DATABASE(), ':im_users_account_no'), 10)").Scan(&locked); err != nil { + return nil, err + } + if !locked.Valid || locked.Int64 != 1 { + return nil, fmt.Errorf("lock user account number sequence timeout") + } + return func() { + _, _ = tx.ExecContext(ctx, "SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':im_users_account_no'))") + }, nil +} + +func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (int64, error) { + var current sql.NullInt64 + err := tx.QueryRowContext(ctx, "SELECT account_no FROM im_users ORDER BY account_no DESC LIMIT 1 FOR UPDATE").Scan(¤t) + if err == sql.ErrNoRows { + return 1000, nil + } + if err != nil { + return 0, err + } + if !current.Valid || current.Int64 < 999 { + return 1000, nil + } + return current.Int64 + 1, nil +} + // TenantAuthAddRecord adds the user's Basic authentication record. func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error { diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index c9abe36..0cf7461 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -289,8 +289,8 @@ func TestTenantAuthIsolation(t *testing.T) { userBID := userAID + 1 for index, userID := range []int64{userAID, userBID} { tenantDB := quoteIdent(dbNames[index]) - if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+ - "VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) "+ + "VALUES(?,?,?,?,?,?,?,?,?,?)", userID, 1000+index, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil { t.Fatal(err) } if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)", @@ -360,9 +360,9 @@ func TestTenantUserSearchMatchesUsernamePhoneAndPublicAccount(t *testing.T) { {targetID, "potter", "+8615500000002", `{"fn":"Potter"}`}, } { if _, err := db.Exec( - `INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) - VALUES(?,?,?,?,?,?,?,?,?)`, - user.id, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil { + `INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + user.id, user.id%1000000, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil { t.Fatal(err) } if _, err := db.Exec( diff --git a/server/db/mysql/user_identity_schema.go b/server/db/mysql/user_identity_schema.go index d36c8c8..77538a7 100644 --- a/server/db/mysql/user_identity_schema.go +++ b/server/db/mysql/user_identity_schema.go @@ -88,6 +88,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { return err } } + if err = ensureUserAccountNoSchema(ctx, conn); err != nil { + return err + } if profilesExist { if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil { return err @@ -568,6 +571,39 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho return out, nil } +func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn) 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 + } + 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, "CREATE UNIQUE INDEX uk_im_users_account_no ON im_users(account_no)"); err != nil { + return fmt.Errorf("create unique user account number index: %w", 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 diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index 61f3db1..b457219 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -269,7 +269,7 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) { func usersInternalQuerySpec() *internalQuerySpec { return &internalQuerySpec{ - Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted, + Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.account_no AS display_no,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted, p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id, COALESCE( NULLIF(avatar.location,''), @@ -288,7 +288,7 @@ func usersInternalQuerySpec() *internalQuerySpec { From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'", DefaultSort: "ORDER BY u.id DESC", DetailKey: "u.id", - Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone)", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}}, + Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}}, } } diff --git a/server/internal_api.go b/server/internal_api.go index ffe53e3..480a18d 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -229,11 +229,18 @@ func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantI writeInternalQueryError(wrt, req, err) return } + displayNo := int64(0) + if row, err := store.InternalAdminQueries.QueryOne(tenantID, "SELECT account_no FROM im_users WHERE id=?", []any{store.DecodeUid(created.Uid())}); err == nil { + displayNo, _ = numericID(row["account_no"]) + } else { + logs.Warn.Printf("internal user create account number query failed tenant:%d user:%d err:%v", tenantID, store.DecodeUid(created.Uid()), err) + } writeIMSuccess(wrt, req, map[string]any{ - "user_id": store.DecodeUid(created.Uid()), - "username": created.Username, - "nickname": body.Nickname, - "phone": created.Phone, + "user_id": store.DecodeUid(created.Uid()), + "display_no": displayNo, + "username": created.Username, + "nickname": body.Nickname, + "phone": created.Phone, }) }