fix: sync contact phone privacy disclosure
This commit is contained in:
parent
0e2fcdf9c8
commit
e1a95c7318
19 changed files with 789 additions and 50 deletions
|
|
@ -0,0 +1,4 @@
|
|||
-- Irreversible privacy cleanup: restoring users.phone here would recreate the
|
||||
-- disclosure this migration removes. Contact relations and all non-phone
|
||||
-- owner-scoped fields are preserved by the up migration.
|
||||
SELECT 1;
|
||||
15
deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql
Normal file
15
deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- contacts.addContact historically replaced an omitted phone with users.phone.
|
||||
-- Those rows are indistinguishable from a client-supplied copy of the same
|
||||
-- number, so privacy-safe repair must treat every exact account-phone copy as
|
||||
-- ambiguous. The contact relationship and owner-scoped names/notes remain; a
|
||||
-- later contacts.importContacts sync can explicitly restore a known phone.
|
||||
--
|
||||
-- This is a one-time write-path repair. Runtime reads must not normalize or
|
||||
-- second-guess the bad shape.
|
||||
UPDATE contacts AS c
|
||||
SET contact_phone = '',
|
||||
updated_at = now()
|
||||
FROM users AS u
|
||||
WHERE u.id = c.contact_user_id
|
||||
AND c.contact_phone <> ''
|
||||
AND c.contact_phone = u.phone;
|
||||
|
|
@ -22,6 +22,7 @@ const maxCloseFriendsCount = 5000
|
|||
|
||||
type phonePrivacyService interface {
|
||||
userprojection.PrivacyEvaluator
|
||||
userprojection.BatchPrivacyEvaluator
|
||||
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
|
||||
}
|
||||
|
||||
|
|
@ -123,19 +124,17 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
|||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
|
||||
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
|
||||
// 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留;
|
||||
// TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。
|
||||
input.Phone = digitsOnly(input.Phone)
|
||||
if s.users != nil {
|
||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
_, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
}
|
||||
if input.Phone == "" {
|
||||
input.Phone = target.Phone
|
||||
}
|
||||
}
|
||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||
if err != nil {
|
||||
|
|
@ -235,6 +234,30 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
|
|||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
if s.privacy != nil && len(targets) > 0 {
|
||||
targetIDs := make([]int64, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
visibility, err := s.privacy.CanSeeBatch(
|
||||
ctx,
|
||||
targetIDs,
|
||||
userID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ImportContactsResult{}, err
|
||||
}
|
||||
allowed := targets[:0]
|
||||
for _, target := range targets {
|
||||
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
|
||||
allowed = append(allowed, target)
|
||||
}
|
||||
}
|
||||
targets = allowed
|
||||
}
|
||||
byPhone := make(map[string]domain.User, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Phone != "" {
|
||||
|
|
@ -310,10 +333,52 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
|
|||
if limit <= 0 || limit > maxSearchLimit {
|
||||
limit = maxSearchLimit
|
||||
}
|
||||
res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit)
|
||||
phoneQuery := ""
|
||||
if isPhoneSearchQuery(query) {
|
||||
phoneQuery = normalizePhone(query)
|
||||
}
|
||||
res, err := s.users.Search(ctx, userID, query, phoneQuery, limit)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 {
|
||||
targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results))
|
||||
for _, target := range res.MyResults {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
for _, target := range res.Results {
|
||||
if target.ID != 0 && target.ID != userID {
|
||||
targetIDs = append(targetIDs, target.ID)
|
||||
}
|
||||
}
|
||||
visibility, err := s.privacy.CanSeeBatch(
|
||||
ctx,
|
||||
targetIDs,
|
||||
userID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
knownContacts := map[int64]domain.Contact{}
|
||||
if s.contacts != nil && len(targetIDs) > 0 {
|
||||
knownContacts, err = s.contacts.GetMany(ctx, userID, targetIDs)
|
||||
if err != nil {
|
||||
return domain.UserSearchResult{}, err
|
||||
}
|
||||
}
|
||||
allowed := func(target domain.User) bool {
|
||||
if visibility[target.ID][domain.PrivacyKeyAddedByPhone] {
|
||||
return true
|
||||
}
|
||||
contact, found := knownContacts[target.ID]
|
||||
return found && contact.Phone != "" && strings.HasPrefix(contact.Phone, phoneQuery)
|
||||
}
|
||||
res.MyResults = filterSearchUsers(res.MyResults, allowed)
|
||||
res.Results = filterSearchUsers(res.Results, allowed)
|
||||
}
|
||||
return s.projectSearchResult(ctx, userID, res)
|
||||
}
|
||||
|
||||
|
|
@ -452,11 +517,14 @@ func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, v
|
|||
if s.contacts == nil {
|
||||
return false, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
||||
contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
// Merely adding the owner by user id does not mean the viewer knows the
|
||||
// owner's phone. Only a non-empty owner-scoped contact phone can suppress the
|
||||
// "share my phone" prompt when PhoneNumber privacy itself denies visibility.
|
||||
return found && contact.Phone != "", nil
|
||||
}
|
||||
|
||||
// BlockContact adds peer to the current user's blocklist.
|
||||
|
|
@ -506,7 +574,22 @@ func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit in
|
|||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return s.contacts.ListBlocked(ctx, userID, offset, limit)
|
||||
list, err := s.contacts.ListBlocked(ctx, userID, offset, limit)
|
||||
if err != nil || len(list.Blocked) == 0 || s.projector == nil {
|
||||
return list, err
|
||||
}
|
||||
users := make([]domain.User, len(list.Blocked))
|
||||
for i := range list.Blocked {
|
||||
users[i] = list.Blocked[i].User
|
||||
}
|
||||
projected, err := s.projector.ForViewer(ctx, userID, users)
|
||||
if err != nil {
|
||||
return domain.BlockedContactList{}, err
|
||||
}
|
||||
for i := range list.Blocked {
|
||||
list.Blocked[i].User = projected[i]
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||
|
|
@ -590,6 +673,34 @@ func normalizePhone(phone string) string {
|
|||
return phone
|
||||
}
|
||||
|
||||
func isPhoneSearchQuery(query string) bool {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return false
|
||||
}
|
||||
hasDigit := false
|
||||
for _, r := range query {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '+', r == ' ', r == '-', r == '(', r == ')':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
func filterSearchUsers(users []domain.User, keep func(domain.User) bool) []domain.User {
|
||||
out := users[:0]
|
||||
for _, user := range users {
|
||||
if keep(user) {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
|
|
|
|||
|
|
@ -168,6 +168,151 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddContactWithoutPhoneDoesNotBackfillTargetPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
|
||||
contact, err := svc.AddContact(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: "Saved",
|
||||
Phone: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddContact: %v", err)
|
||||
}
|
||||
if contact.Phone != "" || contact.User.Phone != "" {
|
||||
t.Fatalf("projected contact phone = local %q user %q, want both empty", contact.Phone, contact.User.Phone)
|
||||
}
|
||||
stored, found, err := contactsStore.Get(ctx, owner.ID, target.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("stored contact found=%v err=%v", found, err)
|
||||
}
|
||||
if stored.Phone != "" || stored.User.Phone != "" {
|
||||
t.Fatalf("stored contact phone = local %q user %q, want both empty", stored.Phone, stored.User.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportContactsHonorsAddedByPhoneInOneBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
input := []domain.ContactInput{{ClientID: 1, Phone: target.Phone, FirstName: "Saved"}}
|
||||
|
||||
hidden, err := svc.ImportContacts(ctx, owner.ID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts hidden: %v", err)
|
||||
}
|
||||
if len(hidden.Imported) != 0 || len(hidden.Contacts) != 0 {
|
||||
t.Fatalf("hidden import = %+v, want no resolved target", hidden)
|
||||
}
|
||||
|
||||
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: owner.ID,
|
||||
FirstName: owner.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add owner: %v", err)
|
||||
}
|
||||
visible, err := svc.ImportContacts(ctx, owner.ID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts visible: %v", err)
|
||||
}
|
||||
if len(visible.Imported) != 1 || visible.Imported[0].UserID != target.ID || len(visible.Contacts) != 1 {
|
||||
t.Fatalf("visible import = %+v, want target %d", visible, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneSearchHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore)
|
||||
if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc))
|
||||
|
||||
hidden, err := svc.Search(ctx, owner.ID, "+1 (555) 123-4567", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search hidden: %v", err)
|
||||
}
|
||||
if len(hidden.Results) != 0 {
|
||||
t.Fatalf("hidden phone search results = %+v, want empty", hidden.Results)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: target.FirstName,
|
||||
Phone: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("owner add target without phone: %v", err)
|
||||
}
|
||||
stillHidden, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search owner-only contact: %v", err)
|
||||
}
|
||||
if len(stillHidden.MyResults)+len(stillHidden.Results) != 0 {
|
||||
t.Fatalf("owner-only empty-phone contact search = %+v, want hidden", stillHidden)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
|
||||
ContactUserID: target.ID,
|
||||
FirstName: target.FirstName,
|
||||
Phone: target.Phone,
|
||||
}); err != nil {
|
||||
t.Fatalf("owner save target phone: %v", err)
|
||||
}
|
||||
knownLocally, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search locally known phone: %v", err)
|
||||
}
|
||||
if len(knownLocally.MyResults)+len(knownLocally.Results) != 1 {
|
||||
t.Fatalf("locally known phone search = %+v, want one local contact", knownLocally)
|
||||
}
|
||||
if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: owner.ID,
|
||||
FirstName: owner.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add owner: %v", err)
|
||||
}
|
||||
visible, err := svc.Search(ctx, owner.ID, target.Phone, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Search visible: %v", err)
|
||||
}
|
||||
if len(visible.Results) != 1 || visible.Results[0].ID != target.ID {
|
||||
t.Fatalf("visible phone search = %+v, want target %d", visible.Results, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -387,8 +532,8 @@ func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("AddContact digitless phone: %v", err)
|
||||
}
|
||||
if emptied.Phone != bob.Phone {
|
||||
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
|
||||
if emptied.Phone != "" || emptied.User.Phone != "" {
|
||||
t.Fatalf("digitless phone contact = local %q user %q, want empty without account-phone fallback", emptied.Phone, emptied.User.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,31 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
|||
return err
|
||||
}
|
||||
c.InvalidateOwners(rules.OwnerUserID)
|
||||
// 数据写入已提交,预热失败不能伪装成写失败;LISTEN/NOTIFY 也会在每个
|
||||
// 实例上再次失效并预热,覆盖本实例通知晚于这里到达的时序。
|
||||
_ = c.WarmOwners(ctx, rules.OwnerUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
||||
owners := dedupPrivacyOwnerIDs(ownerUserIDs)
|
||||
if len(owners) == 0 || c == nil || c.cache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 隐私规则变更很少、读取极热。必须重建全部 key,
|
||||
// 不能只塞本次 key,否则会把 owner 的其它持久规则误当成默认规则。
|
||||
loadEpoch := c.cache.LoadEpoch()
|
||||
list, err := c.inner.ListPrivacyRules(ctx, owners, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots := buildPrivacyRulesByOwner(list, owners)
|
||||
for _, ownerUserID := range owners {
|
||||
c.cache.StoreIfEpoch(ownerUserID, snapshots[ownerUserID], loadEpoch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
||||
func TestCachedPrivacyStoreWarmsCompleteOwnerSnapshotOnSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
|
|
@ -121,24 +121,34 @@ func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
|||
t.Fatalf("set first: %v", err)
|
||||
}
|
||||
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
|
||||
t.Fatalf("prime get ok=%v err=%v", ok, err)
|
||||
t.Fatalf("first memory get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("set second: %v", err)
|
||||
}
|
||||
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after invalidation get ok=%v err=%v", ok, err)
|
||||
t.Fatalf("phone after second set ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("phone rules after second set = %+v, want disallow all", got.Rules)
|
||||
}
|
||||
photo, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("photo after second set ok=%v err=%v", ok, err)
|
||||
}
|
||||
if photo.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("photo rules after second set = %+v, want disallow all", photo.Rules)
|
||||
}
|
||||
if counting.setCalls != 2 {
|
||||
t.Fatalf("SetPrivacyRules calls = %d, want 2", counting.setCalls)
|
||||
}
|
||||
if counting.listCalls != 2 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls)
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want exactly one write-path warm per set and no read-path query", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ const (
|
|||
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
||||
|
||||
contactSnapshotMaxViewers = 4096
|
||||
contactReverseSnapshotOwnerCap = 16
|
||||
contactReversePairMaxEntries = 262144
|
||||
contactPersonalPhotoSnapshotCap = 4096
|
||||
)
|
||||
|
||||
|
|
@ -34,11 +36,32 @@ type personalPhotoSnapshot struct {
|
|||
expireAt time.Time
|
||||
}
|
||||
|
||||
type reverseContactKey struct {
|
||||
ownerUserID int64
|
||||
contactUserID int64
|
||||
}
|
||||
|
||||
type reverseContactSnapshot struct {
|
||||
contact domain.Contact
|
||||
found bool
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
type reverseContactEntry struct {
|
||||
key reverseContactKey
|
||||
snapshot reverseContactSnapshot
|
||||
}
|
||||
|
||||
type contactSnapshotLoadResult struct {
|
||||
snap contactAccountSnapshot
|
||||
stored bool
|
||||
}
|
||||
|
||||
type reverseContactLoadResult struct {
|
||||
contacts map[int64]domain.Contact
|
||||
stored bool
|
||||
}
|
||||
|
||||
type personalPhotoSnapshotLoadResult struct {
|
||||
snap personalPhotoSnapshot
|
||||
stored bool
|
||||
|
|
@ -59,6 +82,10 @@ type CachedContactStore struct {
|
|||
mu sync.RWMutex
|
||||
contacts map[int64]contactAccountSnapshot
|
||||
personalPhotos map[int64]personalPhotoSnapshot
|
||||
reverse map[reverseContactKey]*list.Element
|
||||
reverseLRU *list.List
|
||||
reverseByOwner map[int64]map[int64]struct{}
|
||||
reverseCap int
|
||||
epoch uint64
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
|
@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC
|
|||
now: time.Now,
|
||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
|
||||
reverse: make(map[reverseContactKey]*list.Element, 4096),
|
||||
reverseLRU: list.New(),
|
||||
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
|
||||
reverseCap: contactReversePairMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,24 +162,90 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
|
|||
if len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if len(owners) > contactReverseSnapshotOwnerCap {
|
||||
// Large fan-out should keep using the store's set query until a dedicated
|
||||
// reverse-contact read model exists; loading hundreds of full contact
|
||||
// lists would be worse than one batched SQL.
|
||||
return c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
}
|
||||
missing := make([]int64, 0, len(owners))
|
||||
now := c.now()
|
||||
for _, ownerID := range owners {
|
||||
snap, err := c.contactSnapshot(ctx, ownerID)
|
||||
// Reuse a full owner snapshot when another hot path already loaded it.
|
||||
// Do not cold-load one full list per owner: a large projection would turn
|
||||
// into N SQL queries.
|
||||
if snap, ok := c.lookupContactSnapshot(ownerID, now); ok {
|
||||
if contact, found := snap.contacts[userID]; found {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if contact, found, cached := c.lookupReverseContact(ownerID, userID, now); cached {
|
||||
if found {
|
||||
out[ownerID] = contact
|
||||
}
|
||||
continue
|
||||
}
|
||||
missing = append(missing, ownerID)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
loaded, err := c.loadReverseContacts(ctx, userID, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contact, ok := snap.contacts[userID]; ok {
|
||||
for ownerID, contact := range loaded {
|
||||
if contact.User.ID != 0 {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadReverseContacts performs at most one batched cold-store read for all
|
||||
// missing owner→viewer pairs, then caches both hits and misses. Privacy
|
||||
// projection therefore stays memory-only after warm-up instead of repeating a
|
||||
// reverse-contact SQL query on every large user vector.
|
||||
func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
owners := append([]int64(nil), ownerUserIDs...)
|
||||
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
|
||||
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
|
||||
for {
|
||||
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
|
||||
loadEpoch := c.cacheEpoch()
|
||||
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
if err != nil {
|
||||
return reverseContactLoadResult{}, err
|
||||
}
|
||||
now := c.now()
|
||||
expireAt := now.Add(c.ttl)
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
if stored {
|
||||
for _, ownerID := range owners {
|
||||
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
|
||||
contact, found := contacts[ownerID]
|
||||
c.storeReverseContactLocked(key, reverseContactSnapshot{
|
||||
contact: cloneCachedContact(contact),
|
||||
found: found,
|
||||
expireAt: expireAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return reverseContactLoadResult{
|
||||
contacts: cloneCachedContactMap(contacts),
|
||||
stored: stored,
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := v.(reverseContactLoadResult)
|
||||
if result.stored {
|
||||
return result.contacts, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact, err := c.inner.Upsert(ctx, userID, input)
|
||||
if err == nil {
|
||||
|
|
@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.
|
|||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
|
||||
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
|
||||
c.mu.Lock()
|
||||
element, ok := c.reverse[key]
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
return domain.Contact{}, false, false
|
||||
}
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
snap := entry.snapshot
|
||||
if !snap.expireAt.After(now) {
|
||||
c.removeReverseElementLocked(element)
|
||||
c.mu.Unlock()
|
||||
return domain.Contact{}, false, false
|
||||
}
|
||||
c.reverseLRU.MoveToFront(element)
|
||||
c.mu.Unlock()
|
||||
return cloneCachedContact(snap.contact), snap.found, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
|
||||
if element, ok := c.reverse[key]; ok {
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
entry.snapshot = snapshot
|
||||
c.reverseLRU.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
element := c.reverseLRU.PushFront(&reverseContactEntry{key: key, snapshot: snapshot})
|
||||
c.reverse[key] = element
|
||||
if c.reverseByOwner[key.ownerUserID] == nil {
|
||||
c.reverseByOwner[key.ownerUserID] = make(map[int64]struct{})
|
||||
}
|
||||
c.reverseByOwner[key.ownerUserID][key.contactUserID] = struct{}{}
|
||||
for c.reverseLRU.Len() > c.reverseCap {
|
||||
c.removeReverseElementLocked(c.reverseLRU.Back())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
delete(c.reverse, entry.key)
|
||||
if viewers := c.reverseByOwner[entry.key.ownerUserID]; viewers != nil {
|
||||
delete(viewers, entry.key.contactUserID)
|
||||
if len(viewers) == 0 {
|
||||
delete(c.reverseByOwner, entry.key.ownerUserID)
|
||||
}
|
||||
}
|
||||
c.reverseLRU.Remove(element)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||
if c == nil || len(ids) == 0 {
|
||||
return
|
||||
|
|
@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
|||
}
|
||||
delete(c.contacts, id)
|
||||
delete(c.personalPhotos, id)
|
||||
for contactUserID := range c.reverseByOwner[id] {
|
||||
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
|
||||
c.removeReverseElementLocked(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
|
@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() {
|
|||
c.epoch++
|
||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
|
||||
c.reverseLRU.Init()
|
||||
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +573,14 @@ func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) co
|
|||
return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt}
|
||||
}
|
||||
|
||||
func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact {
|
||||
out := make(map[int64]domain.Contact, len(in))
|
||||
for id, contact := range in {
|
||||
out[id] = cloneCachedContact(contact)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupContactIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
|
|
|
|||
|
|
@ -162,6 +162,81 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
owners := make([]int64, 32)
|
||||
for i := range owners {
|
||||
owners[i] = int64(i + 1)
|
||||
if i%2 == 0 {
|
||||
if _, err := base.Upsert(ctx, owners[i], domain.ContactInput{
|
||||
ContactUserID: 9001,
|
||||
FirstName: "Viewer",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner %d: %v", owners[i], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
|
||||
first, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("first reverse lookup: %v", err)
|
||||
}
|
||||
if len(first) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
||||
t.Fatalf("first reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(first), counting.reverseCalls, counting.listCalls)
|
||||
}
|
||||
second, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("second reverse lookup: %v", err)
|
||||
}
|
||||
if len(second) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
||||
t.Fatalf("cached reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(second), counting.reverseCalls, counting.listCalls)
|
||||
}
|
||||
|
||||
cached.InvalidateViewers(owners[0])
|
||||
third, err := cached.GetReverseContacts(ctx, 9001, owners)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse lookup after owner invalidation: %v", err)
|
||||
}
|
||||
if len(third) != 16 || counting.reverseCalls != 2 {
|
||||
t.Fatalf("invalidated reverse hits=%d reverseCalls=%d, want 16/2", len(third), counting.reverseCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
for ownerID := int64(1); ownerID <= 3; ownerID++ {
|
||||
if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{
|
||||
ContactUserID: 9001,
|
||||
FirstName: "Viewer",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner %d: %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
cached.reverseCap = 2
|
||||
|
||||
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
|
||||
got, err := cached.GetReverseContacts(ctx, 9001, []int64{ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("reverse owner %d: %v", ownerID, err)
|
||||
}
|
||||
if _, ok := got[ownerID]; !ok {
|
||||
t.Fatalf("reverse owner %d missing", ownerID)
|
||||
}
|
||||
}
|
||||
if counting.reverseCalls != 4 {
|
||||
t.Fatalf("reverse calls = %d, want 4 (owner 1 touched, owner 2 evicted only)", counting.reverseCalls)
|
||||
}
|
||||
if len(cached.reverse) != 2 || cached.reverseLRU.Len() != 2 {
|
||||
t.Fatalf("reverse cache map/list = %d/%d, want 2/2", len(cached.reverse), cached.reverseLRU.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
|
|
|
|||
|
|
@ -209,7 +209,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
vis = matrix[u.ID][viewer]
|
||||
}
|
||||
var perr error
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil)
|
||||
hasKnownContactPhone := found && contact.Phone != ""
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
|
|
@ -341,8 +342,9 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
|||
}
|
||||
|
||||
// ForViewer applies the owner-specific user view that Telegram clients expect.
|
||||
// In particular, phone is visible for self and contacts; non-contacts should not
|
||||
// receive a phone field because TDesktop will prefer it over the public name.
|
||||
// A contact relationship alone never grants phone visibility. A viewer may retain
|
||||
// an owner-scoped phone it explicitly supplied, while the target account phone is
|
||||
// governed by PhoneNumber privacy.
|
||||
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||
|
|
@ -489,8 +491,9 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||
contact, found := contactsByID[u.ID]
|
||||
projected = applyContactProjection(projected, contact, found)
|
||||
hasKnownContactPhone := found && contact.Phone != ""
|
||||
var err error
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, hasKnownContactPhone, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -608,9 +611,11 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
user.ContactNote = contact.Note
|
||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
// contact.Phone is an owner-local fact supplied by this viewer. It may differ
|
||||
// from the target's current account phone and is safe to preserve because the
|
||||
// viewer already knew it. An empty contact.Phone must not replace or authorize
|
||||
// the target account phone carried by user.Phone.
|
||||
if contact.Phone != "" {
|
||||
user.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
|
|
@ -623,11 +628,16 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
return user
|
||||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, hasKnownContactPhone bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if user.Deleted {
|
||||
return user.DeletedTombstone(), nil
|
||||
}
|
||||
if privacy == nil {
|
||||
// Missing privacy wiring must fail closed for an account phone. The only
|
||||
// safe exception is an owner-scoped phone the viewer explicitly supplied.
|
||||
if !hasKnownContactPhone {
|
||||
user.Phone = ""
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
// vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
|
||||
|
|
@ -642,7 +652,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !phoneAllowed && !isContact {
|
||||
if !phoneAllowed && !hasKnownContactPhone {
|
||||
user.Phone = ""
|
||||
}
|
||||
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,51 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectorContactWithoutKnownPhoneCannotBypassPhonePrivacy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
viewerID = int64(3101)
|
||||
ownerID = int64(3102)
|
||||
)
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
|
||||
ContactUserID: ownerID,
|
||||
FirstName: "Saved",
|
||||
Phone: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
)
|
||||
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{
|
||||
ID: ownerID,
|
||||
Phone: "15550003102",
|
||||
FirstName: "Owner",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer: %v", err)
|
||||
}
|
||||
owner := projectionUser(t, users, ownerID)
|
||||
if !owner.Contact || owner.Phone != "" {
|
||||
t.Fatalf("owner projection = %+v, want contact=true with hidden phone", owner)
|
||||
}
|
||||
batch, err := projector.ForViewers(ctx, []int64{viewerID}, []domain.User{{
|
||||
ID: ownerID,
|
||||
Phone: "15550003102",
|
||||
FirstName: "Owner",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
batchOwner := projectionUser(t, batch[viewerID], ownerID)
|
||||
if !batchOwner.Contact || batchOwner.Phone != "" {
|
||||
t.Fatalf("batch owner projection = %+v, want contact=true with hidden phone", batchOwner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -563,7 +563,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return u, true, nil
|
||||
}
|
||||
|
||||
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
|
||||
// ResolvePhone resolves a phone number only when the target's AddedByPhone
|
||||
// privacy allows the current viewer. The evaluator is backed by owner-level
|
||||
// privacy/contact snapshots in production, so this adds no per-rule SQL query.
|
||||
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
@ -577,6 +579,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
return u, found, err
|
||||
}
|
||||
s.putCachedUsers(ctx, u)
|
||||
if s.privacy != nil && u.ID != currentUserID {
|
||||
allowed := false
|
||||
var err error
|
||||
if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok {
|
||||
visibility, batchErr := batch.CanSeeBatch(
|
||||
ctx,
|
||||
[]int64{u.ID},
|
||||
currentUserID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyAddedByPhone},
|
||||
)
|
||||
err = batchErr
|
||||
allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone]
|
||||
} else {
|
||||
allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone)
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
if !allowed {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
}
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
|
@ -60,6 +61,42 @@ func TestServiceUsernameLifecycle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contacts := memory.NewContactStore()
|
||||
viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
|
||||
if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set AddedByPhone: %v", err)
|
||||
}
|
||||
svc := NewService(users,
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
)
|
||||
|
||||
if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found {
|
||||
t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err)
|
||||
}
|
||||
if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{
|
||||
ContactUserID: viewer.ID,
|
||||
FirstName: viewer.FirstName,
|
||||
}); err != nil {
|
||||
t.Fatalf("target add viewer: %v", err)
|
||||
}
|
||||
got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone)
|
||||
if err != nil || !found || got.ID != target.ID {
|
||||
t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -1276,7 +1276,7 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
|
|||
|
||||
withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
||||
ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
|
||||
Phone: bob.Phone,
|
||||
Phone: "",
|
||||
FirstName: "Bobby",
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -1291,6 +1291,19 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
|
|||
} else if allowed {
|
||||
t.Fatalf("bob can see alice phone = true, want false before exception")
|
||||
}
|
||||
withoutExceptionUpdates := withoutException.(*tg.Updates)
|
||||
for _, item := range withoutExceptionUpdates.Users {
|
||||
if user, ok := item.(*tg.User); ok && user.ID == bob.ID && user.Phone != "" {
|
||||
t.Fatalf("contacts.addContact(phone=\"\") leaked bob phone %q in updates", user.Phone)
|
||||
}
|
||||
}
|
||||
storedBob, found, err := contactsStore.Get(ctx, alice.ID, bob.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("stored bob contact found=%v err=%v", found, err)
|
||||
}
|
||||
if storedBob.Phone != "" || storedBob.User.Phone != "" {
|
||||
t.Fatalf("stored bob contact phone = local %q user %q, want empty", storedBob.Phone, storedBob.User.Phone)
|
||||
}
|
||||
|
||||
withException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
||||
AddPhonePrivacyException: true,
|
||||
|
|
@ -1538,8 +1551,8 @@ func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
|
|||
if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID {
|
||||
t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID)
|
||||
}
|
||||
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID {
|
||||
t.Fatalf("blocked user = %#v, want alice", full.Users[0])
|
||||
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" {
|
||||
t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0])
|
||||
}
|
||||
|
||||
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{
|
||||
|
|
|
|||
|
|
@ -138,9 +138,6 @@ func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.Cont
|
|||
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
|
||||
contact.CloseFriend = existing.CloseFriend
|
||||
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
|
||||
if contact.Phone == "" {
|
||||
contact.User.Phone = existing.User.Phone
|
||||
}
|
||||
if contact.FirstName == "" {
|
||||
contact.User.FirstName = existing.User.FirstName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1597,7 +1597,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
|
|||
profile.LastName,
|
||||
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
|
||||
profile.Username,
|
||||
profile.Phone,
|
||||
strconv.FormatInt(viewerID, 10),
|
||||
}
|
||||
if isContact {
|
||||
|
|
@ -1610,7 +1609,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
|
|||
contact.User.LastName,
|
||||
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
|
||||
contact.User.Username,
|
||||
contact.User.Phone,
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
|
|
|
|||
|
|
@ -1627,6 +1627,33 @@ func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) {
|
|||
t.Fatalf("username query = %+v, want viewer 2002", stranger)
|
||||
}
|
||||
|
||||
hiddenAccountPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
|
||||
ViewerUserID: owner.ID,
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Limit: 10,
|
||||
Query: "155502",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list query hidden account phone: %v", err)
|
||||
}
|
||||
if hiddenAccountPhone.Count != 0 || len(hiddenAccountPhone.Views) != 0 {
|
||||
t.Fatalf("hidden account phone query = %+v, want no match", hiddenAccountPhone)
|
||||
}
|
||||
knownContactPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
|
||||
ViewerUserID: owner.ID,
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Limit: 10,
|
||||
Query: "7001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list query known contact phone: %v", err)
|
||||
}
|
||||
if knownContactPhone.Count != 1 || len(knownContactPhone.Views) != 1 || knownContactPhone.Views[0].ViewerID != 2001 {
|
||||
t.Fatalf("known contact phone query = %+v, want viewer 2001", knownContactPhone)
|
||||
}
|
||||
|
||||
intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
|
||||
ViewerUserID: owner.ID,
|
||||
Owner: owner,
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ func (f *fakeDialogReadModelCache) flushCount() int {
|
|||
type fakePrivacyReadModelCache struct {
|
||||
mu sync.Mutex
|
||||
ids []int64
|
||||
warmed []int64
|
||||
flushes int
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +429,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
|
|||
f.ids = append(f.ids, ids...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.warmed = append(f.warmed, ids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -440,6 +448,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
|
|||
return append([]int64(nil), f.ids...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]int64(nil), f.warmed...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) flushCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -509,6 +523,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
|
|||
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
|
||||
t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
|
||||
}
|
||||
if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 {
|
||||
t.Fatalf("privacy warms = %v, want [21]", warmed)
|
||||
}
|
||||
|
||||
listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`)
|
||||
if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -13,6 +14,8 @@ import (
|
|||
|
||||
const readModelChangeNotifyChannel = "telesrv_read_model_changed"
|
||||
|
||||
const privacyReadModelWarmTimeout = 5 * time.Second
|
||||
|
||||
// ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。
|
||||
// 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。
|
||||
type ReadModelCacheSet struct {
|
||||
|
|
@ -90,6 +93,14 @@ type PrivacyReadModelCache interface {
|
|||
FlushReadModelCache()
|
||||
}
|
||||
|
||||
// PrivacyReadModelWarmer lets the low-frequency change stream rebuild owner
|
||||
// snapshots after invalidation, so the next user projection does not own a
|
||||
// synchronous database miss. It is optional; caches without it remain
|
||||
// cache-aside and only receive invalidation.
|
||||
type PrivacyReadModelWarmer interface {
|
||||
WarmOwners(context.Context, ...int64) error
|
||||
}
|
||||
|
||||
type ProfilePhotoReadModelCache interface {
|
||||
InvalidateOwner(domain.PeerType, int64)
|
||||
FlushReadModelCache()
|
||||
|
|
@ -393,6 +404,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
|||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
|
||||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(evt.OwnerUserID)
|
||||
}
|
||||
if warmer, ok := l.caches.Privacy.(PrivacyReadModelWarmer); ok && evt.OwnerUserID != 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), privacyReadModelWarmTimeout)
|
||||
err := warmer.WarmOwners(ctx, evt.OwnerUserID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
l.log.Warn("warm privacy read model after change",
|
||||
zap.Int64("owner_user_id", evt.OwnerUserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
case "dialog_light":
|
||||
if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 {
|
||||
peer := domain.Peer{Type: peerType, ID: evt.PeerID}
|
||||
|
|
|
|||
|
|
@ -1126,7 +1126,7 @@ WHERE sv.owner_peer_type = $1
|
|||
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
|
||||
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
|
||||
OR lower(u.username) LIKE $7 ESCAPE '\'
|
||||
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
|
||||
OR lower(c.contact_phone) LIKE $7 ESCAPE '\'
|
||||
)`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike).Scan(&count); err != nil {
|
||||
return domain.StoryViewList{}, fmt.Errorf("count story views: %w", err)
|
||||
}
|
||||
|
|
@ -1160,7 +1160,7 @@ WHERE sv.owner_peer_type = $1
|
|||
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
|
||||
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
|
||||
OR lower(u.username) LIKE $7 ESCAPE '\'
|
||||
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
|
||||
OR lower(c.contact_phone) LIKE $7 ESCAPE '\'
|
||||
)
|
||||
AND (
|
||||
NOT $9::boolean
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue