fix: sync contact phone privacy disclosure
This commit is contained in:
parent
0e2fcdf9c8
commit
e1a95c7318
19 changed files with 789 additions and 50 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue