merged with fixes
This commit is contained in:
parent
a9e758b712
commit
2f1818d656
176 changed files with 9000 additions and 907 deletions
|
|
@ -4,6 +4,9 @@ import (
|
|||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
|
|||
return name[:1] + "***" + name[len(name)-1:] + email[at:]
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
|
||||
// virtual login identity range NormalizePhone accepts without going through
|
||||
// libphonenumber (real E.164 numbers never start with 888). This is a login
|
||||
// identity concept only -- distinct from, and independent of, ownership of any
|
||||
// purchasable collectible-phone asset with the same digit shape.
|
||||
const (
|
||||
virtualLoginPhoneMinDigits = 7
|
||||
virtualLoginPhoneMaxDigits = 15
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if r >= '0' && r <= '9' {
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
|
||||
// email-signup 合成号码额外允许小写字母(EncodeEmailPhone 的转义字符集)。
|
||||
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
|
||||
// 但拒绝空串、非法字符和会截断的超长输入。
|
||||
func ValidPhone(phone string) bool {
|
||||
if len(phone) < 5 || len(phone) > 200 {
|
||||
return false
|
||||
}
|
||||
// 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.
|
||||
//
|
||||
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
|
||||
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
|
||||
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
|
||||
// an already letter-stripped value. A real phone is always pure digits, so
|
||||
// this check never misclassifies one.
|
||||
//
|
||||
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
|
||||
// syntactically possible reserved/test ranges usable without accepting local
|
||||
// numbers that omit their country calling code or numbers outside E.164's
|
||||
// length/plan metadata.
|
||||
func NormalizePhone(phone string) string {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
return strings.ToLower(strings.TrimSpace(phone))
|
||||
}
|
||||
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 any
|
||||
// optional collectible alias registry.
|
||||
if len(digits) >= virtualLoginPhoneMinDigits &&
|
||||
len(digits) <= virtualLoginPhoneMaxDigits &&
|
||||
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 || !phonenumbers.IsPossibleNumber(number) {
|
||||
return ""
|
||||
}
|
||||
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
|
||||
if canonical == "" || len(canonical) > 15 {
|
||||
return ""
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
// 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. Email-signup synthetic numbers keep their own
|
||||
// lower+trim canonical form (see NormalizePhone).
|
||||
func ValidPhone(phone string) bool {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
if len(phone) < 5 || len(phone) > 200 {
|
||||
return false
|
||||
}
|
||||
for _, r := range phone {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
|
||||
return false
|
||||
|
|
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
for _, r := range phone {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
canonical := NormalizePhone(phone)
|
||||
return canonical != "" && canonical == phone
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,4 +43,7 @@ type AuthKeyClientInfo struct {
|
|||
SystemVersion string
|
||||
APIID int
|
||||
AppVersion string
|
||||
// IP 是最近一次会话建立的客户端对端地址(host-only)。只做 metadata 级别的
|
||||
// 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。
|
||||
IP string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,13 @@ func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) {
|
|||
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)
|
||||
// A display phone is cosmetic only (see assignEmailSignupDisplayPhone --
|
||||
// it never goes through ValidPhone in production, only a uniqueness
|
||||
// check): random digits after a real prefix essentially never form a
|
||||
// libphonenumber-possible number, so the invariant worth checking here
|
||||
// is "still a plain digit string", not full E.164 validity.
|
||||
if PhoneDigits(phone) != phone {
|
||||
t.Fatalf("phone %q is not a plain digit string", phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
|
|||
|
||||
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
|
||||
if got != "Hello from OwpenGram!" {
|
||||
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
|
||||
t.Fatalf("expected default branding.ProductName() substitution, got %q", got)
|
||||
}
|
||||
|
||||
SetOfficialSystemUserDisplayName("Custom Server")
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
|
|||
|
||||
// MediaSearchRequest 是共享媒体标签页分页查询的入参(messages.search 媒体过滤分支)。
|
||||
// Categories 是该标签页映射到的基础类别并集(PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
|
||||
// 分页对齐历史语义:OffsetID 为游标(返回 id 严格小于它)、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
|
||||
// OffsetID 定位第一条严格更旧的消息;负 AddOffset 向更新侧取数(可含游标本身)。
|
||||
// MaxID/MinID、MaxDate/MinDate 均为开区间;计数忽略分页偏移。
|
||||
type MediaSearchRequest struct {
|
||||
Categories []MediaCategory
|
||||
Query string
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const (
|
|||
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
|
||||
MaxMessageReplyQuoteLength = 1024
|
||||
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
|
||||
MaxMessageReplyQuoteOffset = MaxMessageTextLength
|
||||
MaxMessageReplyQuoteOffset = 2 * MaxMessageTextLength // UTF-16 units, including surrogate pairs.
|
||||
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
|
||||
MaxMessageEntityCount = 256
|
||||
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
|
||||
|
|
@ -81,7 +81,7 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
|
|||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
// story 回复(StoryID>0)不携带 MessageID/TopMessageID;普通回复至少有其一。
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 && reply.External == nil {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
|
||||
|
|
@ -250,6 +250,9 @@ type MessageReply struct {
|
|||
QuoteText string
|
||||
QuoteEntities []MessageEntity
|
||||
QuoteOffset int
|
||||
// External is an immutable source snapshot resolved by the owning store.
|
||||
// It is not client input and does not authorize a source message lookup.
|
||||
External *MessageReplyExternal `json:",omitempty"`
|
||||
// StoryID > 0 表示这是一条对 story 的回复(评论):MessageID 为 0,Peer 为 story 作者,
|
||||
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
|
||||
StoryID int
|
||||
|
|
@ -287,6 +290,11 @@ type MessageFilter struct {
|
|||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
// SenderUserID intersects all other predicates; zero means no sender filter.
|
||||
SenderUserID int64
|
||||
// CountOnly ignores pagination and returns the exact filtered total without
|
||||
// loading message payloads or users. History's default limit is separate.
|
||||
CountOnly bool
|
||||
// PinnedOnly 仅返回置顶消息(messages.search filterPinned 与
|
||||
// userFull.pinned_msg_id 的查询路径)。
|
||||
PinnedOnly bool
|
||||
|
|
@ -716,11 +724,13 @@ type PinPrivateMessageRequest struct {
|
|||
Pinned bool
|
||||
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
|
||||
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
|
||||
PmOneside bool
|
||||
Silent bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
PmOneside bool
|
||||
Silent bool
|
||||
// RecipientBlocked applies the normal private-service delivery policy.
|
||||
RecipientBlocked bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var (
|
|||
// undisclosed delivery and make a retry impossible to reconcile.
|
||||
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrQuoteTextInvalid = errors.New("quote text invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
|
||||
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶
|
||||
|
|
|
|||
193
internal/domain/message_reply_external.go
Normal file
193
internal/domain/message_reply_external.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxMessageReplyExternalBytes = 1 << 20
|
||||
|
||||
// MessageReplyExternal retains the source at send time, independently of its
|
||||
// later edit/deletion. It never contains another reply or owner-local jump IDs.
|
||||
type MessageReplyExternal struct {
|
||||
From MessageForward `json:"from"`
|
||||
Text string `json:"text"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
Media *MessageMedia `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
// Apply the same validation when this value is nested in an immutable send
|
||||
// receipt. Otherwise json.Unmarshal there would discard unknown fields or
|
||||
// accept an invalid author even though the message-box decoder rejects it.
|
||||
func (v *MessageReplyExternal) UnmarshalJSON(b []byte) error {
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
type snapshot MessageReplyExternal
|
||||
var out snapshot
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&out); err != nil {
|
||||
return fmt.Errorf("decode external reply: %w", err)
|
||||
}
|
||||
if err := d.Decode(new(any)); err != io.EOF {
|
||||
return fmt.Errorf("external reply trailing JSON")
|
||||
}
|
||||
if _, err := EncodeMessageReplyExternal((*MessageReplyExternal)(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = MessageReplyExternal(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EncodeMessageReplyExternal(v *MessageReplyExternal) ([]byte, error) {
|
||||
if v == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
if v.From.Date <= 0 || v.From.From.ID <= 0 || (v.From.From.Type != PeerTypeUser && v.From.From.Type != PeerTypeChannel) || v.From.SavedFrom.ID != 0 || v.From.SavedFromMsgID != 0 || len(v.Entities) > MaxMessageEntityCount || !utf8.ValidString(v.Text) || utf8.RuneCountInString(v.Text) > MaxMessageTextLength {
|
||||
return nil, fmt.Errorf("invalid external reply snapshot")
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode external reply: %w", err)
|
||||
}
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func DecodeMessageReplyExternal(b []byte) (*MessageReplyExternal, error) {
|
||||
if len(b) == 0 || bytes.Equal(bytes.TrimSpace(b), []byte("{}")) {
|
||||
return nil, nil
|
||||
}
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
var out MessageReplyExternal
|
||||
if err := out.UnmarshalJSON(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func NewMessageReplyExternal(source Message) (*MessageReplyExternal, error) {
|
||||
v := &MessageReplyExternal{From: MessageForward{From: source.From, Date: source.Date}, Text: source.Body, Entities: source.Entities, Media: source.Media}
|
||||
b, err := EncodeMessageReplyExternal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A codec round trip detaches the source media, including nested slices.
|
||||
return DecodeMessageReplyExternal(b)
|
||||
}
|
||||
|
||||
func ValidateExternalReplyQuote(reply *MessageReply, text string) error {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
if len(reply.QuoteText) > MaxMessageReplyQuoteLength || !utf8.ValidString(reply.QuoteText) || len(reply.QuoteEntities) > MaxMessageEntityCount {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
if reply.QuoteText == "" {
|
||||
if reply.QuoteOffset != 0 || len(reply.QuoteEntities) != 0 {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
source, quote := utf16.Encode([]rune(text)), utf16.Encode([]rune(reply.QuoteText))
|
||||
start := reply.QuoteOffset
|
||||
if start < 0 || start > len(source) || len(quote) > len(source)-start {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
for i, r := range quote {
|
||||
if source[start+i] != r {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
}
|
||||
for _, e := range reply.QuoteEntities {
|
||||
if e.Offset < 0 || e.Length <= 0 || e.Offset > len(quote) || e.Length > len(quote)-e.Offset {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloneMessageReply(in *MessageReply) *MessageReply {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.QuoteEntities = append([]MessageEntity(nil), in.QuoteEntities...)
|
||||
if in.External != nil {
|
||||
x := *in.External
|
||||
x.Entities = append([]MessageEntity(nil), x.Entities...)
|
||||
if x.Media != nil {
|
||||
x.Media = cloneReplyData(reflect.ValueOf(x.Media)).Interface().(*MessageMedia)
|
||||
}
|
||||
out.External = &x
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Clone only our data model. This is not a protocol codec: it neither interprets
|
||||
// TL fields nor converts bytes. Copying nested pointer/slice fields keeps media
|
||||
// added to MessageMedia from silently becoming shared between owner snapshots.
|
||||
func cloneReplyData(v reflect.Value) reflect.Value {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.New(v.Type().Elem())
|
||||
out.Elem().Set(cloneReplyData(v.Elem()))
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneReplyData(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
case reflect.Map:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeMapWithSize(v.Type(), v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(iter.Key(), cloneReplyData(iter.Value()))
|
||||
}
|
||||
return out
|
||||
case reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(cloneReplyData(v.Elem()))
|
||||
return out
|
||||
case reflect.Struct:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(v)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if v.Type().Field(i).IsExported() {
|
||||
out.Field(i).Set(cloneReplyData(v.Field(i)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case reflect.Array:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneReplyData(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
92
internal/domain/message_reply_external_test.go
Normal file
92
internal/domain/message_reply_external_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExternalReplySnapshotIsolationAndInvalidPayload(t *testing.T) {
|
||||
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000, Body: "🌕 quote", Media: &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{ID: 7, FileReference: []byte{1, 2, 3}}}}
|
||||
x, err := NewMessageReplyExternal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.Media.Photo.FileReference[0] = 9
|
||||
if x.Media.Photo.FileReference[0] != 1 {
|
||||
t.Fatal("source can mutate persisted snapshot")
|
||||
}
|
||||
r := &MessageReply{External: x}
|
||||
if err := ValidateMessageReplyBounds(r); err != nil {
|
||||
t.Fatal("snapshot-only recipient header rejected", err)
|
||||
}
|
||||
copy := CloneMessageReply(r)
|
||||
copy.External.Media.Photo.FileReference[0] = 8
|
||||
if x.Media.Photo.FileReference[0] != 1 {
|
||||
t.Fatal("owner clone aliases media snapshot")
|
||||
}
|
||||
b, err := EncodeMessageReplyExternal(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := DecodeMessageReplyExternal(b)
|
||||
if err != nil || !reflect.DeepEqual(restored, x) {
|
||||
t.Fatalf("snapshot roundtrip: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"null", "[]", `{"from":{}}`, string(b) + ` {}`, strings.Replace(string(b), `"text":`, `"unrecognized":1,"text":`, 1), strings.Repeat("x", MaxMessageReplyExternalBytes+1)} {
|
||||
if _, err := DecodeMessageReplyExternal([]byte(bad)); err == nil {
|
||||
t.Fatalf("invalid external snapshot accepted: %.50s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReplyQuoteUsesUTF16AndExactSubstring(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text, quote string
|
||||
offset int
|
||||
valid bool
|
||||
}{
|
||||
{"a🌕 quote", "quote", 4, true}, {"a🌕 quote", "quote", 3, false}, {"a🌕 quote", "🌕", 1, true}, {"a🌕 quote", "🌕", 2, false}, {"source", "invented", 0, false}, {"source", "source", 0, true}, {"source", "", 0, true}, {"source", "", 1, false},
|
||||
} {
|
||||
err := ValidateExternalReplyQuote(&MessageReply{QuoteText: tc.quote, QuoteOffset: tc.offset}, tc.text)
|
||||
if (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrQuoteTextInvalid)) {
|
||||
t.Fatalf("%+v: %v", tc, err)
|
||||
}
|
||||
}
|
||||
text := strings.Repeat("🌕", 3000) + "quote"
|
||||
if err := ValidateExternalReplyQuote(&MessageReply{QuoteText: "quote", QuoteOffset: 6000}, text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(&MessageReply{MessageID: 1, QuoteOffset: 6000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReplyDocumentNestedSlicesRemainIndependent(t *testing.T) {
|
||||
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000,
|
||||
Media: &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{
|
||||
ID: 7, FileReference: []byte{1, 2, 3}, MimeType: "audio/ogg",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrAudio, Voice: true, Waveform: []byte{4, 5, 6}}},
|
||||
}},
|
||||
}
|
||||
x, err := NewMessageReplyExternal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.Media.Document.FileReference[0] = 9
|
||||
source.Media.Document.Attributes[0].Waveform[0] = 9
|
||||
copy := CloneMessageReply(&MessageReply{External: x})
|
||||
copy.External.Media.Document.Attributes[0].Waveform[1] = 9
|
||||
if !reflect.DeepEqual(x.Media.Document.FileReference, []byte{1, 2, 3}) || !reflect.DeepEqual(x.Media.Document.Attributes[0].Waveform, []byte{4, 5, 6}) {
|
||||
t.Fatal("source or another owner mutated the document snapshot")
|
||||
}
|
||||
raw, err := EncodeMessageReplyExternal(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeMessageReplyExternal(raw)
|
||||
if err != nil || !reflect.DeepEqual(got, x) {
|
||||
t.Fatalf("document snapshot roundtrip: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
59
internal/domain/phone_identity_test.go
Normal file
59
internal/domain/phone_identity_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizePhoneUsesCountryAwareE164Identity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "iran redundant national trunk", input: "+98 0998 167 9461", want: "989981679461"},
|
||||
{name: "iran canonical", input: "+98 998 167 9461", want: "989981679461"},
|
||||
{name: "iran wire digits redundant trunk", input: "9809981679461", want: "989981679461"},
|
||||
{name: "italy significant leading zero", input: "+39 02 1234 5678", want: "390212345678"},
|
||||
{name: "china presentation", input: "+86 (188) 0000-0000", want: "8618800000000"},
|
||||
{name: "possible reserved NANP range", input: "+1 555 000 0001", want: "15550000001"},
|
||||
{name: "local number without country", input: "09981679461", want: ""},
|
||||
{name: "letters are not separators", input: "+98abc9981679461", want: ""},
|
||||
{name: "international prefix is not country code", input: "00989981679461", want: ""},
|
||||
{name: "reserved system identity", input: OfficialSystemPhone, want: OfficialSystemPhone},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := NormalizePhone(test.input); got != test.want {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneAcceptsVirtual888LoginIdentityRange(t *testing.T) {
|
||||
for input, want := range map[string]string{
|
||||
"+888 12-34": "8881234",
|
||||
"8880000": "8880000",
|
||||
"888123456789012": "888123456789012",
|
||||
} {
|
||||
if got := NormalizePhone(input); got != want {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
for _, phone := range []string{"888123", "8881234567890123", "+888abc1234"} {
|
||||
if got := NormalizePhone(phone); got != "" {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want empty", phone, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPhoneRequiresCanonicalStorageShape(t *testing.T) {
|
||||
for _, phone := range []string{"989981679461", "390212345678", "8618800000000", "15550000001", "8880000", "888123456789012", OfficialSystemPhone} {
|
||||
if !ValidPhone(phone) {
|
||||
t.Fatalf("ValidPhone(%q) = false", phone)
|
||||
}
|
||||
}
|
||||
for _, phone := range []string{"+989981679461", "+8881234", "888123", "8881234567890123", "9809981679461", "09981679461", "", "+98abc9981679461"} {
|
||||
if ValidPhone(phone) {
|
||||
t.Fatalf("ValidPhone(%q) = true", phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/domain/privacy_evaluate.go
Normal file
89
internal/domain/privacy_evaluate.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package domain
|
||||
|
||||
import "slices"
|
||||
|
||||
func EvaluatePrivacy(rules PrivacyRules, ctx PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitDisallowMatches(rule, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitAllowMatches(rule, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return false
|
||||
}
|
||||
case PrivacyRuleAllowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowAll:
|
||||
return false
|
||||
case PrivacyRuleAllowAll:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func explicitDisallowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case PrivacyRuleDisallowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case PrivacyRuleDisallowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explicitAllowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleAllowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case PrivacyRuleAllowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case PrivacyRuleAllowCloseFriends:
|
||||
return ctx.ViewerCloseFriend
|
||||
case PrivacyRuleAllowPremium:
|
||||
return ctx.ViewerIsPremium
|
||||
case PrivacyRuleAllowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := set[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import (
|
|||
const (
|
||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||
OfficialSystemUserID int64 = 777000
|
||||
// OfficialSystemPhone is a reserved service identity, not an ordinary E.164
|
||||
// login number. Auth must recognize and reject it before account lookup.
|
||||
OfficialSystemPhone = "42777"
|
||||
// OfficialSystemUserPhotoID/AccessHash 是该账号头像 photo 的固定 id,
|
||||
// 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致,
|
||||
// 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。
|
||||
|
|
@ -108,7 +111,7 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
|
|||
}
|
||||
|
||||
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName --
|
||||
// empty means "use branding.ProductName" (the compile-time default), set
|
||||
// empty means "use branding.ProductName()" (the compile-time default), set
|
||||
// once at startup from the operator's Server Settings -> Server identity
|
||||
// name, if any. Deliberately only the display name, not Username: the
|
||||
// account's @username is a stable, addressable identifier other things may
|
||||
|
|
@ -118,7 +121,7 @@ var officialSystemUserDisplayName string
|
|||
// SetOfficialSystemUserDisplayName records the operator's custom server
|
||||
// name for the official system account (777000), read once at startup from
|
||||
// Server Settings -> Server identity. Pass "" to fall back to
|
||||
// branding.ProductName -- the same "unset -> default" contract the avatar
|
||||
// branding.ProductName() -- the same "unset -> default" contract the avatar
|
||||
// override above uses.
|
||||
func SetOfficialSystemUserDisplayName(name string) {
|
||||
officialSystemUserDisplayName = strings.TrimSpace(name)
|
||||
|
|
@ -126,7 +129,7 @@ func SetOfficialSystemUserDisplayName(name string) {
|
|||
|
||||
// officialSystemDisplayName returns the official system account's current
|
||||
// effective display name: the operator's custom override if set via
|
||||
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
|
||||
// SetOfficialSystemUserDisplayName, else branding.ProductName(). Shared by
|
||||
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
|
||||
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
|
||||
// consistent with each other.
|
||||
|
|
@ -134,7 +137,7 @@ func officialSystemDisplayName() string {
|
|||
if officialSystemUserDisplayName != "" {
|
||||
return officialSystemUserDisplayName
|
||||
}
|
||||
return branding.ProductName
|
||||
return branding.ProductName()
|
||||
}
|
||||
|
||||
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
|
||||
|
|
@ -223,11 +226,11 @@ func OfficialSystemUser() User {
|
|||
// only the default snapshot; startup reconciliation and the memory backend use
|
||||
// these helpers so custom deployments do not expose stale "telesrv" text.
|
||||
func ChatBotDescription() string {
|
||||
return "Chat with the configured " + branding.ProductName + " AI provider."
|
||||
return "Chat with the configured " + branding.ProductName() + " AI provider."
|
||||
}
|
||||
|
||||
func StickersBotDescription() string {
|
||||
return "Create custom sticker and emoji packs for " + branding.ProductName + "."
|
||||
return "Create custom sticker and emoji packs for " + branding.ProductName() + "."
|
||||
}
|
||||
|
||||
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue