internal/domain/account.go
Rama main · réplica de lectura del repositorio original.
package domain
import (
"errors"
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/nyaruka/phonenumbers"
)
const (
// MinLoginPhoneDigits is the shortest accepted login identity in the
// lenient format policy. It keeps short international numbers such as
// "+7 123" usable while staying above the empty and single-digit range.
MinLoginPhoneDigits = 4
// MaxLoginPhoneDigits bounds a login identity. It matches the users.phone
// column width and stays inside E.164's 15-digit ceiling for every
// number the numbering plan can canonicalize.
MaxLoginPhoneDigits = 32
)
// phoneFormatCheck selects the strict phone-number format policy. The default
// is the lenient policy; ConfigurePhoneFormatCheck installs the deployment's
// choice before any store or RPC service is constructed.
var phoneFormatCheck atomic.Bool
// ConfigurePhoneFormatCheck installs the deployment's phone-number format
// policy. Strict mode requires the number to sit in an assigned range of a
// real country calling code; the default lenient mode also accepts nonzero-leading
// digit identities outside the numbering plan, with or without a leading '+'.
func ConfigurePhoneFormatCheck(strict bool) {
phoneFormatCheck.Store(strict)
}
// PhoneFormatCheckEnabled reports whether the strict phone-number format
// policy is installed.
func PhoneFormatCheckEnabled() bool {
return phoneFormatCheck.Load()
}
var (
ErrPasswordHashInvalid = errors.New("password hash invalid")
ErrSRPIDInvalid = errors.New("srp id invalid")
ErrSRPPasswordChanged = errors.New("srp password changed")
ErrTmpPasswordInvalid = errors.New("tmp password invalid")
ErrTmpPasswordPeriod = errors.New("tmp password period invalid")
ErrPasswordMissing = errors.New("password missing")
ErrNewSettingsInvalid = errors.New("new password settings invalid")
ErrNewSaltInvalid = errors.New("new password salt invalid")
ErrPasswordRecoveryNA = errors.New("password recovery not available")
ErrRecoveryCodeEmpty = errors.New("recovery code empty")
ErrRecoveryCodeInvalid = errors.New("recovery code invalid")
ErrPasswordRecoveryExpired = errors.New("password recovery expired")
ErrEmailCodeInvalid = errors.New("email code invalid")
ErrEmailInvalid = errors.New("email invalid")
ErrEmailNotAllowed = errors.New("email not allowed")
ErrEmailOccupied = errors.New("email occupied")
ErrSessionPasswordNeeded = errors.New("session password needed")
ErrPhoneNumberInvalid = errors.New("phone number invalid")
ErrPhoneNumberOccupied = errors.New("phone number occupied")
ErrPhoneCodeEmpty = errors.New("phone code empty")
ErrPhoneCodeInvalid = errors.New("phone code invalid")
ErrPhoneCodeExpired = errors.New("phone code expired")
ErrPhoneChangeAuthInvalid = errors.New("phone change auth invalid")
ErrPhoneChangeForbidden = errors.New("phone change forbidden")
)
type AuthCodeDeliveryKind string
const (
AuthCodeDeliveryPhone AuthCodeDeliveryKind = "phone"
AuthCodeDeliverySMS AuthCodeDeliveryKind = "sms"
AuthCodeDeliveryEmail AuthCodeDeliveryKind = "email"
AuthCodeDeliveryEmailSetupRequired AuthCodeDeliveryKind = "email_setup_required"
)
type AuthCodeDelivery struct {
Kind AuthCodeDeliveryKind
EmailPattern string
Length int
}
// PasswordKDFAlgo is the business-layer description of the SRP KDF algorithm, independent of tg.*.
type PasswordKDFAlgo struct {
Salt1 []byte
Salt2 []byte
G int
P []byte
}
// SecurePasswordKDFAlgo is the KDF algorithm description for a Telegram Passport secure secret.
type SecurePasswordKDFAlgo struct {
Kind string
Salt []byte
}
// PasswordCheck is the business-layer representation of inputCheckPasswordEmpty/inputCheckPasswordSRP.
type PasswordCheck struct {
Empty bool
SRPID int64
A []byte
M1 []byte
}
// PasswordInputSettings is the business-layer representation of account.passwordInputSettings.
type PasswordInputSettings struct {
NewAlgo *PasswordKDFAlgo
NewPasswordHash []byte
Hint string
HasHint bool
Email string
HasEmail bool
}
// PrivatePasswordSettings is the business-layer representation of account.passwordSettings.
type PrivatePasswordSettings struct {
Email string
}
type PasswordResetKind string
const (
PasswordResetOK PasswordResetKind = "ok"
PasswordResetRequestedWait PasswordResetKind = "requested_wait"
PasswordResetFailedWait PasswordResetKind = "failed_wait"
)
type PasswordResetResult struct {
Kind PasswordResetKind
UntilDate int
RetryDate int
}
// PasswordSettings is the account's 2FA/SRP configuration. HasPassword defaults to false.
type PasswordSettings struct {
HasRecovery bool
HasSecureValues bool
HasPassword bool
CurrentAlgo *PasswordKDFAlgo
SRPB []byte
SRPID int64
Hint string
EmailUnconfirmedPattern string
RecoveryEmail string
// LoginEmail is the confirmed login email address (server-private, never sent directly; the masked
// LoginEmailPattern is sent instead). It is independent of the 2FA recovery email RecoveryEmail: an account may set only a login email without 2FA.
LoginEmail string
LoginEmailPattern string
NewAlgo PasswordKDFAlgo
NewSecureAlgo SecurePasswordKDFAlgo
SecureRandom []byte
PendingResetDate int
// Server-only SRP fields. They are persisted but never exposed to rpc/tg conversion.
SRPVerifier []byte
SRPBSecret []byte
}
// RevenueWithdrawalPasswordState carries only the durable 2FA facts required
// by high-risk payout admission. It intentionally excludes password material.
type RevenueWithdrawalPasswordState struct {
HasPassword bool
PasswordChangedAt time.Time
}
// account.getTmpPassword period bounds in seconds, as TDLib's
// createTemporaryPassword documents them.
const (
TmpPasswordMinPeriod = 60
TmpPasswordMaxPeriod = 86400
)
// TmpPasswordGrant is the server record of one account.getTmpPassword
// issuance. It keeps only the SHA-256 of the bearer secret. The grant is valid
// for its own authorization only while AuthorizationCreatedAt and
// PasswordChangedAt still match the live authorization and 2FA password.
type TmpPasswordGrant struct {
AuthKeyID [8]byte
UserID int64
AuthorizationCreatedAt time.Time
PasswordChangedAt time.Time
SecretHash [32]byte
ValidUntil time.Time
}
// TmpPassword is an issued bearer secret; it is returned to the client once.
type TmpPassword struct {
Secret []byte
ValidUntil time.Time
}
// ReactionNotifyFrom stores one account-level reaction notification scope.
type ReactionNotifyFrom string
const (
ReactionNotifyFromNone ReactionNotifyFrom = "none"
ReactionNotifyFromContacts ReactionNotifyFrom = "contacts"
ReactionNotifyFromAll ReactionNotifyFrom = "all"
)
// ReactionsNotifySettings stores the account reaction notification settings
// consumed by account.get/setReactionsNotifySettings.
type ReactionsNotifySettings struct {
MessagesFrom ReactionNotifyFrom
StoriesFrom ReactionNotifyFrom
PollVotesFrom ReactionNotifyFrom
ShowPreviews bool
}
// PaidReactionPrivacyKind stores the account default paid reaction privacy.
type PaidReactionPrivacyKind string
const (
PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default"
PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous"
PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer"
)
// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy.
type PaidReactionPrivacy struct {
Kind PaidReactionPrivacyKind
Peer *Peer
}
// AccountReactionSettings groups account-level reaction preferences.
type AccountReactionSettings struct {
Notify ReactionsNotifySettings
DefaultReaction MessageReaction
PaidPrivacy PaidReactionPrivacy
}
func DefaultAccountReactionSettings() AccountReactionSettings {
return AccountReactionSettings{
Notify: ReactionsNotifySettings{
MessagesFrom: ReactionNotifyFromContacts,
StoriesFrom: ReactionNotifyFromContacts,
PollVotesFrom: ReactionNotifyFromContacts,
ShowPreviews: true,
},
DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"},
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
}
}
// DefaultAccountTTLDays is the default account self-destruct TTL (when there is no explicit setting). It matches the historical fixed echo.
const (
DefaultAccountTTLDays = 365
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
MaxAccountTTLDays = 3650
)
// DisallowedGifts stores the Layer 228 global gift-reception switches.
type DisallowedGifts struct {
UnlimitedStargifts bool
LimitedStargifts bool
UniqueStargifts bool
PremiumGifts bool
StargiftsFromChannel bool
}
func (g DisallowedGifts) Zero() bool {
return !g.UnlimitedStargifts && !g.LimitedStargifts && !g.UniqueStargifts &&
!g.PremiumGifts && !g.StargiftsFromChannel
}
// GlobalPrivacy is the business-layer representation of globalPrivacySettings (account-level privacy switches).
type GlobalPrivacy struct {
ArchiveAndMuteNewNoncontactPeers bool
KeepArchivedUnmuted bool
KeepArchivedFolders bool
HideReadMarks bool
NewNoncontactPeersRequirePremium bool
DisplayGiftsButton bool
DisallowedGifts DisallowedGifts
// NoncontactPeersPaidStars: the number of Stars required for a non-contact to message this user. The Stars ledger is not yet implemented,
// so this is only faithfully persisted (no value lost on round-trip) and takes no part in billing logic.
NoncontactPeersPaidStars int64
}
// AccountSettings aggregates account-level singleton settings (one row per user): global privacy, account self-destruct TTL,
// sensitive-content switch, contact-signup notification mute. Corresponds to account.get/set GlobalPrivacySettings,
// get/set AccountTTL, get/set ContentSettings, get/set ContactSignUpNotification.
type AccountSettings struct {
GlobalPrivacy GlobalPrivacy
AccountTTLDays int
SensitiveContentEnabled bool
// ContactSignUpSilent corresponds to the silent parameter of account.setContactSignUpNotification:
// true = do not notify this user when a contact signs up. getContactSignUpNotification returns this value directly.
ContactSignUpSilent bool
}
// DefaultAccountSettings is the account settings default when nothing is persisted (matching the historical echo stub behavior:
// all global privacy off, TTL 365 days, sensitive content off, contact-signup notifications on).
func DefaultAccountSettings() AccountSettings {
return AccountSettings{
AccountTTLDays: DefaultAccountTTLDays,
}
}
// NormalizedTTLDays returns the clamped account self-destruct TTL (0/out-of-range falls back to the default).
func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
return DefaultAccountTTLDays
}
return s.AccountTTLDays
}
// MaskEmail masks an email address following the Telegram pattern convention (keeping one local-name
// character at each end, e.g. a***z@x.com), for send points that may only expose a mask such as
// account.password.login_email_pattern / auth.sentCodeTypeEmailCode. An empty string returns an empty string.
func MaskEmail(email string) string {
if email == "" {
return ""
}
at := strings.Index(email, "@")
if at <= 1 {
return email
}
name := email[:at]
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// PhoneDigits removes presentation punctuation from a phone number. It is
// intentionally not an identity canonicalizer: callers that select accounts,
// issue codes, or persist users must use NormalizePhone and ValidPhone.
func PhoneDigits(phone string) string {
var b strings.Builder
b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
seenDigit = true
case r == '+':
if seenPlus || seenDigit {
return ""
}
seenPlus = true
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
// Presentation separators accepted by official clients and contact UIs.
default:
return ""
}
}
return b.String()
}
// NormalizePhone returns the one persisted login identity. Virtual +888
// identities are independent of the collectible-phone registry and accept
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
// without the leading '+'. Their parsing is deliberately country-aware so a
// national trunk prefix is removed only where the numbering plan says it is a
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
//
// Two format policies exist:
//
// - The default (lenient) policy keeps IsPossibleNumber as the structural
// gate: a country calling code plus a length that matches that country's
// numbering plan is enough. A number the plan cannot describe is still
// accepted when it is short enough and carries no national trunk prefix,
// so ranges such as "+7 123" or "+1488" remain usable login identities.
// The digits are then stored verbatim.
// - The strict policy (ConfigurePhoneFormatCheck) additionally requires
// IsValidNumber, i.e. the number must sit in an assigned range of a real
// country calling code. Short and unassigned ranges are rejected.
//
// Neither policy accepts a national number that omits its country calling
// code: "09981679461" and "00989981679461" carry a leading trunk zero and
// are refused in both modes.
func NormalizePhone(phone string) string {
digits := PhoneDigits(phone)
if digits == "" {
return ""
}
// Every syntactically valid +888 virtual number is an independent login
// identity; minting or owning the same collectible-phone value is not a
// prerequisite. users.phone therefore takes lookup precedence over the
// optional collectible alias registry.
if len(digits) >= MinCollectiblePhoneLength &&
len(digits) <= MaxCollectiblePhoneLength &&
strings.HasPrefix(digits, "888") {
return digits
}
// 42777 is the reserved, non-login phone of the built-in service identity.
// It predates the ordinary E.164 user invariant and remains resolvable only
// so auth can reject it as a system account instead of treating it as free.
if digits == OfficialSystemPhone {
return digits
}
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
if err == nil {
if phoneFormatCheck.Load() {
if !phonenumbers.IsValidNumber(number) {
return ""
}
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > MaxLoginPhoneDigits {
return ""
}
return canonical
}
if phonenumbers.IsPossibleNumber(number) {
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > MaxLoginPhoneDigits {
return ""
}
return canonical
}
// The numbering plan cannot describe these digits. The lenient policy
// still keeps an explicitly international number usable.
} else if phoneFormatCheck.Load() {
return ""
}
// The lenient policy accepts a number the plan cannot describe. The digits
// are stored verbatim, so the identity stays exactly what the caller
// typed and stays canonical on the next normalization pass.
if phoneFormatCheck.Load() {
return ""
}
if len(digits) < MinLoginPhoneDigits || len(digits) > MaxLoginPhoneDigits {
return ""
}
// A leading zero marks the national/trunk form of a number whose country
// calling code was omitted ("07400123456", "00989981679461"). Such a
// value is ambiguous between a national number and a calling code, so it
// never becomes a login identity.
if strings.HasPrefix(digits, "0") {
return ""
}
// The +888 space has its own identity rule above: a number outside the
// 7-15 digit collectible range is not a login identity at all.
if strings.HasPrefix(digits, "888") {
return ""
}
return digits
}
// ValidPhone reports whether phone is already in the persisted canonical form.
// Callers accepting user input normalize first, then validate, so equivalent
// international spellings converge before lookup, rate limiting, OTP delivery,
// and uniqueness checks.
func ValidPhone(phone string) bool {
canonical := NormalizePhone(phone)
return canonical != "" && canonical == phone
}