ON CONFLICT (id) DO NOTHING only catches the id itself, so every other failure (duplicate username/phone/signup_email) was reported as a generic "already exists (or insert failed)" - not useful for telling apart the four distinct causes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
181 lines
6.9 KiB
Go
181 lines
6.9 KiB
Go
// 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)
|
|
}
|