From caa4d955e5c5dff3ac1365839854b6d3d4e6deb4 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 13 Sep 2026 17:59:39 +0100 Subject: [PATCH] tools: add createuser command for reserving a custom user id auth.signUp never lets a caller pick a user id (users_id_seq always assigns it), but users.id is GENERATED BY DEFAULT rather than ALWAYS, so an explicit id in the INSERT is honored - the same mechanism ensureOfficialSystemUserWithDB already relies on to seed the built-in system accounts at fixed ids. createuser -id N [-phone ...|-email ...] inserts a user row at that id for local/dev use, refusing (unless -force) a reserved system-account id or one at/above UserIDSequenceBase where a future organic signup could collide with it. -email reproduces the real email-signup path exactly: a synthetic 888-prefixed display phone (domain.NewEmailSignupDisplayPhone, re-rolled on collision) plus the real address in signup_email, rather than storing the address in users.phone directly. --- cmd/createuser/main.go | 153 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 cmd/createuser/main.go diff --git a/cmd/createuser/main.go b/cmd/createuser/main.go new file mode 100644 index 00000000..579a4e0c --- /dev/null +++ b/cmd/createuser/main.go @@ -0,0 +1,153 @@ +// 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" + "flag" + "fmt" + "os" + "time" + + "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.Fprintf(os.Stderr, "createuser: id %d already exists (or insert failed): %v\n", *id, 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) +} + +// 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) +}