From 1eaa06d9619ed78434cad1ec341d696865ced41c Mon Sep 17 00:00:00 2001 From: onysd Date: Tue, 14 Jul 2026 18:37:27 +0300 Subject: [PATCH] added config for virtual phone number --- .env.example | 9 +++ cmd/telesrv/main.go | 6 +- internal/app/account/phone_change.go | 30 +++++++++- internal/app/account/service.go | 12 ++++ internal/app/auth/service.go | 39 +++++++++++- internal/app/help/service.go | 42 +++++++++---- .../app/help/service_email_signup_test.go | 59 +++++++++++++++++++ internal/config/config.go | 31 ++++++++++ internal/domain/emailphone.go | 46 +++++++++++---- internal/domain/emailphone_test.go | 25 +++++++- 10 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 internal/app/help/service_email_signup_test.go diff --git a/.env.example b/.env.example index 680759e4..57bf4130 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,15 @@ TELESRV_SMTP_TIMEOUT=10s # delivery at all. TELESRV_EMAIL_SIGNUP_ENABLE=false +# Comma-separated list of phone-number prefixes randomly assigned as the +# account's actual, visible short display number (unrelated to the internal +# "888" wire encoding above, which is fixed and never shown to anyone). +# Defaults to "888" alone. Change this to make freshly registered accounts +# look like they have a locally-flavored number, e.g. "888,380,373" — no +# client update needed, since clients never construct this number themselves, +# they only ever display whatever the server assigns. +TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES=888 + # Client-visible telesrv links. This is an HTTP(S) URL, not a listen address. # Production uses https://telesrv.net. For local link/deeplink smoke tests use # http://127.0.0.1:2401. Invalid schemes, credentials, query strings, fragments, diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 002cd6b8..79c185c3 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -532,6 +532,7 @@ func run(logger *zap.Logger) error { account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), account.WithPublicBaseURL(cfg.PublicBaseURL), account.WithEmailSignup(cfg.EmailSignupEnable), + account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes), } var loginEmailSender mailpkg.Sender if cfg.LoginEmailEnable || cfg.EmailSignupEnable { @@ -707,7 +708,8 @@ func run(logger *zap.Logger) error { Store: accountService, Sender: loginEmailSender, }), - auth.WithEmailSignup(cfg.EmailSignupEnable)) + auth.WithEmailSignup(cfg.EmailSignupEnable), + auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes)) updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates"))) router := rpc.New(rpc.Config{ DC: cfg.DC, @@ -735,7 +737,7 @@ func run(logger *zap.Logger) error { Auth: authService, Account: accountService, Privacy: privacyService, - Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithEmailSignupEnable(cfg.EmailSignupEnable)), + Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithEmailSignupEnable(cfg.EmailSignupEnable), help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes)), AICompose: aiComposeService, Users: usersService, Updates: updatesService, diff --git a/internal/app/account/phone_change.go b/internal/app/account/phone_change.go index e2894bf8..36fb5822 100644 --- a/internal/app/account/phone_change.go +++ b/internal/app/account/phone_change.go @@ -3,6 +3,7 @@ package account import ( "context" "crypto/rand" + "encoding/binary" "encoding/hex" "fmt" "strings" @@ -242,14 +243,18 @@ func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID // collision-retry loop so a store failure can't spin forever. const maxEmailSignupPhoneAttempts = 20 -// assignEmailSignupDisplayPhone generates a short "888" display number for -// an email-signup account rebinding to a new email (see +// assignEmailSignupDisplayPhone generates a short display number for an +// email-signup account rebinding to a new email (see // domain.NewEmailSignupDisplayPhone / auth.Service's SignUp counterpart), // re-rolling on the astronomically unlikely collision with an existing // account's phone. func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) { + prefix, err := randomEmailSignupPhonePrefix(s.emailSignupPhonePrefixes) + if err != nil { + return "", err + } for i := 0; i < maxEmailSignupPhoneAttempts; i++ { - candidate, err := domain.NewEmailSignupDisplayPhone() + candidate, err := domain.NewEmailSignupDisplayPhone(prefix) if err != nil { return "", err } @@ -262,6 +267,25 @@ func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, er return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts) } +// randomEmailSignupPhonePrefix mirrors auth.Service's identical helper +// (unexported to each package, but must pick with the same fairness): pick +// one entry at random from prefixes, falling back to domain.EmailPhonePrefix +// ("888") when the list is empty. +func randomEmailSignupPhonePrefix(prefixes []string) (string, error) { + if len(prefixes) == 0 { + return domain.EmailPhonePrefix, nil + } + if len(prefixes) == 1 { + return prefixes[0], nil + } + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("pick email signup phone prefix: %w", err) + } + idx := binary.LittleEndian.Uint64(b[:]) % uint64(len(prefixes)) + return prefixes[idx], nil +} + func phoneChangeHash() (string, error) { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 0cd4dbd9..1d6d0eeb 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -55,6 +55,10 @@ type Service struct { // 验证码走 loginEmailSender 发到解码出的邮箱;非 888 号码一律拒绝——本服务器没有真实 // 短信通道,放行会让账号的邮箱身份绑定被绕过(见迁移前的密码找回验证码漏洞教训)。 emailSignupEnabled bool + // emailSignupPhonePrefixes 是改绑邮箱时随机挑选的账号展示号码号段前缀 + // 列表(domain.NewEmailSignupDisplayPhone),与 auth.Service 的同名字段 + // 同一份服务端配置来源。为空时退回默认 "888"。 + emailSignupPhonePrefixes []string } // ServiceOption 调整 account 服务依赖。 @@ -163,6 +167,14 @@ func WithEmailSignup(enabled bool) ServiceOption { } } +// WithEmailSignupPhonePrefixes 设置改绑邮箱时展示号码随机挑选的号段前缀列表 +// (见 emailSignupPhonePrefixes 字段注释)。 +func WithEmailSignupPhonePrefixes(prefixes []string) ServiceOption { + return func(s *Service) { + s.emailSignupPhonePrefixes = prefixes + } +} + // NewService 创建 account 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{ diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index e7c1f10f..69be8617 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -90,6 +90,11 @@ type Service struct { // 在 sendCode 阶段直接解码出邮箱、复用登录邮箱同一投递通道发码,与 // loginEmailEnabled(手机号账号的第二验证渠道)是两条独立开关。 emailSignupEnabled bool + // emailSignupPhonePrefixes 是 SignUp 时随机挑选的账号展示号码号段前缀 + // 列表(domain.NewEmailSignupDisplayPhone);与 emailSignupEnabled 本身 + // 用的合成 wire 号码前缀(固定 "888",见 domain.EncodeEmailPhone)无关。 + // 为空时退回默认 "888"。 + emailSignupPhonePrefixes []string // premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。 premiumGrantMonths int } @@ -194,6 +199,14 @@ func WithEmailSignup(enabled bool) Option { } } +// WithEmailSignupPhonePrefixes 设置 SignUp 展示号码随机挑选的号段前缀列表 +// (见 emailSignupPhonePrefixes 字段注释)。 +func WithEmailSignupPhonePrefixes(prefixes []string) Option { + return func(s *Service) { + s.emailSignupPhonePrefixes = prefixes + } +} + // NewService 创建登录服务。fixedCode 为开发固定验证码。 func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service { s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6} @@ -1510,8 +1523,12 @@ func normalizePhone(phone string) string { const maxEmailSignupPhoneAttempts = 20 func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) { + prefix, err := randomEmailSignupPhonePrefix(s.emailSignupPhonePrefixes) + if err != nil { + return "", err + } for i := 0; i < maxEmailSignupPhoneAttempts; i++ { - candidate, err := domain.NewEmailSignupDisplayPhone() + candidate, err := domain.NewEmailSignupDisplayPhone(prefix) if err != nil { return "", err } @@ -1524,6 +1541,26 @@ func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, er return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts) } +// randomEmailSignupPhonePrefix picks one entry at random from prefixes, +// falling back to domain.EmailPhonePrefix ("888") when the list is empty — +// config validation requires a non-empty list whenever email signup is +// enabled, but store-agnostic callers (tests, WithEmailSignupPhonePrefixes +// simply never called) shouldn't crash for lack of one. +func randomEmailSignupPhonePrefix(prefixes []string) (string, error) { + if len(prefixes) == 0 { + return domain.EmailPhonePrefix, nil + } + if len(prefixes) == 1 { + return prefixes[0], nil + } + n, err := randomInt64() + if err != nil { + return "", err + } + idx := uint64(n) % uint64(len(prefixes)) + return prefixes[idx], nil +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 58decaa8..c1419a44 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "hash/crc32" + "strings" "sync" "telesrv/internal/domain" @@ -64,10 +65,11 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增 // (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走 // internal/seed/catalog(go:embed 一次解析),本就在内存。 type Service struct { - appConfigs store.AppConfigStore - countries store.CountryStore - mapboxToken string - emailSignupEnable bool + appConfigs store.AppConfigStore + countries store.CountryStore + mapboxToken string + emailSignupEnable bool + emailSignupPhonePrefixes []string appConfigOnce sync.Once appConfigCache domain.AppConfig @@ -93,6 +95,16 @@ func WithEmailSignupEnable(enabled bool) Option { } } +// WithEmailSignupPhonePrefixes 下发 email_signup_phone_prefixes:账号展示号码 +// (domain.NewEmailSignupDisplayPhone)随机挑选的号段前缀列表,纯信息性——当前 +// 客户端并不需要读它就能工作(该号码全程由服务端生成/下发),暴露出来只是让 +// 管理员改动列表天然对所有已适配客户端可见,不需要客户端升级。 +func WithEmailSignupPhonePrefixes(prefixes []string) Option { + return func(s *Service) { + s.emailSignupPhonePrefixes = prefixes + } +} + // NewService 创建 help 服务。 func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service { s := &Service{appConfigs: appConfigs, countries: countries} @@ -104,15 +116,20 @@ func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, o return s } -func defaultAppConfig(mapboxToken string, emailSignupEnable bool) domain.AppConfig { - jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable) - return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken, emailSignupEnable), JSON: jsonBytes} +func defaultAppConfig(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) domain.AppConfig { + jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable, emailSignupPhonePrefixes) + return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken, emailSignupEnable, emailSignupPhonePrefixes), JSON: jsonBytes} } -func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool) []byte { +func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) []byte { base := tdesktopDefaultAppConfigBase if emailSignupEnable { base += `,"email_signup_enabled":true` + if len(emailSignupPhonePrefixes) > 0 { + if prefixesJSON, err := json.Marshal(emailSignupPhonePrefixes); err == nil { + base += `,"email_signup_phone_prefixes":` + string(prefixesJSON) + } + } } if mapboxToken == "" { return []byte(base + `}`) @@ -125,10 +142,13 @@ func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool) []byte { return []byte(base + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`) } -func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool) int { +func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) int { h := defaultAppConfigHash if emailSignupEnable { h += 1000003 // large odd offset so toggling the flag always changes the hash + if len(emailSignupPhonePrefixes) > 0 { + h += 1 + int(crc32.ChecksumIEEE([]byte(strings.Join(emailSignupPhonePrefixes, ",")))&0x3fffffff) + } } if mapboxToken == "" { return h @@ -144,9 +164,9 @@ func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig { if s == nil { - return defaultAppConfig("", false) + return defaultAppConfig("", false, nil) } - defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable) + defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable, s.emailSignupPhonePrefixes) s.appConfigOnce.Do(func() { if s.appConfigs == nil { s.appConfigCache = defaultCfg diff --git a/internal/app/help/service_email_signup_test.go b/internal/app/help/service_email_signup_test.go new file mode 100644 index 00000000..2fa30493 --- /dev/null +++ b/internal/app/help/service_email_signup_test.go @@ -0,0 +1,59 @@ +package help + +import ( + "context" + "encoding/json" + "testing" +) + +// TestAppConfigEmailSignupPhonePrefixes asserts email_signup_phone_prefixes +// is only present alongside email_signup_enabled=true, carries exactly the +// configured prefix list, and that changing the list bumps the hash so +// clients don't cache a stale list. +func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) { + ctx := context.Background() + + disabled := NewService(nil, nil) + cfg, _, err := disabled.GetAppConfig(ctx, 0) + if err != nil { + t.Fatalf("GetAppConfig (disabled): %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(cfg.JSON, &decoded); err != nil { + t.Fatalf("app config json invalid: %v", err) + } + if _, present := decoded["email_signup_phone_prefixes"]; present { + t.Fatalf("email_signup_phone_prefixes present while email signup disabled: %+v", decoded["email_signup_phone_prefixes"]) + } + + enabled := NewService(nil, nil, + WithEmailSignupEnable(true), + WithEmailSignupPhonePrefixes([]string{"888", "380", "373"})) + cfg2, _, err := enabled.GetAppConfig(ctx, 0) + if err != nil { + t.Fatalf("GetAppConfig (enabled): %v", err) + } + if err := json.Unmarshal(cfg2.JSON, &decoded); err != nil { + t.Fatalf("app config json invalid: %v", err) + } + rawPrefixes, ok := decoded["email_signup_phone_prefixes"].([]any) + if !ok || len(rawPrefixes) != 3 { + t.Fatalf("email_signup_phone_prefixes = %+v, want [888 380 373]", decoded["email_signup_phone_prefixes"]) + } + for i, want := range []string{"888", "380", "373"} { + if rawPrefixes[i] != want { + t.Fatalf("email_signup_phone_prefixes[%d] = %v, want %q", i, rawPrefixes[i], want) + } + } + + other := NewService(nil, nil, + WithEmailSignupEnable(true), + WithEmailSignupPhonePrefixes([]string{"888"})) + cfg3, _, err := other.GetAppConfig(ctx, 0) + if err != nil { + t.Fatalf("GetAppConfig (different prefixes): %v", err) + } + if cfg3.Hash == cfg2.Hash { + t.Fatalf("hash unchanged (%d) despite a different prefix list, clients would stay cached on the old list", cfg3.Hash) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8ce2d8a1..17c1f2cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,6 +124,14 @@ type Config struct { // 不变),验证码通过 SMTP 发到解码出的邮箱而非发短信。要求 SMTP 配置可用(与 // LoginEmailEnable 共用同一组 TELESRV_SMTP_* 变量)。 EmailSignupEnable bool + // EmailSignupPhonePrefixes 是账号实际可见的 users.phone 短号码 + // (domain.NewEmailSignupDisplayPhone)随机选用的号段前缀列表,逗号分隔, + // 默认仅 "888"。注意这与合成 wire 号码(domain.EncodeEmailPhone,sendCode + // 阶段用于携带邮箱本身)无关——那个前缀恒为 "888" 且从不下发给客户端; + // 这里只影响注册后账号真正落库/展示的号码好不好看,可通过 + // help.getAppConfig 的 email_signup_phone_prefixes 下发给客户端,管理员 + // 改动此列表不需要客户端升级。 + EmailSignupPhonePrefixes []string // SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 时必须可用。 SMTPHost string SMTPPort int @@ -459,6 +467,7 @@ func Load() (Config, error) { LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false), LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false), EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false), + EmailSignupPhonePrefixes: envListOr("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", []string{"888"}), LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6), SMTPHost: envOr("TELESRV_SMTP_HOST", ""), SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587), @@ -588,6 +597,16 @@ func validateLoginEmailConfig(cfg Config) error { default: return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none") } + if cfg.EmailSignupEnable { + if len(cfg.EmailSignupPhonePrefixes) == 0 { + return fmt.Errorf("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES must not be empty when TELESRV_EMAIL_SIGNUP_ENABLE=true") + } + for _, prefix := range cfg.EmailSignupPhonePrefixes { + if !isDigitsOnly(prefix) || len(prefix) < 1 || len(prefix) > 4 { + return fmt.Errorf("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES entry %q must be 1-4 digits", prefix) + } + } + } if !cfg.LoginEmailEnable && !cfg.EmailSignupEnable { return nil } @@ -799,6 +818,18 @@ func (e envSource) envAllowEmptyOr(key, def string) string { return def } +func isDigitsOnly(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + func (e envSource) envListOr(key string, def []string) []string { v := e.envOr(key, "") if v == "" { diff --git a/internal/domain/emailphone.go b/internal/domain/emailphone.go index a17a51d0..0b6373a3 100644 --- a/internal/domain/emailphone.go +++ b/internal/domain/emailphone.go @@ -139,33 +139,53 @@ func NormalizeEmailForPhone(email string) string { return strings.ToLower(strings.TrimSpace(email)) } -// emailSignupDisplayPhoneDigits is how many random digits follow the "888" -// prefix in a NewEmailSignupDisplayPhone result, e.g. "888" + 8 digits = +// emailSignupDisplayPhoneDigits is how many random digits follow the prefix +// in a NewEmailSignupDisplayPhone result, e.g. prefix "888" + 8 digits = // "88812345678" (formats on-screen as something like "+888 1234 5678"). const emailSignupDisplayPhoneDigits = 8 -// NewEmailSignupDisplayPhone generates a short, all-digit "888" phone number -// for an email-signup account's users.phone column. Unlike EncodeEmailPhone -// this carries no information about the email — it is purely a -// normal-looking display/identity number — so the caller must separately -// persist the email->user association (see User.SignupEmail) for returning -// logins to be found. Because the result is all digits, IsEmailSignupPhone -// on it is always false: once assigned, it behaves exactly like a real phone -// number everywhere else in the system (contacts, search, ByPhone lookups). -func NewEmailSignupDisplayPhone() (string, error) { +// NewEmailSignupDisplayPhone generates a short, all-digit phone number for an +// email-signup account's users.phone column, using the given prefix (one of +// the server's configured EmailSignupPhonePrefixes — see +// help.getAppConfig's email_signup_phone_prefixes, config.go). This is +// unrelated to EncodeEmailPhone's own fixed "888" wire prefix: that one only +// ever travels internally between client and server during sendCode/signIn +// and is never shown to anyone, so it has no reason to be configurable, +// unlike this display number, which is the account's actual, permanent, +// user-visible phone. The caller must separately persist the email->user +// association (see User.SignupEmail) for returning logins to be found. +// Because the result is all digits, IsEmailSignupPhone on it is always +// false: once assigned, it behaves exactly like a real phone number +// everywhere else in the system (contacts, search, ByPhone lookups). +func NewEmailSignupDisplayPhone(prefix string) (string, error) { + if !isAllASCIIDigits(prefix) { + return "", fmt.Errorf("email signup display phone prefix %q must be non-empty digits", prefix) + } b := make([]byte, emailSignupDisplayPhoneDigits) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate email signup display phone: %w", err) } var out strings.Builder - out.Grow(len(EmailPhonePrefix) + emailSignupDisplayPhoneDigits) - out.WriteString(EmailPhonePrefix) + out.Grow(len(prefix) + emailSignupDisplayPhoneDigits) + out.WriteString(prefix) for _, v := range b { out.WriteByte('0' + v%10) } return out.String(), nil } +func isAllASCIIDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + // IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone. // Every encoded value contains at least one letter (the mandatory '@' // escape's 'q' marker byte), which real, all-digit phone numbers — even diff --git a/internal/domain/emailphone_test.go b/internal/domain/emailphone_test.go index 8a8560b1..e6d4ceac 100644 --- a/internal/domain/emailphone_test.go +++ b/internal/domain/emailphone_test.go @@ -69,7 +69,7 @@ func TestDecodeEmailPhoneRejectsNonEmailNumbers(t *testing.T) { func TestNewEmailSignupDisplayPhoneLooksLikeARealPhoneNumber(t *testing.T) { seen := make(map[string]struct{}) for i := 0; i < 200; i++ { - phone, err := NewEmailSignupDisplayPhone() + phone, err := NewEmailSignupDisplayPhone(EmailPhonePrefix) if err != nil { t.Fatalf("NewEmailSignupDisplayPhone: %v", err) } @@ -94,3 +94,26 @@ func TestNewEmailSignupDisplayPhoneLooksLikeARealPhoneNumber(t *testing.T) { t.Fatalf("only %d distinct values out of 200 draws, generator looks non-random", len(seen)) } } + +func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) { + for _, prefix := range []string{"380", "1", "7777"} { + phone, err := NewEmailSignupDisplayPhone(prefix) + if err != nil { + t.Fatalf("NewEmailSignupDisplayPhone(%q): %v", prefix, err) + } + if !strings.HasPrefix(phone, prefix) { + t.Fatalf("phone %q missing configured prefix %q", phone, prefix) + } + if !ValidPhone(phone) { + t.Fatalf("phone %q fails ValidPhone", phone) + } + } +} + +func TestNewEmailSignupDisplayPhoneRejectsInvalidPrefix(t *testing.T) { + for _, prefix := range []string{"", "abc", "88q", "-1"} { + if _, err := NewEmailSignupDisplayPhone(prefix); err == nil { + t.Fatalf("NewEmailSignupDisplayPhone(%q) err = nil, want error", prefix) + } + } +}