diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 0df06d12..f49216d4 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -26,6 +26,7 @@ import ( "telesrv/internal/app/langpack" "telesrv/internal/app/maintenance" messageapp "telesrv/internal/app/messages" + privacyapp "telesrv/internal/app/privacy" "telesrv/internal/app/updates" "telesrv/internal/app/users" "telesrv/internal/config" @@ -125,6 +126,7 @@ func run(logger *zap.Logger) error { messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator)) channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator)) mediaStore := postgres.NewMediaStore(pool) + privacyStore := postgres.NewPrivacyStore(pool) blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir) if err != nil { return fmt.Errorf("init blob backend: %w", err) @@ -170,6 +172,7 @@ func run(logger *zap.Logger) error { rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout), ).Run(ctx) langPackService := langpack.NewService(langPackStore) + privacyService := privacyapp.NewService(privacyStore, contactStore) if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil { return fmt.Errorf("seed langpack: %w", err) } else if seeded > 0 { @@ -184,12 +187,13 @@ func run(logger *zap.Logger) error { }, rpc.Deps{ Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)), Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)), + Privacy: privacyService, Help: help.NewService(helpStore, helpStore), - Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore)), + Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore), users.WithPrivacyEvaluator(privacyService)), Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)), - Contacts: contacts.NewService(contactStore, userStore), - Dialogs: dialogs.NewService(dialogStore, channelStore), - Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore)), + Contacts: contacts.NewService(contactStore, userStore).Configure(contacts.WithPhotoProvider(mediaStore), contacts.WithPrivacyEvaluator(privacyService)), + Dialogs: dialogs.NewService(dialogStore, channelStore).Configure(dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(mediaStore), dialogs.WithPrivacyEvaluator(privacyService)), + Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(mediaStore), messageapp.WithPrivacyEvaluator(privacyService)), Channels: channelapp.NewService(channelStore), Files: filesService, LangPack: langPackService, diff --git a/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql b/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql new file mode 100644 index 00000000..60c83739 --- /dev/null +++ b/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql @@ -0,0 +1,26 @@ +DROP INDEX IF EXISTS contacts_personal_photo_idx; + +ALTER TABLE contacts + DROP COLUMN IF EXISTS personal_photo_date, + DROP COLUMN IF EXISTS personal_photo_id; + +DROP INDEX IF EXISTS profile_photos_current_idx; + +ALTER TABLE profile_photos + DROP CONSTRAINT IF EXISTS profile_photos_kind_check; + +ALTER TABLE profile_photos + DROP CONSTRAINT IF EXISTS profile_photos_pkey; + +ALTER TABLE profile_photos + ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, photo_id); + +ALTER TABLE profile_photos + DROP COLUMN IF EXISTS kind; + +CREATE INDEX IF NOT EXISTS profile_photos_current_idx + ON profile_photos (owner_peer_type, owner_peer_id, sort_order DESC) + WHERE active; + +DROP TABLE IF EXISTS account_privacy_rules; + diff --git a/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql b/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql new file mode 100644 index 00000000..0f9f9b4f --- /dev/null +++ b/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql @@ -0,0 +1,39 @@ +-- 0069_privacy_and_profile_photo_kinds: account privacy rules, fallback photos, +-- and viewer-private contact profile photos. + +CREATE TABLE IF NOT EXISTS account_privacy_rules ( + owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + privacy_key TEXT NOT NULL, + rules JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_user_id, privacy_key) +); + +ALTER TABLE profile_photos + ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'profile'; + +ALTER TABLE profile_photos + DROP CONSTRAINT IF EXISTS profile_photos_pkey; + +ALTER TABLE profile_photos + ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, kind, photo_id); + +ALTER TABLE profile_photos + DROP CONSTRAINT IF EXISTS profile_photos_kind_check; + +ALTER TABLE profile_photos + ADD CONSTRAINT profile_photos_kind_check CHECK (kind IN ('profile', 'fallback')); + +DROP INDEX IF EXISTS profile_photos_current_idx; +CREATE INDEX IF NOT EXISTS profile_photos_current_idx + ON profile_photos (owner_peer_type, owner_peer_id, kind, sort_order DESC) + WHERE active; + +ALTER TABLE contacts + ADD COLUMN IF NOT EXISTS personal_photo_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS personal_photo_date INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS contacts_personal_photo_idx + ON contacts (user_id, contact_user_id) + WHERE personal_photo_id <> 0; + diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index fcfff778..551cd43e 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -6,6 +6,7 @@ import ( "strings" "unicode/utf8" + "telesrv/internal/app/userprojection" "telesrv/internal/domain" "telesrv/internal/store" ) @@ -18,10 +19,31 @@ var ( const maxSearchLimit = 50 +type phonePrivacyService interface { + userprojection.PrivacyEvaluator + AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) +} + // Service 提供通讯录查询。 type Service struct { - contacts store.ContactStore - users store.UserStore + contacts store.ContactStore + users store.UserStore + photos userprojection.ProfilePhotoProvider + privacy phonePrivacyService + projector *userprojection.Projector +} + +// Option adjusts optional contacts service dependencies. +type Option func(*Service) + +// WithPhotoProvider enables current profile photo enrichment for returned users. +func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option { + return func(s *Service) { s.photos = p } +} + +// WithPrivacyEvaluator enables viewer-specific privacy projection. +func WithPrivacyEvaluator(p phonePrivacyService) Option { + return func(s *Service) { s.privacy = p } } // NewService 创建 contacts 服务。 @@ -30,9 +52,33 @@ func NewService(contacts store.ContactStore, users ...store.UserStore) *Service if len(users) > 0 { s.users = users[0] } + s.rebuildProjector() return s } +// Configure applies optional dependencies after construction. +func (s *Service) Configure(opts ...Option) *Service { + if s == nil { + return s + } + for _, opt := range opts { + opt(s) + } + s.rebuildProjector() + return s +} + +func (s *Service) rebuildProjector() { + if s == nil { + return + } + s.projector = userprojection.New( + userprojection.WithContactStore(s.contacts), + userprojection.WithPhotoProvider(s.photos), + userprojection.WithPrivacyEvaluator(s.privacy), + ) +} + // GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。 func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) { if s == nil || s.contacts == nil || userID == 0 { @@ -47,6 +93,9 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do return domain.ContactList{}, false, err } } + if err := s.projectContactUsers(ctx, userID, &list); err != nil { + return domain.ContactList{}, false, err + } if hash != 0 && hash == list.Hash { return list, true, nil } @@ -110,7 +159,12 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con if err != nil { return domain.Contact{}, err } - return contact, nil + if input.AddPhonePrivacyException && s.privacy != nil { + if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil { + return domain.Contact{}, err + } + } + return s.projectContact(ctx, userID, contact) } // AcceptContact shares the current user's phone/profile with an existing one-way contact. @@ -151,6 +205,11 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64 if err != nil { return domain.Contact{}, err } + if s.privacy != nil { + if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil { + return domain.Contact{}, err + } + } contact, found, err := s.contacts.Get(ctx, userID, target.ID) if err != nil { return domain.Contact{}, err @@ -158,7 +217,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64 if !found { return domain.Contact{}, ErrContactReqMissing } - return contact, nil + return s.projectContact(ctx, userID, contact) } func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) { @@ -229,7 +288,22 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom if err != nil { return domain.ImportContactsResult{}, err } + if s.privacy != nil { + for _, input := range upserts { + if !input.AddPhonePrivacyException || input.ContactUserID == 0 { + continue + } + if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil { + return domain.ImportContactsResult{}, err + } + } + } out.Contacts = append(out.Contacts, contacts...) + projected := domain.ContactList{Contacts: out.Contacts} + if err := s.projectContactUsers(ctx, userID, &projected); err != nil { + return domain.ImportContactsResult{}, err + } + out.Contacts = projected.Contacts return out, nil } @@ -246,7 +320,11 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit if limit <= 0 || limit > maxSearchLimit { limit = maxSearchLimit } - return s.users.Search(ctx, userID, query, normalizePhone(query), limit) + res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit) + if err != nil { + return domain.UserSearchResult{}, err + } + return s.projectSearchResult(ctx, userID, res) } func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { @@ -270,6 +348,41 @@ func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID i return contact, nil } +func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error) { + if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID || photo.ID == 0 { + return domain.Contact{}, ErrContactIDInvalid + } + contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, photo.ID, date) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactReqMissing + } + return s.projectContact(ctx, userID, contact) +} + +func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error) { + if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { + return domain.Contact{}, ErrContactIDInvalid + } + contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, 0, date) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactReqMissing + } + return s.projectContact(ctx, userID, contact) +} + +func (s *Service) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) == 0 { + return map[int64]domain.ProfilePhotoRef{}, nil + } + return s.contacts.PersonalPhotos(ctx, userID, contactUserIDs) +} + func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) { if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { return domain.PeerSettings{}, nil @@ -282,10 +395,18 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain if err != nil { return domain.PeerSettings{}, err } + shareContact := found && !contact.Mutual + if s.privacy != nil { + peerCanSeePhone, err := s.privacy.CanSee(ctx, userID, peer.ID, domain.PrivacyKeyPhoneNumber) + if err != nil { + return domain.PeerSettings{}, err + } + shareContact = found && !peerCanSeePhone + } return domain.PeerSettings{ AddContact: !found, BlockContact: !blocked, - ShareContact: found && !contact.Mutual, + ShareContact: shareContact, }, nil } @@ -343,6 +464,51 @@ func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]i return ids, false, nil } +func (s *Service) projectContactUsers(ctx context.Context, userID int64, list *domain.ContactList) error { + if s == nil || s.projector == nil || list == nil || len(list.Contacts) == 0 { + return nil + } + users := make([]domain.User, len(list.Contacts)) + for i, contact := range list.Contacts { + users[i] = contact.User + } + projected, err := s.projector.ForViewer(ctx, userID, users) + if err != nil { + return err + } + for i := range list.Contacts { + list.Contacts[i].User = projected[i] + } + return nil +} + +func (s *Service) projectContact(ctx context.Context, userID int64, contact domain.Contact) (domain.Contact, error) { + list := domain.ContactList{Contacts: []domain.Contact{contact}} + if err := s.projectContactUsers(ctx, userID, &list); err != nil { + return domain.Contact{}, err + } + if len(list.Contacts) == 0 { + return domain.Contact{}, nil + } + return list.Contacts[0], nil +} + +func (s *Service) projectSearchResult(ctx context.Context, userID int64, res domain.UserSearchResult) (domain.UserSearchResult, error) { + if s == nil || s.projector == nil { + return res, nil + } + var err error + res.MyResults, err = s.projector.ForViewer(ctx, userID, res.MyResults) + if err != nil { + return domain.UserSearchResult{}, err + } + res.Results, err = s.projector.ForViewer(ctx, userID, res.Results) + if err != nil { + return domain.UserSearchResult{}, err + } + return res, nil +} + func normalizePhone(phone string) string { if !utf8.ValidString(phone) { return "" diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index 29128073..f3b5409a 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -21,7 +21,9 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) { if err != nil { t.Fatalf("create target: %v", err) } - svc := NewService(contactsStore, users) + svc := NewService(contactsStore, users).Configure(WithPhotoProvider(contactProfilePhotos{ + target.ID: {PhotoID: 9400, DCID: 2}, + })) res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{ {ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"}, @@ -42,6 +44,49 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) { if res.Contacts[0].FirstName != "Alice Final" { t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName) } + if res.Contacts[0].User.PhotoID != 9400 || res.Contacts[0].User.PhotoDCID != 2 { + t.Fatalf("imported contact photo = id %d dc %d, want 9400/2", res.Contacts[0].User.PhotoID, res.Contacts[0].User.PhotoDCID) + } +} + +func TestGetContactsProjectsCurrentProfilePhoto(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: "Alice"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ + ContactUserID: target.ID, + Phone: "1111", + FirstName: "Alice", + LastName: "Saved", + }); err != nil { + t.Fatalf("upsert contact: %v", err) + } + svc := NewService(contactsStore, users).Configure(WithPhotoProvider(contactProfilePhotos{ + target.ID: {PhotoID: 9401, DCID: 2, Stripped: []byte{11, 12}}, + })) + + list, notModified, err := svc.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("GetContacts: %v", err) + } + if notModified || len(list.Contacts) != 1 { + t.Fatalf("contacts notModified=%v len=%d, want one full contact", notModified, len(list.Contacts)) + } + contact := list.Contacts[0] + if contact.User.PhotoID != 9401 || contact.User.PhotoDCID != 2 || string(contact.User.PhotoStripped) != string([]byte{11, 12}) { + t.Fatalf("contact user photo = id %d dc %d stripped %v, want 9401/2/[11 12]", contact.User.PhotoID, contact.User.PhotoDCID, contact.User.PhotoStripped) + } + if contact.User.FirstName != "Alice" || contact.User.LastName != "Saved" || contact.User.Phone != "1111" { + t.Fatalf("contact user projection = %+v, want contact name/phone", contact.User) + } } func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) { @@ -130,3 +175,15 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) { t.Fatalf("AcceptContact without contact err = %v, want ErrContactReqMissing", err) } } + +type contactProfilePhotos map[int64]domain.ProfilePhotoRef + +func (p contactProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(ids)) + for _, id := range ids { + if ref, ok := p[id]; ok { + out[id] = ref + } + } + return out, nil +} diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go index 2c4a4727..cfe3a8e4 100644 --- a/internal/app/dialogs/service.go +++ b/internal/app/dialogs/service.go @@ -8,14 +8,37 @@ import ( "sort" "unicode/utf8" + "telesrv/internal/app/userprojection" "telesrv/internal/domain" "telesrv/internal/store" ) // Service 提供会话列表查询。 type Service struct { - dialogs store.DialogStore - channels store.ChannelStore + dialogs store.DialogStore + channels store.ChannelStore + contacts store.ContactStore + photos userprojection.ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + projector *userprojection.Projector +} + +// Option adjusts optional dialogs service dependencies. +type Option func(*Service) + +// WithContactStore enables viewer-specific user projection for dialog users. +func WithContactStore(c store.ContactStore) Option { + return func(s *Service) { s.contacts = c } +} + +// WithPhotoProvider enables current profile photo enrichment for dialog users. +func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option { + return func(s *Service) { s.photos = p } +} + +// WithPrivacyEvaluator enables viewer-specific privacy projection for dialog users. +func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { + return func(s *Service) { s.privacy = p } } // NewService 创建 dialogs 服务。 @@ -24,9 +47,33 @@ func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Serv if len(channels) > 0 { s.channels = channels[0] } + s.rebuildProjector() return s } +// Configure applies optional dependencies after construction. +func (s *Service) Configure(opts ...Option) *Service { + if s == nil { + return s + } + for _, opt := range opts { + opt(s) + } + s.rebuildProjector() + return s +} + +func (s *Service) rebuildProjector() { + if s == nil { + return + } + s.projector = userprojection.New( + userprojection.WithContactStore(s.contacts), + userprojection.WithPhotoProvider(s.photos), + userprojection.WithPrivacyEvaluator(s.privacy), + ) +} + // GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。 func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { if s == nil || userID == 0 { @@ -81,6 +128,9 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di if err := s.attachDrafts(ctx, userID, &out); err != nil { return domain.DialogList{}, err } + if err := s.projectDialogUsers(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } return out, nil } @@ -124,6 +174,9 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma if err := s.attachDrafts(ctx, userID, &out); err != nil { return domain.DialogList{}, err } + if err := s.projectDialogUsers(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } return out, nil } @@ -439,6 +492,18 @@ func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.D return nil } +func (s *Service) projectDialogUsers(ctx context.Context, userID int64, list *domain.DialogList) error { + if s == nil || s.projector == nil || list == nil || len(list.Users) == 0 { + return nil + } + users, err := s.projector.ForViewer(ctx, userID, list.Users) + if err != nil { + return err + } + list.Users = users + return nil +} + func validateDraft(draft domain.DialogDraft) error { if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil { return err diff --git a/internal/app/dialogs/service_test.go b/internal/app/dialogs/service_test.go index 838cc0a9..16085d53 100644 --- a/internal/app/dialogs/service_test.go +++ b/internal/app/dialogs/service_test.go @@ -62,6 +62,64 @@ func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) { } } +func TestGetDialogsProjectsUsersWithCurrentProfilePhoto(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + const peerID int64 = 1002 + dialogStore := memory.NewDialogStore() + if err := dialogStore.SaveList(ctx, ownerID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + TopMessage: 1, + TopMessageDate: 20, + }}, + Users: []domain.User{{ + ID: peerID, + AccessHash: 22, + Phone: "15550000002", + FirstName: "Alice A", + }}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + contacts := memory.NewContactStore() + if _, err := contacts.Upsert(ctx, ownerID, domain.ContactInput{ + ContactUserID: peerID, + Phone: "1111", + FirstName: "Alice", + LastName: "Saved", + }); err != nil { + t.Fatalf("upsert contact: %v", err) + } + dialogs := NewService(dialogStore).Configure( + WithContactStore(contacts), + WithPhotoProvider(dialogProfilePhotos{ + peerID: {PhotoID: 9201, DCID: 2, Stripped: []byte{7, 8}}, + }), + ) + + list, err := dialogs.GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + peer := findDialogUser(t, list.Users, peerID) + if peer.PhotoID != 9201 || peer.PhotoDCID != 2 || string(peer.PhotoStripped) != string([]byte{7, 8}) { + t.Fatalf("dialog user photo = id %d dc %d stripped %v, want 9201/2/[7 8]", peer.PhotoID, peer.PhotoDCID, peer.PhotoStripped) + } + if !peer.Contact || peer.FirstName != "Alice" || peer.LastName != "Saved" || peer.Phone != "1111" { + t.Fatalf("dialog user projection = %+v, want contact view", peer) + } + + peerList, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{{Type: domain.PeerTypeUser, ID: peerID}}) + if err != nil { + t.Fatalf("GetPeerDialogs: %v", err) + } + peer = findDialogUser(t, peerList.Users, peerID) + if peer.PhotoID != 9201 || peer.PhotoDCID != 2 { + t.Fatalf("peer dialog user photo = id %d dc %d, want 9201/2", peer.PhotoID, peer.PhotoDCID) + } +} + func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { ctx := context.Background() channelStore := memory.NewChannelStore() @@ -280,3 +338,26 @@ func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) do t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs) return domain.Dialog{} } + +func findDialogUser(t *testing.T, users []domain.User, userID int64) domain.User { + t.Helper() + for _, user := range users { + if user.ID == userID { + return user + } + } + t.Fatalf("user %d not found in %+v", userID, users) + return domain.User{} +} + +type dialogProfilePhotos map[int64]domain.ProfilePhotoRef + +func (p dialogProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(ids)) + for _, id := range ids { + if ref, ok := p[id]; ok { + out[id] = ref + } + } + return out, nil +} diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index bd6e5b5b..c227c6b6 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -19,6 +19,11 @@ import ( // UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。 func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) { + return s.UploadProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, file, date) +} + +// UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind. +func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) { data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Photo{}, err @@ -33,7 +38,7 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT if err != nil { return domain.Photo{}, err } - if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil { + if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photo.ID, date); err != nil { return domain.Photo{}, err } return photo, nil @@ -133,6 +138,11 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo // SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。 func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) { + return s.SetCurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date) +} + +// SetCurrentProfilePhotoKind sets an existing photo as current for profile or fallback history. +func (s *Service) SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error) { photo, ok, err := s.media.GetPhoto(ctx, photoID) if err != nil || !ok { return domain.Photo{}, ok, err @@ -140,7 +150,7 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P if date == 0 { date = int(time.Now().Unix()) } - if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil { + if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photoID, date); err != nil { return domain.Photo{}, false, err } return photo, true, nil @@ -148,7 +158,12 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P // CurrentProfilePhoto 返回某 owner 的当前头像 Photo。 func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) { - id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID) + return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile) +} + +// CurrentProfilePhotoKind returns the current profile/fallback photo. +func (s *Service) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) { + id, ok, err := s.media.CurrentProfilePhotoKind(ctx, ownerType, ownerID, kind) if err != nil || !ok { return domain.Photo{}, ok, err } @@ -157,7 +172,12 @@ func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.Peer // GetProfilePhotos 返回 owner 的头像历史(最新在前)。 func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) { - ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, offset, limit, maxID) + return s.GetProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID) +} + +// GetProfilePhotosKind returns profile/fallback photo history. +func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, offset, limit, maxID) if err != nil { return nil, 0, err } @@ -174,7 +194,12 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp // DeleteProfilePhotos 停用指定头像,返回成功停用数量。 func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) { - deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs) + return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs) +} + +// DeleteProfilePhotosKind disables profile/fallback photos of the selected kind. +func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error) { + deleted, err := s.media.DeleteProfilePhotosKind(ctx, ownerType, ownerID, kind, photoIDs) if err != nil { return 0, err } diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index 8c408f61..01aab05c 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -159,18 +159,33 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error { return nil } +func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error { + return nil +} func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) { return 0, false, nil } +func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) { + return 0, false, nil +} func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) { return map[int64]domain.ProfilePhotoRef{}, nil } +func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + return map[int64]domain.ProfilePhotoRef{}, nil +} func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) { return nil, 0, nil } +func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) { + return nil, 0, nil +} func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) { return nil, nil } +func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ []int64) ([]int64, error) { + return nil, nil +} func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { seedDir := t.TempDir() diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 4c511c91..33ea90cc 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -10,9 +10,12 @@ import ( // Service 提供消息历史、搜索与已读业务。 type Service struct { - messages store.MessageStore - dialogs store.DialogStore - contacts store.ContactStore + messages store.MessageStore + dialogs store.DialogStore + contacts store.ContactStore + photos userprojection.ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + projector *userprojection.Projector } // Option adjusts optional message service dependencies. @@ -23,12 +26,27 @@ func WithContactStore(c store.ContactStore) Option { return func(s *Service) { s.contacts = c } } +// WithPhotoProvider enables current profile photo enrichment for message users. +func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option { + return func(s *Service) { s.photos = p } +} + +// WithPrivacyEvaluator enables viewer-specific privacy projection for message users. +func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { + return func(s *Service) { s.privacy = p } +} + // NewService 创建 messages 服务。 func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service { s := &Service{messages: messages, dialogs: dialogs} for _, opt := range opts { opt(s) } + s.projector = userprojection.New( + userprojection.WithContactStore(s.contacts), + userprojection.WithPhotoProvider(s.photos), + userprojection.WithPrivacyEvaluator(s.privacy), + ) return s } @@ -202,7 +220,10 @@ func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageF } func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list domain.MessageList) (domain.MessageList, error) { - users, err := userprojection.ForViewer(ctx, s.contacts, userID, list.Users) + if s == nil || s.projector == nil { + return list, nil + } + users, err := s.projector.ForViewer(ctx, userID, list.Users) if err != nil { return domain.MessageList{}, err } diff --git a/internal/app/messages/service_test.go b/internal/app/messages/service_test.go index 8737d94d..efa53310 100644 --- a/internal/app/messages/service_test.go +++ b/internal/app/messages/service_test.go @@ -29,7 +29,10 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) { {ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"}, }, }} - svc := NewService(store, nil, WithContactStore(contacts)) + svc := NewService(store, nil, WithContactStore(contacts), WithPhotoProvider(messageProfilePhotos{ + friendID: {PhotoID: 9101, DCID: 2, Stripped: []byte{5, 6}}, + strangerID: {PhotoID: 9102, DCID: 4}, + })) list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10}) if err != nil { @@ -39,10 +42,16 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) { if !friend.Contact || friend.FirstName != "Remark" || friend.LastName != "Friend" || friend.Phone != "15550000002" { t.Fatalf("friend projection = %+v, want contact remark and phone", friend) } + if friend.PhotoID != 9101 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{5, 6}) { + t.Fatalf("friend photo = id %d dc %d stripped %v, want 9101/2/[5 6]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped) + } stranger := findUser(t, list.Users, strangerID) if stranger.Contact || stranger.Phone != "" || stranger.FirstName != "Stranger" { t.Fatalf("stranger projection = %+v, want non-contact with hidden phone", stranger) } + if stranger.PhotoID != 9102 || stranger.PhotoDCID != 4 { + t.Fatalf("stranger photo = id %d dc %d, want 9102/4", stranger.PhotoID, stranger.PhotoDCID) + } self := findUser(t, list.Users, ownerID) if self.Phone != "15550000001" { t.Fatalf("self phone = %q, want preserved", self.Phone) @@ -64,6 +73,18 @@ type projectionMessageStore struct { list domain.MessageList } +type messageProfilePhotos map[int64]domain.ProfilePhotoRef + +func (p messageProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(ids)) + for _, id := range ids { + if ref, ok := p[id]; ok { + out[id] = ref + } + } + return out, nil +} + func (s projectionMessageStore) Create(context.Context, domain.Message) (domain.Message, error) { return domain.Message{}, nil } diff --git a/internal/app/privacy/service.go b/internal/app/privacy/service.go new file mode 100644 index 00000000..a57e62c9 --- /dev/null +++ b/internal/app/privacy/service.go @@ -0,0 +1,268 @@ +package privacy + +import ( + "context" + "slices" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const maxPrivacyRules = 100 + +// Service owns account privacy rules and viewer-specific evaluation. +type Service struct { + rules store.PrivacyStore + contacts store.ContactStore +} + +func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service { + return &Service{rules: rules, contacts: contacts} +} + +func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) { + if !ValidKey(key) { + return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid + } + if s == nil || s.rules == nil { + return defaultRules(ownerUserID, key), nil + } + rules, ok, err := s.rules.GetPrivacyRules(ctx, ownerUserID, key) + if err != nil { + return domain.PrivacyRules{}, err + } + if !ok { + return defaultRules(ownerUserID, key), nil + } + rules.OwnerUserID = ownerUserID + rules.Key = key + if len(rules.Rules) == 0 { + rules.Rules = domain.DefaultPrivacyRules(key) + } + return cloneRules(rules), nil +} + +func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) { + if !ValidKey(key) { + return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid + } + if len(rules) == 0 { + rules = domain.DefaultPrivacyRules(key) + } + if err := validateRules(rules); err != nil { + return domain.PrivacyRules{}, err + } + out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)} + if s != nil && s.rules != nil { + if err := s.rules.SetPrivacyRules(ctx, out); err != nil { + return domain.PrivacyRules{}, err + } + } + return out, nil +} + +func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) { + if targetUserID == 0 { + return domain.PrivacyRules{}, false, domain.ErrPrivacyRuleInvalid + } + rules, err := s.GetRules(ctx, ownerUserID, key) + if err != nil { + return domain.PrivacyRules{}, false, err + } + for i := range rules.Rules { + if rules.Rules[i].Kind != domain.PrivacyRuleAllowUsers { + continue + } + if slices.Contains(rules.Rules[i].UserIDs, targetUserID) { + return rules, false, nil + } + rules.Rules[i].UserIDs = append(rules.Rules[i].UserIDs, targetUserID) + next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules) + return next, true, err + } + rules.Rules = append([]domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowUsers, UserIDs: []int64{targetUserID}}}, rules.Rules...) + next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules) + return next, true, err +} + +func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) { + if ownerUserID == 0 || viewerUserID == 0 { + return false, nil + } + if ownerUserID == viewerUserID { + return true, nil + } + rules, err := s.GetRules(ctx, ownerUserID, key) + if err != nil { + return false, err + } + evalCtx := domain.PrivacyContext{ + OwnerUserID: ownerUserID, + ViewerUserID: viewerUserID, + } + if s != nil && s.contacts != nil { + if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil { + return false, err + } else if found { + evalCtx.ViewerIsContact = true + } + } + return Evaluate(rules, evalCtx), nil +} + +func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool { + if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID { + return true + } + if len(rules.Rules) == 0 { + rules.Rules = domain.DefaultPrivacyRules(rules.Key) + } + for _, rule := range rules.Rules { + if explicitDisallowMatches(rule, ctx) { + return false + } + } + for _, rule := range rules.Rules { + if explicitAllowMatches(rule, ctx) { + return true + } + } + for _, rule := range rules.Rules { + switch rule.Kind { + case domain.PrivacyRuleDisallowContacts: + if ctx.ViewerIsContact { + return false + } + case domain.PrivacyRuleAllowContacts: + if ctx.ViewerIsContact { + return true + } + } + } + for _, rule := range rules.Rules { + switch rule.Kind { + case domain.PrivacyRuleDisallowAll: + return false + case domain.PrivacyRuleAllowAll: + return true + } + } + return false +} + +func ValidKey(key domain.PrivacyKey) bool { + switch key { + case domain.PrivacyKeyStatusTimestamp, + domain.PrivacyKeyChatInvite, + domain.PrivacyKeyPhoneCall, + domain.PrivacyKeyPhoneP2P, + domain.PrivacyKeyForwards, + domain.PrivacyKeyProfilePhoto, + domain.PrivacyKeyPhoneNumber, + domain.PrivacyKeyAddedByPhone, + domain.PrivacyKeyVoiceMessages, + domain.PrivacyKeyAbout, + domain.PrivacyKeyBirthday, + domain.PrivacyKeyStarGiftsAutoSave, + domain.PrivacyKeyNoPaidMessages, + domain.PrivacyKeySavedMusic: + return true + default: + return false + } +} + +func validateRules(rules []domain.PrivacyRule) error { + if len(rules) > maxPrivacyRules { + return domain.ErrPrivacyRuleInvalid + } + for _, rule := range rules { + switch rule.Kind { + case domain.PrivacyRuleAllowContacts, + domain.PrivacyRuleAllowAll, + domain.PrivacyRuleAllowUsers, + domain.PrivacyRuleDisallowContacts, + domain.PrivacyRuleDisallowAll, + domain.PrivacyRuleDisallowUsers, + domain.PrivacyRuleAllowChatParticipants, + domain.PrivacyRuleDisallowChatParticipants, + domain.PrivacyRuleAllowCloseFriends, + domain.PrivacyRuleAllowPremium, + domain.PrivacyRuleAllowBots, + domain.PrivacyRuleDisallowBots: + default: + return domain.ErrPrivacyRuleInvalid + } + } + return nil +} + +func explicitDisallowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool { + switch rule.Kind { + case domain.PrivacyRuleDisallowUsers: + return slices.Contains(rule.UserIDs, ctx.ViewerUserID) + case domain.PrivacyRuleDisallowChatParticipants: + return intersects(rule.ChatIDs, ctx.SharedChatIDs) + case domain.PrivacyRuleDisallowBots: + return ctx.ViewerIsBot + default: + return false + } +} + +func explicitAllowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool { + switch rule.Kind { + case domain.PrivacyRuleAllowUsers: + return slices.Contains(rule.UserIDs, ctx.ViewerUserID) + case domain.PrivacyRuleAllowChatParticipants: + return intersects(rule.ChatIDs, ctx.SharedChatIDs) + case domain.PrivacyRuleAllowCloseFriends: + return ctx.ViewerCloseFriend + case domain.PrivacyRuleAllowPremium: + return ctx.ViewerIsPremium + case domain.PrivacyRuleAllowBots: + return ctx.ViewerIsBot + default: + return false + } +} + +func intersects(a, b []int64) bool { + if len(a) == 0 || len(b) == 0 { + return false + } + set := make(map[int64]struct{}, len(a)) + for _, id := range a { + set[id] = struct{}{} + } + for _, id := range b { + if _, ok := set[id]; ok { + return true + } + } + return false +} + +func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules { + return domain.PrivacyRules{ + OwnerUserID: ownerUserID, + Key: key, + Rules: domain.DefaultPrivacyRules(key), + } +} + +func cloneRules(in domain.PrivacyRules) domain.PrivacyRules { + out := in + out.Rules = cloneRuleSlice(in.Rules) + return out +} + +func cloneRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule { + out := make([]domain.PrivacyRule, len(in)) + for i, rule := range in { + out[i] = rule + out[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} diff --git a/internal/app/privacy/service_test.go b/internal/app/privacy/service_test.go new file mode 100644 index 00000000..aaef5629 --- /dev/null +++ b/internal/app/privacy/service_test.go @@ -0,0 +1,75 @@ +package privacy + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestDefaultPrivacyRules(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()) + phone, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil { + t.Fatalf("phone rules: %v", err) + } + if len(phone.Rules) != 1 || phone.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("phone default = %+v, want disallow all", phone.Rules) + } + birthday, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyBirthday) + if err != nil { + t.Fatalf("birthday rules: %v", err) + } + if len(birthday.Rules) != 1 || birthday.Rules[0].Kind != domain.PrivacyRuleAllowContacts { + t.Fatalf("birthday default = %+v, want allow contacts", birthday.Rules) + } + profile, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyProfilePhoto) + if err != nil { + t.Fatalf("profile rules: %v", err) + } + if len(profile.Rules) != 1 || profile.Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("profile default = %+v, want allow all", profile.Rules) + } +} + +func TestAddAllowUserOverridesDisallowAll(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()) + if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set rules: %v", err) + } + allowed, err := svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber) + if err != nil { + t.Fatalf("can see before: %v", err) + } + if allowed { + t.Fatal("viewer should not see phone before exception") + } + if _, changed, err := svc.AddAllowUser(ctx, 1001, domain.PrivacyKeyPhoneNumber, 1002); err != nil { + t.Fatalf("add allow: %v", err) + } else if !changed { + t.Fatal("first add allow should report changed") + } + allowed, err = svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber) + if err != nil { + t.Fatalf("can see after: %v", err) + } + if !allowed { + t.Fatal("viewer should see phone after allow-user exception") + } +} + +func TestExplicitDisallowUserWins(t *testing.T) { + rules := domain.PrivacyRules{ + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowAll}, + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{1002}}, + }, + } + if Evaluate(rules, domain.PrivacyContext{OwnerUserID: 1001, ViewerUserID: 1002}) { + t.Fatal("explicit disallow user should win over allow all") + } +} diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 300f4b24..76eb0196 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -7,6 +7,111 @@ import ( "telesrv/internal/store" ) +// ProfilePhotoProvider returns current profile photos for a batch of owners. +type ProfilePhotoProvider interface { + CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) +} + +// ProfilePhotoKindProvider returns current profile/fallback photos for a batch of owners. +type ProfilePhotoKindProvider interface { + CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) +} + +// PrivacyEvaluator answers viewer-specific visibility for one user privacy key. +type PrivacyEvaluator interface { + CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) +} + +// Projector builds the current viewer's user view for RPC response payloads. +// It intentionally stays in app/domain types; tg.* conversion remains in rpc. +type Projector struct { + contacts store.ContactStore + photos ProfilePhotoProvider + privacy PrivacyEvaluator +} + +// Option configures a Projector. +type Option func(*Projector) + +// WithContactStore enables viewer-specific contact name/phone projection. +func WithContactStore(c store.ContactStore) Option { + return func(p *Projector) { p.contacts = c } +} + +// WithPhotoProvider enables current profile photo enrichment. +func WithPhotoProvider(photos ProfilePhotoProvider) Option { + return func(p *Projector) { p.photos = photos } +} + +// WithPrivacyEvaluator enables profile/photo/status privacy projection. +func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option { + return func(p *Projector) { p.privacy = privacy } +} + +// New creates a user projector. +func New(opts ...Option) *Projector { + p := &Projector{} + for _, opt := range opts { + opt(p) + } + return p +} + +// ForViewer applies both current profile photos and owner-specific contact view. +func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) { + if p == nil { + return users, nil + } + return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users) +} + +// One applies ForViewer to a single user. +func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) { + if p == nil { + return user, nil + } + projected, err := p.ForViewer(ctx, viewerUserID, []domain.User{user}) + if err != nil || len(projected) == 0 { + return domain.User{}, err + } + return projected[0], nil +} + +// WithProfilePhotos enriches users with their current avatar from profile photo storage. +// The lookup is best-effort: a storage error keeps the original user list. +func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User { + if photos == nil || len(users) == 0 { + return users + } + ids := make([]int64, 0, len(users)) + seen := make(map[int64]struct{}, len(users)) + for _, u := range users { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + ids = append(ids, u.ID) + } + if len(ids) == 0 { + return users + } + refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) + if err != nil || len(refs) == 0 { + return users + } + out := make([]domain.User, len(users)) + copy(out, users) + for i := range out { + if ref, ok := refs[out[i].ID]; ok { + applyPhotoRef(&out[i], ref) + } + } + return out +} + // 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. @@ -45,6 +150,74 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u return projected[0], nil } +func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) { + if len(users) == 0 { + return users, nil + } + out := make([]domain.User, len(users)) + copy(out, users) + ids := uniqueUserIDs(out) + profileRefs := map[int64]domain.ProfilePhotoRef{} + fallbackRefs := map[int64]domain.ProfilePhotoRef{} + personalRefs := map[int64]domain.ProfilePhotoRef{} + if photos != nil && len(ids) > 0 { + if kindPhotos, ok := photos.(ProfilePhotoKindProvider); ok { + refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile) + if err != nil { + return nil, err + } + profileRefs = refs + refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback) + if err != nil { + return nil, err + } + fallbackRefs = refs + } else { + refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) + if err != nil { + return nil, err + } + profileRefs = refs + } + } + var contactsByID map[int64]domain.Contact + if contacts != nil && viewerUserID != 0 && len(ids) > 0 { + var err error + contactsByID, err = contacts.GetMany(ctx, viewerUserID, ids) + if err != nil { + return nil, err + } + personalRefs, err = contacts.PersonalPhotos(ctx, viewerUserID, ids) + if err != nil { + return nil, err + } + } + cache := make(map[int64]domain.User, len(out)) + for i := range out { + u := out[i] + if u.ID == 0 { + continue + } + if projected, ok := cache[u.ID]; ok { + out[i] = projected + continue + } + projected := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewerUserID) + if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID { + contact, found := contactsByID[u.ID] + projected = applyContactProjection(projected, contact, found) + var err error + projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, profileRefs, fallbackRefs, personalRefs) + if err != nil { + return nil, err + } + } + cache[u.ID] = projected + out[i] = projected + } + return out, nil +} + func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) { contact, found, err := contacts.Get(ctx, viewerUserID, user.ID) if err != nil { @@ -73,3 +246,130 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i } return projected, nil } + +func uniqueUserIDs(users []domain.User) []int64 { + seen := make(map[int64]struct{}, len(users)) + ids := make([]int64, 0, len(users)) + for _, user := range users { + if user.ID == 0 { + continue + } + if _, ok := seen[user.ID]; ok { + continue + } + seen[user.ID] = struct{}{} + ids = append(ids, user.ID) + } + return ids +} + +func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User { + if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) { + return user + } + clearPhoto(&user) + if viewerUserID != 0 && user.ID != viewerUserID { + if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 { + ref.Personal = true + applyPhotoRef(&user, ref) + return user + } + } + if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 { + applyPhotoRef(&user, ref) + return user + } + if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 { + applyPhotoRef(&user, ref) + } + return user +} + +func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User { + if !found { + user.Phone = "" + user.Contact = false + user.Mutual = false + return user + } + user.Contact = true + user.Mutual = contact.Mutual || contact.User.Mutual + if contact.User.Phone != "" { + user.Phone = contact.User.Phone + } else { + user.Phone = contact.Phone + } + if contact.User.FirstName != "" || contact.User.LastName != "" { + user.FirstName = contact.User.FirstName + user.LastName = contact.User.LastName + } else if contact.FirstName != "" || contact.LastName != "" { + user.FirstName = contact.FirstName + user.LastName = contact.LastName + } + return user +} + +func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { + if privacy == nil { + return user, nil + } + phoneAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyPhoneNumber) + if err != nil { + return domain.User{}, err + } + if !phoneAllowed && !isContact { + user.Phone = "" + } + statusAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyStatusTimestamp) + if err != nil { + return domain.User{}, err + } + if !statusAllowed { + user.LastSeenAt = 0 + if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline { + user.Status = domain.UserStatus{Kind: domain.UserStatusRecently} + } + } + if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 { + ref.Personal = true + applyPhotoRef(&user, ref) + return user, nil + } + if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) && user.PhotoID == 0 { + return user, nil + } + profileAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyProfilePhoto) + if err != nil { + return domain.User{}, err + } + if profileAllowed { + if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 { + applyPhotoRef(&user, ref) + return user, nil + } + } + if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 { + applyPhotoRef(&user, ref) + return user, nil + } + clearPhoto(&user) + return user, nil +} + +func hasPhotoLookups(profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) bool { + return len(profileRefs) != 0 || len(fallbackRefs) != 0 || len(personalRefs) != 0 +} + +func applyPhotoRef(user *domain.User, ref domain.ProfilePhotoRef) { + user.PhotoID = ref.PhotoID + user.PhotoDCID = ref.DCID + user.PhotoStripped = append([]byte(nil), ref.Stripped...) + user.PhotoPersonal = ref.Personal +} + +func clearPhoto(user *domain.User) { + user.PhotoID = 0 + user.PhotoDCID = 0 + user.PhotoStripped = nil + user.PhotoPersonal = false +} diff --git a/internal/app/userprojection/projection_test.go b/internal/app/userprojection/projection_test.go new file mode 100644 index 00000000..d9fe06b1 --- /dev/null +++ b/internal/app/userprojection/projection_test.go @@ -0,0 +1,148 @@ +package userprojection + +import ( + "context" + "testing" + + privacyapp "telesrv/internal/app/privacy" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) { + ctx := context.Background() + const viewerID int64 = 1001 + const friendID int64 = 1002 + const strangerID int64 = 1003 + contacts := memory.NewContactStore() + if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{ + ContactUserID: friendID, + Phone: "1111", + FirstName: "Alice", + LastName: "Contact", + }); err != nil { + t.Fatalf("upsert contact: %v", err) + } + projector := New( + WithContactStore(contacts), + WithPhotoProvider(fakeProfilePhotos{ + profile: map[int64]domain.ProfilePhotoRef{ + friendID: {PhotoID: 9001, DCID: 2, Stripped: []byte{1, 2}}, + strangerID: {PhotoID: 9002, DCID: 3, Stripped: []byte{3, 4}}, + }, + }), + ) + + users, err := projector.ForViewer(ctx, viewerID, []domain.User{ + {ID: viewerID, Phone: "15550000001", FirstName: "Owner"}, + {ID: friendID, AccessHash: 22, Phone: "15550000002", FirstName: "Public", LastName: "Name"}, + {ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"}, + }) + if err != nil { + t.Fatalf("ForViewer: %v", err) + } + + friend := projectionUser(t, users, friendID) + if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact { + t.Fatalf("friend projection = %+v, want contact name/phone", friend) + } + if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) { + t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped) + } + stranger := projectionUser(t, users, strangerID) + if stranger.Phone != "" || stranger.Contact { + t.Fatalf("stranger projection = %+v, want hidden phone and non-contact", stranger) + } + if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 { + t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID) + } +} + +func TestProjectorPersonalPhotoWinsOverProfile(t *testing.T) { + ctx := context.Background() + const viewerID int64 = 2001 + const friendID int64 = 2002 + contacts := memory.NewContactStore() + if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{ContactUserID: friendID, FirstName: "Friend"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + if _, _, err := contacts.SetPersonalPhoto(ctx, viewerID, friendID, 9100, 100); err != nil { + t.Fatalf("set personal photo: %v", err) + } + projector := New( + WithContactStore(contacts), + WithPhotoProvider(fakeProfilePhotos{ + profile: map[int64]domain.ProfilePhotoRef{friendID: {PhotoID: 9001, DCID: 2}}, + }), + ) + users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: friendID, FirstName: "Public"}}) + if err != nil { + t.Fatalf("ForViewer: %v", err) + } + friend := projectionUser(t, users, friendID) + if friend.PhotoID != 9100 || !friend.PhotoPersonal { + t.Fatalf("friend photo = id %d personal %v, want personal 9100", friend.PhotoID, friend.PhotoPersonal) + } +} + +func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) { + ctx := context.Background() + const viewerID int64 = 3001 + const ownerID int64 = 3002 + contacts := memory.NewContactStore() + rules := memory.NewPrivacyStore() + privacy := privacyapp.NewService(rules, contacts) + if _, err := privacy.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set privacy: %v", err) + } + projector := New( + WithContactStore(contacts), + WithPrivacyEvaluator(privacy), + WithPhotoProvider(fakeProfilePhotos{ + profile: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9001, DCID: 2}}, + fallback: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9002, DCID: 3}}, + }), + ) + users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: ownerID, Phone: "15550003002", FirstName: "Owner"}}) + if err != nil { + t.Fatalf("ForViewer: %v", err) + } + owner := projectionUser(t, users, ownerID) + if owner.PhotoID != 9002 || owner.PhotoDCID != 3 || owner.Phone != "" { + t.Fatalf("owner projection = %+v, want fallback photo and hidden phone", owner) + } +} + +func projectionUser(t *testing.T, users []domain.User, id int64) domain.User { + t.Helper() + for _, user := range users { + if user.ID == id { + return user + } + } + t.Fatalf("user %d not found in %+v", id, users) + return domain.User{} +} + +type fakeProfilePhotos struct { + profile map[int64]domain.ProfilePhotoRef + fallback map[int64]domain.ProfilePhotoRef +} + +func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { + return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile) +} + +func (p fakeProfilePhotos) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + source := p.profile + if kind == domain.ProfilePhotoKindFallback { + source = p.fallback + } + out := make(map[int64]domain.ProfilePhotoRef, len(ids)) + for _, id := range ids { + if ref, ok := source[id]; ok { + out[id] = ref + } + } + return out, nil +} diff --git a/internal/app/users/service.go b/internal/app/users/service.go index a2c8238e..ec618a4f 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -15,15 +15,15 @@ import ( var ErrNotAuthorized = errors.New("not authorized") // ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。 -type ProfilePhotoProvider interface { - CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) -} +type ProfilePhotoProvider = userprojection.ProfilePhotoProvider // Service 提供用户查询。 type Service struct { - users store.UserStore - contacts store.ContactStore - photos ProfilePhotoProvider + users store.UserStore + contacts store.ContactStore + photos ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + projector *userprojection.Projector } // Option 调整用户服务可选依赖。 @@ -39,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option { return func(s *Service) { s.contacts = c } } +// WithPrivacyEvaluator enables viewer-specific privacy projection. +func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { + return func(s *Service) { s.privacy = p } +} + const ( minUsernameLen = 5 maxUsernameLen = 32 @@ -53,6 +58,11 @@ func NewService(users store.UserStore, opts ...Option) *Service { for _, opt := range opts { opt(s) } + s.projector = userprojection.New( + userprojection.WithContactStore(s.contacts), + userprojection.WithPhotoProvider(s.photos), + userprojection.WithPrivacyEvaluator(s.privacy), + ) return s } @@ -77,7 +87,7 @@ func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) { if err != nil { return domain.User{}, err } - return s.enrichOne(ctx, u), nil + return s.projectOne(ctx, userID, u) } // ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。 @@ -92,8 +102,7 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain if !found { return u, false, nil } - u = s.enrichOne(ctx, u) - u, err = userprojection.One(ctx, s.contacts, currentUserID, u) + u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err } @@ -127,38 +136,7 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6 if err != nil { return nil, err } - users = s.enrich(ctx, users) - return userprojection.ForViewer(ctx, s.contacts, currentUserID, users) -} - -// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。 -func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User { - if s.photos == nil || len(users) == 0 { - return users - } - ids := make([]int64, 0, len(users)) - for _, u := range users { - if u.ID != 0 { - ids = append(ids, u.ID) - } - } - refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) - if err != nil { - return users - } - for i := range users { - if ref, ok := refs[users[i].ID]; ok { - users[i].PhotoID = ref.PhotoID - users[i].PhotoDCID = ref.DCID - users[i].PhotoStripped = ref.Stripped - } - } - return users -} - -func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User { - enriched := s.enrich(ctx, []domain.User{u}) - return enriched[0] + return s.projectUsers(ctx, currentUserID, users) } // CheckUsername 校验当前用户是否可以占用 username。 @@ -261,8 +239,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user if err != nil || !found { return u, found, err } - u = s.enrichOne(ctx, u) - u, err = userprojection.One(ctx, s.contacts, currentUserID, u) + u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err } @@ -282,14 +259,27 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s if err != nil || !found { return u, found, err } - u = s.enrichOne(ctx, u) - u, err = userprojection.One(ctx, s.contacts, currentUserID, u) + u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err } return u, true, nil } +func (s *Service) projectUsers(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) { + if s == nil || s.projector == nil { + return users, nil + } + return s.projector.ForViewer(ctx, viewerUserID, users) +} + +func (s *Service) projectOne(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) { + if s == nil || s.projector == nil { + return user, nil + } + return s.projector.One(ctx, viewerUserID, user) +} + func normalizeUsername(username string) string { username = strings.TrimSpace(username) username = strings.TrimPrefix(username, "@") diff --git a/internal/domain/media.go b/internal/domain/media.go index 38bb4839..a204c53a 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -258,11 +258,21 @@ type StickerSet struct { SystemKey string `json:"system_key,omitempty"` } -// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。 +// ProfilePhotoKind distinguishes a user's real profile photo from the fallback +// public photo shown when privacy hides the real one. +type ProfilePhotoKind string + +const ( + ProfilePhotoKindProfile ProfilePhotoKind = "profile" + ProfilePhotoKindFallback ProfilePhotoKind = "fallback" +) + +// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile/fallback/personal photo)。 type ProfilePhotoRef struct { PhotoID int64 DCID int Stripped []byte // photoStrippedSize 内联缩略图,可空 + Personal bool // true 表示 viewer 私有联系人头像 } // StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。 diff --git a/internal/domain/privacy.go b/internal/domain/privacy.go new file mode 100644 index 00000000..4d48e1ec --- /dev/null +++ b/internal/domain/privacy.go @@ -0,0 +1,83 @@ +package domain + +import "errors" + +// PrivacyKey identifies a Telegram account privacy setting without exposing tg.*. +type PrivacyKey string + +const ( + PrivacyKeyStatusTimestamp PrivacyKey = "status_timestamp" + PrivacyKeyChatInvite PrivacyKey = "chat_invite" + PrivacyKeyPhoneCall PrivacyKey = "phone_call" + PrivacyKeyPhoneP2P PrivacyKey = "phone_p2p" + PrivacyKeyForwards PrivacyKey = "forwards" + PrivacyKeyProfilePhoto PrivacyKey = "profile_photo" + PrivacyKeyPhoneNumber PrivacyKey = "phone_number" + PrivacyKeyAddedByPhone PrivacyKey = "added_by_phone" + PrivacyKeyVoiceMessages PrivacyKey = "voice_messages" + PrivacyKeyAbout PrivacyKey = "about" + PrivacyKeyBirthday PrivacyKey = "birthday" + PrivacyKeyStarGiftsAutoSave PrivacyKey = "star_gifts_auto_save" + PrivacyKeyNoPaidMessages PrivacyKey = "no_paid_messages" + PrivacyKeySavedMusic PrivacyKey = "saved_music" +) + +// PrivacyRuleKind mirrors Layer 225 privacy rule constructors. +type PrivacyRuleKind string + +const ( + PrivacyRuleAllowContacts PrivacyRuleKind = "allow_contacts" + PrivacyRuleAllowAll PrivacyRuleKind = "allow_all" + PrivacyRuleAllowUsers PrivacyRuleKind = "allow_users" + PrivacyRuleDisallowContacts PrivacyRuleKind = "disallow_contacts" + PrivacyRuleDisallowAll PrivacyRuleKind = "disallow_all" + PrivacyRuleDisallowUsers PrivacyRuleKind = "disallow_users" + PrivacyRuleAllowChatParticipants PrivacyRuleKind = "allow_chat_participants" + PrivacyRuleDisallowChatParticipants PrivacyRuleKind = "disallow_chat_participants" + PrivacyRuleAllowCloseFriends PrivacyRuleKind = "allow_close_friends" + PrivacyRuleAllowPremium PrivacyRuleKind = "allow_premium" + PrivacyRuleAllowBots PrivacyRuleKind = "allow_bots" + PrivacyRuleDisallowBots PrivacyRuleKind = "disallow_bots" +) + +// PrivacyRule is a protocol-neutral privacy rule. +type PrivacyRule struct { + Kind PrivacyRuleKind `json:"kind"` + UserIDs []int64 `json:"user_ids,omitempty"` + ChatIDs []int64 `json:"chat_ids,omitempty"` +} + +// PrivacyRules is one owner/key rule set. +type PrivacyRules struct { + OwnerUserID int64 `json:"owner_user_id,omitempty"` + Key PrivacyKey `json:"key"` + Rules []PrivacyRule `json:"rules"` +} + +// PrivacyContext describes viewer facts needed for privacy evaluation. +type PrivacyContext struct { + OwnerUserID int64 + ViewerUserID int64 + ViewerIsContact bool + ViewerIsBot bool + ViewerIsPremium bool + ViewerCloseFriend bool + SharedChatIDs []int64 +} + +var ( + ErrPrivacyKeyInvalid = errors.New("privacy key invalid") + ErrPrivacyRuleInvalid = errors.New("privacy rule invalid") +) + +// DefaultPrivacyRules returns Telegram-like defaults used when no user setting exists. +func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule { + switch key { + case PrivacyKeyPhoneNumber: + return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}} + case PrivacyKeyBirthday: + return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}} + default: + return []PrivacyRule{{Kind: PrivacyRuleAllowAll}} + } +} diff --git a/internal/domain/user.go b/internal/domain/user.go index fccf5ea7..1b049ead 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -21,10 +21,11 @@ type User struct { Support bool Contact bool Mutual bool - // Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。 + // Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。 PhotoID int64 PhotoDCID int PhotoStripped []byte + PhotoPersonal bool LastSeenAt int Status UserStatus } @@ -61,3 +62,17 @@ type UserProfileUpdate struct { About string HasAbout bool } + +// UserFullView is the app-layer personalized full user view consumed by RPC. +type UserFullView struct { + User User + ProfilePhoto *Photo + PersonalPhoto *Photo + FallbackPhoto *Photo + About string + PhoneCallsAvailable bool + PhoneCallsPrivate bool + VideoCallsAvailable bool + VoiceMessagesForbidden bool + ReadDatesPrivate bool +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 0faea1d4..4dc54a40 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -101,6 +101,14 @@ type AccountService interface { GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) } +// PrivacyService owns account privacy rule storage/evaluation. +type PrivacyService interface { + GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) + SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) + AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) + CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) +} + // HelpService 抽象启动配置与国家区号目录。 type HelpService interface { GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) @@ -138,6 +146,9 @@ type ContactsService interface { Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) + SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error) + ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error) + PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error) UnblockContact(ctx context.Context, userID, peerUserID int64) (bool, error) @@ -307,10 +318,15 @@ type FilesService interface { GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) + UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) + SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) + CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error) + GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) + DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error) } // LangPackService 抽象客户端语言包查询。 @@ -324,6 +340,7 @@ type LangPackService interface { type Deps struct { Auth AuthService Account AccountService + Privacy PrivacyService Help HelpService Users UsersService Updates UpdatesService diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go index 7fda7e8c..e8e19c86 100644 --- a/internal/rpc/send_media_test.go +++ b/internal/rpc/send_media_test.go @@ -70,18 +70,33 @@ func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, b func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) { return domain.Photo{}, nil } +func (f *fakeFiles) UploadProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, domain.UploadedFileRef, int) (domain.Photo, error) { + return domain.Photo{}, nil +} func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) { return domain.Photo{}, false, nil } +func (f *fakeFiles) SetCurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int64, int) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) { return domain.Photo{}, false, nil } +func (f *fakeFiles) CurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) { return nil, 0, nil } +func (f *fakeFiles) GetProfilePhotosKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int, int, int64) ([]domain.Photo, int, error) { + return nil, 0, nil +} func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) { return 0, nil } +func (f *fakeFiles) DeleteProfilePhotosKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, []int64) (int, error) { + return 0, nil +} func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) { t.Helper() diff --git a/internal/store/contact.go b/internal/store/contact.go index 478c0087..cb7bd220 100644 --- a/internal/store/contact.go +++ b/internal/store/contact.go @@ -10,9 +10,13 @@ import ( type ContactStore interface { ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) + GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) + GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) + SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) + PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error) diff --git a/internal/store/media.go b/internal/store/media.go index ef299a0c..0f02abfc 100644 --- a/internal/store/media.go +++ b/internal/store/media.go @@ -39,8 +39,13 @@ type MediaStore interface { // 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。 AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error + AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) + CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) + CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error) + ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (ids []int64, total int, err error) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) + DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) } diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go index 683c1a6d..6f66a016 100644 --- a/internal/store/memory/memory.go +++ b/internal/store/memory/memory.go @@ -276,6 +276,52 @@ func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (doma return domain.Contact{}, false, nil } +func (s *ContactStore) GetMany(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if _, ok := want[contact.User.ID]; ok { + out[contact.User.ID] = cloneContact(contact) + } + } + return out, nil +} + +func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(ownerUserIDs)) + if userID == 0 || len(ownerUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(ownerUserIDs)) + for _, id := range ownerUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + defer s.mu.RUnlock() + for ownerID := range want { + for _, contact := range s.m[ownerID].Contacts { + if contact.User.ID == userID { + out[ownerID] = cloneContact(contact) + break + } + } + } + return out, nil +} + func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { contact := domain.Contact{ User: domain.User{ @@ -366,6 +412,52 @@ func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64 return domain.Contact{}, false, nil } +func (s *ContactStore) SetPersonalPhoto(_ context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + _ = date + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Contacts { + if list.Contacts[i].User.ID != contactUserID { + continue + } + list.Contacts[i].User.PhotoID = photoID + list.Contacts[i].User.PhotoPersonal = photoID != 0 + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(list.Contacts[i]), true, nil + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) PersonalPhotos(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if _, ok := want[contact.User.ID]; !ok || contact.User.PhotoID == 0 { + continue + } + out[contact.User.ID] = domain.ProfilePhotoRef{ + PhotoID: contact.User.PhotoID, + DCID: contact.User.PhotoDCID, + Stripped: append([]byte(nil), contact.User.PhotoStripped...), + Personal: true, + } + } + return out, nil +} + func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) { remove := make(map[int64]struct{}, len(contactUserIDs)) for _, id := range contactUserIDs { diff --git a/internal/store/memory/privacy.go b/internal/store/memory/privacy.go new file mode 100644 index 00000000..0bfbed1f --- /dev/null +++ b/internal/store/memory/privacy.go @@ -0,0 +1,75 @@ +package memory + +import ( + "context" + "sync" + + "telesrv/internal/domain" +) + +type privacyStoreKey struct { + ownerUserID int64 + key domain.PrivacyKey +} + +// PrivacyStore is an in-memory account privacy rule store for tests/dev mode. +type PrivacyStore struct { + mu sync.RWMutex + rules map[privacyStoreKey]domain.PrivacyRules +} + +func NewPrivacyStore() *PrivacyStore { + return &PrivacyStore{rules: make(map[privacyStoreKey]domain.PrivacyRules)} +} + +func (s *PrivacyStore) GetPrivacyRules(_ context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) { + s.mu.RLock() + rules, ok := s.rules[privacyStoreKey{ownerUserID: ownerUserID, key: key}] + s.mu.RUnlock() + return clonePrivacyRules(rules), ok, nil +} + +func (s *PrivacyStore) SetPrivacyRules(_ context.Context, rules domain.PrivacyRules) error { + s.mu.Lock() + s.rules[privacyStoreKey{ownerUserID: rules.OwnerUserID, key: rules.Key}] = clonePrivacyRules(rules) + s.mu.Unlock() + return nil +} + +func (s *PrivacyStore) ListPrivacyRules(_ context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) { + if len(ownerUserIDs) == 0 || len(keys) == 0 { + return nil, nil + } + owners := make(map[int64]struct{}, len(ownerUserIDs)) + for _, id := range ownerUserIDs { + owners[id] = struct{}{} + } + keySet := make(map[domain.PrivacyKey]struct{}, len(keys)) + for _, key := range keys { + keySet[key] = struct{}{} + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.PrivacyRules, 0, len(s.rules)) + for k, rules := range s.rules { + if _, ok := owners[k.ownerUserID]; !ok { + continue + } + if _, ok := keySet[k.key]; !ok { + continue + } + out = append(out, clonePrivacyRules(rules)) + } + return out, nil +} + +func clonePrivacyRules(in domain.PrivacyRules) domain.PrivacyRules { + out := in + out.Rules = make([]domain.PrivacyRule, len(in.Rules)) + for i, rule := range in.Rules { + out.Rules[i] = rule + out.Rules[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out.Rules[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go index 7b9dd660..80b2fac4 100644 --- a/internal/store/postgres/contact.go +++ b/internal/store/postgres/contact.go @@ -59,6 +59,98 @@ func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (do return contact, true, nil } +func (s *ContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 + AND c.contact_user_id = ANY($2::bigint[]) +`, userID, contactUserIDs) + if err != nil { + return nil, fmt.Errorf("get contacts many: %w", err) + } + defer rows.Close() + for rows.Next() { + contact, err := scanContactRows(rows) + if err != nil { + return nil, err + } + out[contact.User.ID] = contact + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(ownerUserIDs)) + if userID == 0 || len(ownerUserIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT + c.user_id AS owner_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.contact_user_id = $1 + AND c.user_id = ANY($2::bigint[]) +`, userID, ownerUserIDs) + if err != nil { + return nil, fmt.Errorf("get reverse contacts: %w", err) + } + defer rows.Close() + for rows.Next() { + ownerID, contact, err := scanReverseContactRows(rows) + if err != nil { + return nil, err + } + out[ownerID] = contact + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { entities, err := encodeMessageEntities(input.NoteEntities) if err != nil { @@ -295,6 +387,70 @@ func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int return contact, true, nil } +func (s *ContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + tag, err := s.db.Exec(ctx, ` +UPDATE contacts +SET personal_photo_id = $3, + personal_photo_date = CASE WHEN $3::bigint = 0 THEN 0 ELSE $4::int END, + updated_at = now() +WHERE user_id = $1 + AND contact_user_id = $2 +`, userID, contactUserID, photoID, date) + if err != nil { + return domain.Contact{}, false, fmt.Errorf("set contact personal photo: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.Contact{}, false, nil + } + contact, found, err := s.Get(ctx, userID, contactUserID) + return contact, found, err +} + +func (s *ContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT + c.contact_user_id, + c.personal_photo_id, + ph.dc_id, + ph.sizes::text AS sizes_json +FROM contacts c +JOIN photos ph ON ph.id = c.personal_photo_id +WHERE c.user_id = $1 + AND c.contact_user_id = ANY($2::bigint[]) + AND c.personal_photo_id <> 0 +`, userID, contactUserIDs) + if err != nil { + return nil, fmt.Errorf("list contact personal photos: %w", err) + } + defer rows.Close() + for rows.Next() { + var contactUserID, photoID int64 + var dcID int32 + var sizesJSON string + if err := rows.Scan(&contactUserID, &photoID, &dcID, &sizesJSON); err != nil { + return nil, err + } + sizes, err := decodePhotoSizes(sizesJSON) + if err != nil { + return nil, err + } + out[contactUserID] = domain.ProfilePhotoRef{ + PhotoID: photoID, + DCID: int(dcID), + Stripped: domain.StrippedFromSizes(sizes), + Personal: true, + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { if len(contactUserIDs) == 0 { return 0, nil @@ -366,6 +522,107 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam } } +type contactScanner interface { + Scan(dest ...any) error +} + +func scanContactRows(row contactScanner) (domain.Contact, error) { + var ( + contactUserID int64 + mutual bool + contactPhone string + contactFirstName string + contactLastName string + note string + noteEntitiesJSON string + id int64 + accessHash int64 + phone string + firstName string + lastName string + username string + countryCode string + verified bool + support bool + lastSeenAt int32 + ) + if err := row.Scan( + &contactUserID, + &mutual, + &contactPhone, + &contactFirstName, + &contactLastName, + ¬e, + ¬eEntitiesJSON, + &id, + &accessHash, + &phone, + &firstName, + &lastName, + &username, + &countryCode, + &verified, + &support, + &lastSeenAt, + ); err != nil { + return domain.Contact{}, err + } + entities, err := decodeMessageEntities(noteEntitiesJSON) + if err != nil { + return domain.Contact{}, err + } + return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual), nil +} + +func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { + var ( + ownerUserID int64 + mutual bool + contactPhone string + contactFirstName string + contactLastName string + note string + noteEntitiesJSON string + id int64 + accessHash int64 + phone string + firstName string + lastName string + username string + countryCode string + verified bool + support bool + lastSeenAt int32 + ) + if err := row.Scan( + &ownerUserID, + &mutual, + &contactPhone, + &contactFirstName, + &contactLastName, + ¬e, + ¬eEntitiesJSON, + &id, + &accessHash, + &phone, + &firstName, + &lastName, + &username, + &countryCode, + &verified, + &support, + &lastSeenAt, + ); err != nil { + return 0, domain.Contact{}, err + } + entities, err := decodeMessageEntities(noteEntitiesJSON) + if err != nil { + return 0, domain.Contact{}, err + } + contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual) + return ownerUserID, contact, nil +} + func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) { if userID == 0 || blockedUserID == 0 || userID == blockedUserID { return false, nil diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index 5fc4a936..f9868cbf 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -420,27 +420,44 @@ func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) { // ---- 头像历史 ---- func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error { - next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - }) + return s.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date) +} + +func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error { + kind = normalizeProfilePhotoKind(kind) + next, err := s.nextProfilePhotoOrder(ctx, ownerType, ownerID, kind) if err != nil { return err } - return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - PhotoID: photoID, - Date: int32(date), - SortOrder: next + 1, - }) + _, err = s.db.Exec(ctx, ` +INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order) +VALUES ($1, $2, $3, $4, $5, true, $6) +ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET + date = EXCLUDED.date, + active = true, + sort_order = EXCLUDED.sort_order +`, string(ownerType), ownerID, string(kind), photoID, date, next+1) + return err } func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) { - id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - }) + return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile) +} + +func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) { + kind = normalizeProfilePhotoKind(kind) + row := s.db.QueryRow(ctx, ` +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 + AND active +ORDER BY sort_order DESC +LIMIT 1 +`, string(ownerType), ownerID, string(kind)) + var id int64 + err := row.Scan(&id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, false, nil @@ -451,59 +468,156 @@ func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.P } func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + return s.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) +} + +func (s *MediaStore) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { if len(ownerIDs) == 0 { return map[int64]domain.ProfilePhotoRef{}, nil } - rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{ - OwnerPeerType: string(ownerType), - OwnerIds: ownerIDs, - }) + kind = normalizeProfilePhotoKind(kind) + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT ON (pp.owner_peer_id) + pp.owner_peer_id, + pp.photo_id, + ph.dc_id, + ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = $1 + AND pp.owner_peer_id = ANY($2::bigint[]) + AND pp.kind = $3 + AND pp.active +ORDER BY pp.owner_peer_id, pp.sort_order DESC +`, string(ownerType), ownerIDs, string(kind)) if err != nil { return nil, err } - out := make(map[int64]domain.ProfilePhotoRef, len(rows)) - for _, r := range rows { - sizes, err := decodePhotoSizes(r.SizesJson) + defer rows.Close() + out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) + for rows.Next() { + var ownerID, photoID int64 + var dcID int32 + var sizesJSON string + if err := rows.Scan(&ownerID, &photoID, &dcID, &sizesJSON); err != nil { + return nil, err + } + sizes, err := decodePhotoSizes(sizesJSON) if err != nil { return nil, err } - out[r.OwnerPeerID] = domain.ProfilePhotoRef{ - PhotoID: r.PhotoID, - DCID: int(r.DcID), + out[ownerID] = domain.ProfilePhotoRef{ + PhotoID: photoID, + DCID: int(dcID), Stripped: domain.StrippedFromSizes(sizes), } } + if err := rows.Err(); err != nil { + return nil, err + } return out, nil } func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) { - ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - MaxID: maxID, - OffsetCount: int32(offset), - LimitCount: int32(limit), - }) + return s.ListProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID) +} + +func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) { + kind = normalizeProfilePhotoKind(kind) + rows, err := s.db.Query(ctx, ` +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 + AND active + AND ($4::bigint <= 0 OR photo_id < $4::bigint) +ORDER BY sort_order DESC +OFFSET $5 +LIMIT $6 +`, string(ownerType), ownerID, string(kind), maxID, offset, limit) if err != nil { return nil, 0, err } - total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - }) + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, 0, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + var total int + err = s.db.QueryRow(ctx, ` +SELECT count(*)::int +FROM profile_photos +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 + AND active +`, string(ownerType), ownerID, string(kind)).Scan(&total) if err != nil { return nil, 0, err } - return ids, int(total), nil + return ids, total, nil } func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) { + return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs) +} + +func (s *MediaStore) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) { if len(photoIDs) == 0 { return nil, nil } - return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{ - OwnerPeerType: string(ownerType), - OwnerPeerID: ownerID, - PhotoIds: photoIDs, - }) + kind = normalizeProfilePhotoKind(kind) + rows, err := s.db.Query(ctx, ` +UPDATE profile_photos +SET active = false +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 + AND photo_id = ANY($4::bigint[]) + AND active +RETURNING photo_id +`, string(ownerType), ownerID, string(kind), photoIDs) + if err != nil { + return nil, err + } + defer rows.Close() + deleted := make([]int64, 0, len(photoIDs)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + deleted = append(deleted, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return deleted, nil +} + +func (s *MediaStore) nextProfilePhotoOrder(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, error) { + var maxOrder int64 + err := s.db.QueryRow(ctx, ` +SELECT COALESCE(MAX(sort_order), 0)::bigint +FROM profile_photos +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 +`, string(ownerType), ownerID, string(kind)).Scan(&maxOrder) + return maxOrder, err +} + +func normalizeProfilePhotoKind(kind domain.ProfilePhotoKind) domain.ProfilePhotoKind { + if kind == domain.ProfilePhotoKindFallback { + return kind + } + return domain.ProfilePhotoKindProfile } diff --git a/internal/store/postgres/privacy.go b/internal/store/postgres/privacy.go new file mode 100644 index 00000000..c9d2c3f3 --- /dev/null +++ b/internal/store/postgres/privacy.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +var _ store.PrivacyStore = (*PrivacyStore)(nil) + +// PrivacyStore persists account privacy rules in PostgreSQL. +type PrivacyStore struct { + db sqlcgen.DBTX +} + +func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore { + return &PrivacyStore{db: db} +} + +func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT rules::text +FROM account_privacy_rules +WHERE owner_user_id = $1 + AND privacy_key = $2 +`, ownerUserID, string(key)) + var raw string + if err := row.Scan(&raw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PrivacyRules{}, false, nil + } + return domain.PrivacyRules{}, false, fmt.Errorf("get privacy rules: %w", err) + } + rules, err := decodePrivacyRulesJSON(raw) + if err != nil { + return domain.PrivacyRules{}, false, err + } + return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: rules}, true, nil +} + +func (s *PrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error { + raw, err := json.Marshal(rules.Rules) + if err != nil { + return err + } + _, err = s.db.Exec(ctx, ` +INSERT INTO account_privacy_rules (owner_user_id, privacy_key, rules, updated_at) +VALUES ($1, $2, $3::jsonb, NOW()) +ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET + rules = EXCLUDED.rules, + updated_at = EXCLUDED.updated_at +`, rules.OwnerUserID, string(rules.Key), string(raw)) + if err != nil { + return fmt.Errorf("set privacy rules: %w", err) + } + return nil +} + +func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) { + if len(ownerUserIDs) == 0 || len(keys) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT owner_user_id, privacy_key, rules::text +FROM account_privacy_rules +WHERE owner_user_id = ANY($1::bigint[]) + AND privacy_key = ANY($2::text[]) +`, ownerUserIDs, privacyKeyStrings(keys)) + if err != nil { + return nil, fmt.Errorf("list privacy rules: %w", err) + } + defer rows.Close() + out := make([]domain.PrivacyRules, 0) + for rows.Next() { + var ownerUserID int64 + var key string + var raw string + if err := rows.Scan(&ownerUserID, &key, &raw); err != nil { + return nil, err + } + rules, err := decodePrivacyRulesJSON(raw) + if err != nil { + return nil, err + } + out = append(out, domain.PrivacyRules{ + OwnerUserID: ownerUserID, + Key: domain.PrivacyKey(key), + Rules: rules, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func privacyKeyStrings(keys []domain.PrivacyKey) []string { + out := make([]string, 0, len(keys)) + for _, key := range keys { + out = append(out, string(key)) + } + return out +} + +func decodePrivacyRulesJSON(raw string) ([]domain.PrivacyRule, error) { + if raw == "" { + return nil, nil + } + var rules []domain.PrivacyRule + if err := json.Unmarshal([]byte(raw), &rules); err != nil { + return nil, fmt.Errorf("decode privacy rules: %w", err) + } + for i := range rules { + rules[i].UserIDs = append([]int64(nil), rules[i].UserIDs...) + rules[i].ChatIDs = append([]int64(nil), rules[i].ChatIDs...) + } + return rules, nil +} diff --git a/internal/store/privacy.go b/internal/store/privacy.go new file mode 100644 index 00000000..e6577568 --- /dev/null +++ b/internal/store/privacy.go @@ -0,0 +1,14 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// PrivacyStore persists account privacy rules by owner user and privacy key. +type PrivacyStore interface { + GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) + SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error + ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) +}