fix: sync contact phone privacy disclosure

This commit is contained in:
iamxvbaba 2026-07-24 11:56:58 +08:00
parent 0e2fcdf9c8
commit e1a95c7318
19 changed files with 789 additions and 50 deletions

View file

@ -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)
if err != nil {
return nil, err
// 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, ok := snap.contacts[userID]; ok {
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
}
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))

View file

@ -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()

View file

@ -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)

View file

@ -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 (