// Command createuser inserts a users row with an operator-chosen id, bypassing // the normal users_id_seq auto-assignment. This works because users.id is // GENERATED BY DEFAULT AS IDENTITY (not GENERATED ALWAYS) -- an explicit id in // the INSERT is honored, the same mechanism ensureOfficialSystemUserWithDB // (internal/store/postgres/message_send.go) already relies on to seed the // built-in system accounts (ChatBot, BotFather, ...) at their fixed ids. // // Normal signup (auth.signUp) never lets a caller pick an id, so this exists // purely for local/dev tooling -- reserving a specific low id (below // OfficialSystemUserID=777000, say) for a test account. // // Usage: // // createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -phone "15550001234" // createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -email "test@example.com" // // -phone and -email are mutually exclusive: an email-signup account never // stores the address in users.phone directly (see internal/domain/emailphone.go) // -- it gets a synthetic "888"-prefixed display phone instead (the same one // assignEmailSignupDisplayPhone hands a real email-signup account), with the // real address recorded separately in signup_email. // // Reads TELESRV_POSTGRES_DSN the same way the server does (internal/config). package main import ( "context" "crypto/rand" "encoding/binary" "errors" "flag" "fmt" "os" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "telesrv/internal/config" "telesrv/internal/domain" ) // maxEmailSignupPhoneAttempts bounds the display-phone collision-retry loop, // mirroring internal/app/auth/service.go's own constant of the same name. const maxEmailSignupPhoneAttempts = 20 func randomInt64() (int64, error) { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return 0, fmt.Errorf("rand: %w", err) } return int64(binary.LittleEndian.Uint64(b[:])), nil } func main() { id := flag.Int64("id", 0, "user id to create (required)") firstName := flag.String("first-name", "Test", "first name") lastName := flag.String("last-name", "", "last name") username := flag.String("username", "", "username, without @ (optional)") phone := flag.String("phone", "", "phone number (optional; mutually exclusive with -email)") email := flag.String("email", "", "email address for an email-signup account (optional; mutually exclusive with -phone)") force := flag.Bool("force", false, "skip the reserved-id / sequence-collision safety checks") flag.Parse() if *id <= 0 { fmt.Fprintln(os.Stderr, "createuser: -id is required and must be positive") os.Exit(2) } if *phone != "" && *email != "" { fmt.Fprintln(os.Stderr, "createuser: -phone and -email are mutually exclusive") os.Exit(2) } if !*force { if domain.IsSystemUserID(*id) { fmt.Fprintf(os.Stderr, "createuser: %d is a reserved built-in system account id (see internal/domain/system.go) - refusing, pass -force to override\n", *id) os.Exit(2) } if *id >= domain.UserIDSequenceBase { fmt.Fprintf(os.Stderr, "createuser: %d is >= UserIDSequenceBase (%d) - a future organic signup could eventually collide with it; pass -force to proceed anyway (then consider bumping users_id_seq yourself)\n", *id, domain.UserIDSequenceBase) os.Exit(2) } } cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "createuser: load config: %v\n", err) os.Exit(1) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() pool, err := pgxpool.New(ctx, cfg.PostgresDSN) if err != nil { fmt.Fprintf(os.Stderr, "createuser: connect: %v\n", err) os.Exit(1) } defer pool.Close() accessHash, err := randomInt64() if err != nil { fmt.Fprintf(os.Stderr, "createuser: %v\n", err) os.Exit(1) } displayPhone := *phone signupEmail := "" if *email != "" { signupEmail = domain.NormalizeEmailForPhone(*email) displayPhone, err = assignEmailSignupDisplayPhone(ctx, pool) if err != nil { fmt.Fprintf(os.Stderr, "createuser: %v\n", err) os.Exit(1) } } // phone/username/signup_email all sit under partial unique indexes that // exclude '', so leaving any of them blank never collides with another // blank-valued account. row := pool.QueryRow(ctx, ` INSERT INTO users (id, access_hash, phone, signup_email, first_name, last_name, username, country_code) VALUES ($1, $2, $3, $4, $5, $6, $7, '') ON CONFLICT (id) DO NOTHING RETURNING id`, *id, accessHash, displayPhone, signupEmail, *firstName, *lastName, *username) var createdID int64 if err := row.Scan(&createdID); err != nil { fmt.Fprintln(os.Stderr, describeInsertFailure(*id, *username, displayPhone, signupEmail, err)) os.Exit(1) } fmt.Printf("created user id=%d access_hash=%d first_name=%q last_name=%q username=%q phone=%q signup_email=%q\n", createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail) } // describeInsertFailure turns the INSERT's failure into a message naming the // actual thing that collided, instead of "id already exists" for every case: // ON CONFLICT (id) DO NOTHING only covers the id itself, so a duplicate // username/phone/signup_email surfaces here as a distinct unique-violation // error (pgx.ErrNoRows only means the id itself was the conflict). func describeInsertFailure(id int64, username, phone, signupEmail string, err error) string { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { switch pgErr.ConstraintName { case "users_username_lower_unique_idx": return fmt.Sprintf("createuser: username %q is already taken", username) case "users_phone_unique_idx": return fmt.Sprintf("createuser: phone %q is already in use", phone) case "users_signup_email_lower_unique_idx": return fmt.Sprintf("createuser: email %q is already in use by another account", signupEmail) default: return fmt.Sprintf("createuser: unique constraint %q violated: %v", pgErr.ConstraintName, err) } } if errors.Is(err, pgx.ErrNoRows) { return fmt.Sprintf("createuser: id %d already exists", id) } return fmt.Sprintf("createuser: insert failed: %v", err) } // assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method // of the same name: pick a random "888"-prefixed display phone and re-roll on // the astronomically unlikely collision with an existing account's phone. func assignEmailSignupDisplayPhone(ctx context.Context, pool *pgxpool.Pool) (string, error) { for range maxEmailSignupPhoneAttempts { candidate, err := domain.NewEmailSignupDisplayPhone(domain.EmailPhonePrefix) if err != nil { return "", err } var exists bool if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM users WHERE phone = $1)`, candidate).Scan(&exists); err != nil { return "", fmt.Errorf("check display phone collision: %w", err) } if !exists { return candidate, nil } } return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts) }