new message on login
This commit is contained in:
parent
3d9b0f1de7
commit
cee960fea0
9 changed files with 492 additions and 90 deletions
|
|
@ -235,7 +235,14 @@ func MaskEmail(email string) string {
|
|||
|
||||
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
|
||||
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
|
||||
//
|
||||
// Email-signup 合成号码(EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外:
|
||||
// 原样保留(仅 lower+trim),不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而
|
||||
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。
|
||||
func NormalizePhone(phone string) string {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
return strings.ToLower(strings.TrimSpace(phone))
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
|
|
@ -249,14 +256,22 @@ func NormalizePhone(phone string) string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
// ValidPhone 校验 NormalizePhone 后的持久化形态:5-200 位纯数字。
|
||||
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
|
||||
// email-signup 合成号码额外允许小写字母(EncodeEmailPhone 的转义字符集)。
|
||||
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
|
||||
// 但拒绝空串、非数字和会截断的超长输入。上限从 32 放宽到 200 是为了容纳
|
||||
// EncodeEmailPhone 生成的 "888"+反向可解码大整数(真实手机号远用不到这个上限)。
|
||||
// 但拒绝空串、非法字符和会截断的超长输入。
|
||||
func ValidPhone(phone string) bool {
|
||||
if len(phone) < 5 || len(phone) > 200 {
|
||||
return false
|
||||
}
|
||||
if IsEmailSignupPhone(phone) {
|
||||
for _, r := range phone {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, r := range phone {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// EmailPhonePrefix marks a "phone number" as a synthetic identity encoding an
|
||||
// email address, not a real phone. It reuses Telegram's own +888 "Anonymous
|
||||
|
|
@ -17,24 +14,69 @@ const EmailPhonePrefix = "888"
|
|||
// 0087) and ValidPhone's upper bound.
|
||||
const MaxEmailSignupPhoneLen = 200
|
||||
|
||||
// emailPhoneEscape marks a 2-character escape sequence standing in for one
|
||||
// punctuation byte email addresses may contain but a "phone number" string
|
||||
// otherwise can't (see NormalizePhone, which only preserves letters/digits
|
||||
// for values recognized as email-signup phones). Every encoded value
|
||||
// contains at least one 'q' (from the mandatory '@' escape), which is what
|
||||
// lets IsEmailSignupPhone tell an encoded phone apart from a real,
|
||||
// all-digit, "888"-area-code phone number without any extra bookkeeping.
|
||||
const emailPhoneEscape = 'q'
|
||||
|
||||
var emailPhoneEscapeEncode = map[rune]byte{
|
||||
'@': '0',
|
||||
'.': '1',
|
||||
'-': '2',
|
||||
'_': '3',
|
||||
'+': '4',
|
||||
emailPhoneEscape: '5',
|
||||
}
|
||||
|
||||
var emailPhoneEscapeDecode = map[byte]rune{
|
||||
'0': '@',
|
||||
'1': '.',
|
||||
'2': '-',
|
||||
'3': '_',
|
||||
'4': '+',
|
||||
'5': emailPhoneEscape,
|
||||
}
|
||||
|
||||
// EncodeEmailPhone deterministically and reversibly encodes an email address
|
||||
// into a synthetic "888"-prefixed all-digit phone number: the email's
|
||||
// lowercased/trimmed UTF-8 bytes, read as a big-endian unsigned integer, then
|
||||
// printed in decimal. This lets the existing phone-based sendCode/signUp/
|
||||
// signIn/changePhone flow carry an email address end to end unchanged — no
|
||||
// new TL constructors, no server-side reverse-lookup table required.
|
||||
// into a synthetic "888"-prefixed phone-number-shaped string: letters and
|
||||
// digits pass through unchanged, and the handful of punctuation characters
|
||||
// real email addresses use are each replaced by a 2-character escape
|
||||
// ('q' + a digit). This keeps the encoded length close to the email's own
|
||||
// length (unlike a byte-for-byte big-integer encoding, which runs ~2.4x
|
||||
// longer), while staying fully reversible with no server-side lookup table
|
||||
// and no new TL constructors — the existing phone-based sendCode/signUp/
|
||||
// signIn/changePhone flow carries it end to end unchanged.
|
||||
//
|
||||
// ok is false if email is empty/invalid or the encoded result would not fit
|
||||
// the users.phone column (MaxEmailSignupPhoneLen) — this comfortably covers
|
||||
// realistic email addresses (roughly up to 80 bytes).
|
||||
// ok is false if email is empty/invalid, contains a character outside
|
||||
// [a-z0-9@._+-], or the encoded result would not fit the users.phone column
|
||||
// (MaxEmailSignupPhoneLen).
|
||||
func EncodeEmailPhone(email string) (phone string, ok bool) {
|
||||
normalized := NormalizeEmailForPhone(email)
|
||||
if normalized == "" || !strings.Contains(normalized, "@") {
|
||||
return "", false
|
||||
}
|
||||
n := new(big.Int).SetBytes([]byte(normalized))
|
||||
digits := n.String()
|
||||
phone = EmailPhonePrefix + digits
|
||||
var b strings.Builder
|
||||
b.Grow(len(normalized) * 2)
|
||||
for _, r := range normalized {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' && r != rune(emailPhoneEscape):
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
digit, escapable := emailPhoneEscapeEncode[r]
|
||||
if !escapable {
|
||||
return "", false
|
||||
}
|
||||
b.WriteByte(emailPhoneEscape)
|
||||
b.WriteByte(digit)
|
||||
}
|
||||
}
|
||||
phone = EmailPhonePrefix + b.String()
|
||||
if len(phone) > MaxEmailSignupPhoneLen {
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -44,20 +86,46 @@ func EncodeEmailPhone(email string) (phone string, ok bool) {
|
|||
// DecodeEmailPhone reverses EncodeEmailPhone. ok is false if phone does not
|
||||
// carry the "888" prefix or does not decode to a plausible email address.
|
||||
func DecodeEmailPhone(phone string) (email string, ok bool) {
|
||||
phone = NormalizePhone(strings.TrimSpace(phone))
|
||||
digits, found := strings.CutPrefix(phone, EmailPhonePrefix)
|
||||
if !found || digits == "" {
|
||||
lower := strings.ToLower(strings.TrimSpace(phone))
|
||||
body, found := strings.CutPrefix(lower, EmailPhonePrefix)
|
||||
if !found || body == "" {
|
||||
return "", false
|
||||
}
|
||||
n, valid := new(big.Int).SetString(digits, 10)
|
||||
if !valid {
|
||||
var b strings.Builder
|
||||
b.Grow(len(body))
|
||||
runes := []rune(body)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r != rune(emailPhoneEscape) {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(runes) {
|
||||
return "", false
|
||||
}
|
||||
digitByte, ok := asciiDigitByte(runes[i])
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
decoded, known := emailPhoneEscapeDecode[digitByte]
|
||||
if !known {
|
||||
return "", false
|
||||
}
|
||||
b.WriteRune(decoded)
|
||||
}
|
||||
email = b.String()
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return "", false
|
||||
}
|
||||
decoded := string(n.Bytes())
|
||||
if decoded == "" || !strings.Contains(decoded, "@") {
|
||||
return "", false
|
||||
return email, true
|
||||
}
|
||||
|
||||
func asciiDigitByte(r rune) (byte, bool) {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
return decoded, true
|
||||
return byte(r), true
|
||||
}
|
||||
|
||||
// NormalizeEmailForPhone lowercases and trims an email so the same address
|
||||
|
|
@ -67,8 +135,20 @@ func NormalizeEmailForPhone(email string) string {
|
|||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// IsEmailSignupPhone reports whether phone was produced by EncodeEmailPhone
|
||||
// (i.e. carries the synthetic "888" prefix), without decoding it.
|
||||
// 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
|
||||
// ones that happen to start with the 888 area code — never do; this keeps
|
||||
// the check unambiguous without any extra prefix bookkeeping.
|
||||
func IsEmailSignupPhone(phone string) bool {
|
||||
return strings.HasPrefix(NormalizePhone(strings.TrimSpace(phone)), EmailPhonePrefix)
|
||||
lower := strings.ToLower(strings.TrimSpace(phone))
|
||||
if !strings.HasPrefix(lower, EmailPhonePrefix) {
|
||||
return false
|
||||
}
|
||||
for _, r := range lower {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
40
internal/domain/welcome_message.go
Normal file
40
internal/domain/welcome_message.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from Settings → Devices immediately."
|
||||
|
||||
// OfficialWelcomeMessage builds the account-visible incoming message sent
|
||||
// from the official system account on every completed sign-in (SignUp and
|
||||
// every subsequent SignIn/SignInWithEmail), regardless of delivery channel.
|
||||
// Unlike OfficialLoginCodeMessage this never embeds a secret, so it is safe
|
||||
// to send unconditionally — it exists to give the account owner (and, on a
|
||||
// self-hosted single-admin server, that's usually also "the admin") a
|
||||
// visible record of every session start.
|
||||
func OfficialWelcomeMessage(userID int64, method string, date int) (Message, error) {
|
||||
method = strings.TrimSpace(method)
|
||||
if userID <= 0 || IsSystemUserID(userID) || method == "" || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d method=%q date=%d", ErrLoginCodeDeliveryInvalid, userID, method, date)
|
||||
}
|
||||
return Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
Date: date,
|
||||
Body: fmt.Sprintf(officialWelcomeMessageTemplate, method),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignInMethodLabel returns the human-readable method name embedded in
|
||||
// OfficialWelcomeMessage, derived from whether phone is an email-signup
|
||||
// synthetic number (see EncodeEmailPhone) or a real phone number.
|
||||
func SignInMethodLabel(phone string) string {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
return "email"
|
||||
}
|
||||
return "phone number"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue