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