business: add privacy-aware user projection
(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
This commit is contained in:
parent
14d220c971
commit
8ff3343ae0
29 changed files with 2239 additions and 112 deletions
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"telesrv/internal/app/langpack"
|
"telesrv/internal/app/langpack"
|
||||||
"telesrv/internal/app/maintenance"
|
"telesrv/internal/app/maintenance"
|
||||||
messageapp "telesrv/internal/app/messages"
|
messageapp "telesrv/internal/app/messages"
|
||||||
|
privacyapp "telesrv/internal/app/privacy"
|
||||||
"telesrv/internal/app/updates"
|
"telesrv/internal/app/updates"
|
||||||
"telesrv/internal/app/users"
|
"telesrv/internal/app/users"
|
||||||
"telesrv/internal/config"
|
"telesrv/internal/config"
|
||||||
|
|
@ -125,6 +126,7 @@ func run(logger *zap.Logger) error {
|
||||||
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
|
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
|
||||||
channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator))
|
channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator))
|
||||||
mediaStore := postgres.NewMediaStore(pool)
|
mediaStore := postgres.NewMediaStore(pool)
|
||||||
|
privacyStore := postgres.NewPrivacyStore(pool)
|
||||||
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
|
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init blob backend: %w", err)
|
return fmt.Errorf("init blob backend: %w", err)
|
||||||
|
|
@ -170,6 +172,7 @@ func run(logger *zap.Logger) error {
|
||||||
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
|
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
|
||||||
).Run(ctx)
|
).Run(ctx)
|
||||||
langPackService := langpack.NewService(langPackStore)
|
langPackService := langpack.NewService(langPackStore)
|
||||||
|
privacyService := privacyapp.NewService(privacyStore, contactStore)
|
||||||
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
|
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
|
||||||
return fmt.Errorf("seed langpack: %w", err)
|
return fmt.Errorf("seed langpack: %w", err)
|
||||||
} else if seeded > 0 {
|
} else if seeded > 0 {
|
||||||
|
|
@ -184,12 +187,13 @@ func run(logger *zap.Logger) error {
|
||||||
}, rpc.Deps{
|
}, rpc.Deps{
|
||||||
Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)),
|
Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)),
|
||||||
Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)),
|
Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)),
|
||||||
|
Privacy: privacyService,
|
||||||
Help: help.NewService(helpStore, helpStore),
|
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)),
|
Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)),
|
||||||
Contacts: contacts.NewService(contactStore, userStore),
|
Contacts: contacts.NewService(contactStore, userStore).Configure(contacts.WithPhotoProvider(mediaStore), contacts.WithPrivacyEvaluator(privacyService)),
|
||||||
Dialogs: dialogs.NewService(dialogStore, channelStore),
|
Dialogs: dialogs.NewService(dialogStore, channelStore).Configure(dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(mediaStore), dialogs.WithPrivacyEvaluator(privacyService)),
|
||||||
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore)),
|
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(mediaStore), messageapp.WithPrivacyEvaluator(privacyService)),
|
||||||
Channels: channelapp.NewService(channelStore),
|
Channels: channelapp.NewService(channelStore),
|
||||||
Files: filesService,
|
Files: filesService,
|
||||||
LangPack: langPackService,
|
LangPack: langPackService,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"telesrv/internal/app/userprojection"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
@ -18,10 +19,31 @@ var (
|
||||||
|
|
||||||
const maxSearchLimit = 50
|
const maxSearchLimit = 50
|
||||||
|
|
||||||
|
type phonePrivacyService interface {
|
||||||
|
userprojection.PrivacyEvaluator
|
||||||
|
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Service 提供通讯录查询。
|
// Service 提供通讯录查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
users store.UserStore
|
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 服务。
|
// NewService 创建 contacts 服务。
|
||||||
|
|
@ -30,9 +52,33 @@ func NewService(contacts store.ContactStore, users ...store.UserStore) *Service
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
s.users = users[0]
|
s.users = users[0]
|
||||||
}
|
}
|
||||||
|
s.rebuildProjector()
|
||||||
return s
|
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 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
|
// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
|
||||||
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
|
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
|
||||||
if s == nil || s.contacts == nil || userID == 0 {
|
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
|
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 {
|
if hash != 0 && hash == list.Hash {
|
||||||
return list, true, nil
|
return list, true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +159,12 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Contact{}, err
|
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.
|
// 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 {
|
if err != nil {
|
||||||
return domain.Contact{}, err
|
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)
|
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Contact{}, err
|
return domain.Contact{}, err
|
||||||
|
|
@ -158,7 +217,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
|
||||||
if !found {
|
if !found {
|
||||||
return domain.Contact{}, ErrContactReqMissing
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.ImportContactsResult{}, err
|
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...)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +320,11 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
|
||||||
if limit <= 0 || limit > maxSearchLimit {
|
if limit <= 0 || limit > maxSearchLimit {
|
||||||
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) {
|
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
|
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) {
|
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 {
|
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
|
||||||
return domain.PeerSettings{}, nil
|
return domain.PeerSettings{}, nil
|
||||||
|
|
@ -282,10 +395,18 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PeerSettings{}, err
|
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{
|
return domain.PeerSettings{
|
||||||
AddContact: !found,
|
AddContact: !found,
|
||||||
BlockContact: !blocked,
|
BlockContact: !blocked,
|
||||||
ShareContact: found && !contact.Mutual,
|
ShareContact: shareContact,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,6 +464,51 @@ func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]i
|
||||||
return ids, false, nil
|
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 {
|
func normalizePhone(phone string) string {
|
||||||
if !utf8.ValidString(phone) {
|
if !utf8.ValidString(phone) {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create target: %v", err)
|
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{
|
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{
|
||||||
{ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"},
|
{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" {
|
if res.Contacts[0].FirstName != "Alice Final" {
|
||||||
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
|
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) {
|
func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
|
||||||
|
|
@ -130,3 +175,15 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
|
||||||
t.Fatalf("AcceptContact without contact err = %v, want ErrContactReqMissing", err)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,37 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"telesrv/internal/app/userprojection"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service 提供会话列表查询。
|
// Service 提供会话列表查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
dialogs store.DialogStore
|
dialogs store.DialogStore
|
||||||
channels store.ChannelStore
|
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 服务。
|
// NewService 创建 dialogs 服务。
|
||||||
|
|
@ -24,9 +47,33 @@ func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Serv
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
s.channels = channels[0]
|
s.channels = channels[0]
|
||||||
}
|
}
|
||||||
|
s.rebuildProjector()
|
||||||
return s
|
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 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
||||||
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||||
if s == nil || userID == 0 {
|
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 {
|
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||||
return domain.DialogList{}, err
|
return domain.DialogList{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||||
|
return domain.DialogList{}, err
|
||||||
|
}
|
||||||
return out, nil
|
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 {
|
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||||
return domain.DialogList{}, err
|
return domain.DialogList{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||||
|
return domain.DialogList{}, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,6 +492,18 @@ func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.D
|
||||||
return nil
|
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 {
|
func validateDraft(draft domain.DialogDraft) error {
|
||||||
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
channelStore := memory.NewChannelStore()
|
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)
|
t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs)
|
||||||
return domain.Dialog{}
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ import (
|
||||||
|
|
||||||
// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。
|
// 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) {
|
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)
|
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Photo{}, err
|
return domain.Photo{}, err
|
||||||
|
|
@ -33,7 +38,7 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Photo{}, err
|
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 domain.Photo{}, err
|
||||||
}
|
}
|
||||||
return photo, nil
|
return photo, nil
|
||||||
|
|
@ -133,6 +138,11 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
|
||||||
|
|
||||||
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。
|
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。
|
||||||
func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) {
|
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)
|
photo, ok, err := s.media.GetPhoto(ctx, photoID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return domain.Photo{}, ok, err
|
return domain.Photo{}, ok, err
|
||||||
|
|
@ -140,7 +150,7 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P
|
||||||
if date == 0 {
|
if date == 0 {
|
||||||
date = int(time.Now().Unix())
|
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 domain.Photo{}, false, err
|
||||||
}
|
}
|
||||||
return photo, true, nil
|
return photo, true, nil
|
||||||
|
|
@ -148,7 +158,12 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P
|
||||||
|
|
||||||
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
|
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
|
||||||
func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) {
|
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 {
|
if err != nil || !ok {
|
||||||
return domain.Photo{}, ok, err
|
return domain.Photo{}, ok, err
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +172,12 @@ func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.Peer
|
||||||
|
|
||||||
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
|
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
|
||||||
func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +194,12 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp
|
||||||
|
|
||||||
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
||||||
func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,18 +159,33 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
|
||||||
func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
|
func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
|
||||||
return nil
|
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) {
|
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
|
||||||
return 0, false, nil
|
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) {
|
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
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) {
|
func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) {
|
||||||
return nil, 0, nil
|
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) {
|
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||||||
return nil, nil
|
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) {
|
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||||
seedDir := t.TempDir()
|
seedDir := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import (
|
||||||
|
|
||||||
// Service 提供消息历史、搜索与已读业务。
|
// Service 提供消息历史、搜索与已读业务。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
messages store.MessageStore
|
messages store.MessageStore
|
||||||
dialogs store.DialogStore
|
dialogs store.DialogStore
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
|
photos userprojection.ProfilePhotoProvider
|
||||||
|
privacy userprojection.PrivacyEvaluator
|
||||||
|
projector *userprojection.Projector
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option adjusts optional message service dependencies.
|
// Option adjusts optional message service dependencies.
|
||||||
|
|
@ -23,12 +26,27 @@ func WithContactStore(c store.ContactStore) Option {
|
||||||
return func(s *Service) { s.contacts = c }
|
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 服务。
|
// NewService 创建 messages 服务。
|
||||||
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
|
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
|
||||||
s := &Service{messages: messages, dialogs: dialogs}
|
s := &Service{messages: messages, dialogs: dialogs}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(s)
|
opt(s)
|
||||||
}
|
}
|
||||||
|
s.projector = userprojection.New(
|
||||||
|
userprojection.WithContactStore(s.contacts),
|
||||||
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
|
)
|
||||||
return s
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.MessageList{}, err
|
return domain.MessageList{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
||||||
{ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"},
|
{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})
|
list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -39,10 +42,16 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
||||||
if !friend.Contact || friend.FirstName != "Remark" || friend.LastName != "Friend" || friend.Phone != "15550000002" {
|
if !friend.Contact || friend.FirstName != "Remark" || friend.LastName != "Friend" || friend.Phone != "15550000002" {
|
||||||
t.Fatalf("friend projection = %+v, want contact remark and phone", friend)
|
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)
|
stranger := findUser(t, list.Users, strangerID)
|
||||||
if stranger.Contact || stranger.Phone != "" || stranger.FirstName != "Stranger" {
|
if stranger.Contact || stranger.Phone != "" || stranger.FirstName != "Stranger" {
|
||||||
t.Fatalf("stranger projection = %+v, want non-contact with hidden phone", 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)
|
self := findUser(t, list.Users, ownerID)
|
||||||
if self.Phone != "15550000001" {
|
if self.Phone != "15550000001" {
|
||||||
t.Fatalf("self phone = %q, want preserved", self.Phone)
|
t.Fatalf("self phone = %q, want preserved", self.Phone)
|
||||||
|
|
@ -64,6 +73,18 @@ type projectionMessageStore struct {
|
||||||
list domain.MessageList
|
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) {
|
func (s projectionMessageStore) Create(context.Context, domain.Message) (domain.Message, error) {
|
||||||
return domain.Message{}, nil
|
return domain.Message{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
internal/app/privacy/service.go
Normal file
268
internal/app/privacy/service.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
75
internal/app/privacy/service_test.go
Normal file
75
internal/app/privacy/service_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,111 @@ import (
|
||||||
"telesrv/internal/store"
|
"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.
|
// ForViewer applies the owner-specific user view that Telegram clients expect.
|
||||||
// In particular, phone is visible for self and contacts; non-contacts should not
|
// 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.
|
// 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
|
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) {
|
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)
|
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -73,3 +246,130 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
||||||
}
|
}
|
||||||
return projected, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
148
internal/app/userprojection/projection_test.go
Normal file
148
internal/app/userprojection/projection_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -15,15 +15,15 @@ import (
|
||||||
var ErrNotAuthorized = errors.New("not authorized")
|
var ErrNotAuthorized = errors.New("not authorized")
|
||||||
|
|
||||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||||
type ProfilePhotoProvider interface {
|
type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
||||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service 提供用户查询。
|
// Service 提供用户查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
users store.UserStore
|
users store.UserStore
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
photos ProfilePhotoProvider
|
photos ProfilePhotoProvider
|
||||||
|
privacy userprojection.PrivacyEvaluator
|
||||||
|
projector *userprojection.Projector
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 调整用户服务可选依赖。
|
// Option 调整用户服务可选依赖。
|
||||||
|
|
@ -39,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option {
|
||||||
return func(s *Service) { s.contacts = c }
|
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 (
|
const (
|
||||||
minUsernameLen = 5
|
minUsernameLen = 5
|
||||||
maxUsernameLen = 32
|
maxUsernameLen = 32
|
||||||
|
|
@ -53,6 +58,11 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(s)
|
opt(s)
|
||||||
}
|
}
|
||||||
|
s.projector = userprojection.New(
|
||||||
|
userprojection.WithContactStore(s.contacts),
|
||||||
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
|
)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +87,7 @@ func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
}
|
}
|
||||||
return s.enrichOne(ctx, u), nil
|
return s.projectOne(ctx, userID, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。
|
// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。
|
||||||
|
|
@ -92,8 +102,7 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain
|
||||||
if !found {
|
if !found {
|
||||||
return u, false, nil
|
return u, false, nil
|
||||||
}
|
}
|
||||||
u = s.enrichOne(ctx, u)
|
u, err = s.projectOne(ctx, currentUserID, u)
|
||||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, false, err
|
return domain.User{}, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -127,38 +136,7 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
users = s.enrich(ctx, users)
|
return s.projectUsers(ctx, currentUserID, 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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckUsername 校验当前用户是否可以占用 username。
|
// CheckUsername 校验当前用户是否可以占用 username。
|
||||||
|
|
@ -261,8 +239,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
return u, found, err
|
return u, found, err
|
||||||
}
|
}
|
||||||
u = s.enrichOne(ctx, u)
|
u, err = s.projectOne(ctx, currentUserID, u)
|
||||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, false, err
|
return domain.User{}, false, err
|
||||||
}
|
}
|
||||||
|
|
@ -282,14 +259,27 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
return u, found, err
|
return u, found, err
|
||||||
}
|
}
|
||||||
u = s.enrichOne(ctx, u)
|
u, err = s.projectOne(ctx, currentUserID, u)
|
||||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, false, err
|
return domain.User{}, false, err
|
||||||
}
|
}
|
||||||
return u, true, nil
|
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 {
|
func normalizeUsername(username string) string {
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
username = strings.TrimPrefix(username, "@")
|
username = strings.TrimPrefix(username, "@")
|
||||||
|
|
|
||||||
|
|
@ -258,11 +258,21 @@ type StickerSet struct {
|
||||||
SystemKey string `json:"system_key,omitempty"`
|
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 {
|
type ProfilePhotoRef struct {
|
||||||
PhotoID int64
|
PhotoID int64
|
||||||
DCID int
|
DCID int
|
||||||
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
||||||
|
Personal bool // true 表示 viewer 私有联系人头像
|
||||||
}
|
}
|
||||||
|
|
||||||
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
||||||
|
|
|
||||||
83
internal/domain/privacy.go
Normal file
83
internal/domain/privacy.go
Normal file
|
|
@ -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}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,11 @@ type User struct {
|
||||||
Support bool
|
Support bool
|
||||||
Contact bool
|
Contact bool
|
||||||
Mutual bool
|
Mutual bool
|
||||||
// Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。
|
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
|
||||||
PhotoID int64
|
PhotoID int64
|
||||||
PhotoDCID int
|
PhotoDCID int
|
||||||
PhotoStripped []byte
|
PhotoStripped []byte
|
||||||
|
PhotoPersonal bool
|
||||||
LastSeenAt int
|
LastSeenAt int
|
||||||
Status UserStatus
|
Status UserStatus
|
||||||
}
|
}
|
||||||
|
|
@ -61,3 +62,17 @@ type UserProfileUpdate struct {
|
||||||
About string
|
About string
|
||||||
HasAbout bool
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,14 @@ type AccountService interface {
|
||||||
GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error)
|
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 抽象启动配置与国家区号目录。
|
// HelpService 抽象启动配置与国家区号目录。
|
||||||
type HelpService interface {
|
type HelpService interface {
|
||||||
GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error)
|
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)
|
Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error)
|
||||||
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, 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)
|
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)
|
GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error)
|
||||||
BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error)
|
BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error)
|
||||||
UnblockContact(ctx context.Context, userID, peerUserID int64) (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)
|
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
|
||||||
GetDocument(ctx context.Context, id int64) (domain.Document, 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)
|
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)
|
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)
|
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)
|
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)
|
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 抽象客户端语言包查询。
|
// LangPackService 抽象客户端语言包查询。
|
||||||
|
|
@ -324,6 +340,7 @@ type LangPackService interface {
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Auth AuthService
|
Auth AuthService
|
||||||
Account AccountService
|
Account AccountService
|
||||||
|
Privacy PrivacyService
|
||||||
Help HelpService
|
Help HelpService
|
||||||
Users UsersService
|
Users UsersService
|
||||||
Updates UpdatesService
|
Updates UpdatesService
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) {
|
||||||
return domain.Photo{}, nil
|
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) {
|
func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) {
|
||||||
return domain.Photo{}, false, nil
|
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) {
|
func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) {
|
||||||
return domain.Photo{}, false, nil
|
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) {
|
func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) {
|
||||||
return nil, 0, nil
|
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) {
|
func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) {
|
||||||
return 0, nil
|
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) {
|
func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,13 @@ import (
|
||||||
type ContactStore interface {
|
type ContactStore interface {
|
||||||
ListByUser(ctx context.Context, userID int64) (domain.ContactList, error)
|
ListByUser(ctx context.Context, userID int64) (domain.ContactList, error)
|
||||||
Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, 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)
|
Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
|
||||||
UpsertMany(ctx context.Context, userID int64, inputs []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)
|
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)
|
Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
|
||||||
Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error)
|
Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error)
|
||||||
Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error)
|
Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error)
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,13 @@ type MediaStore interface {
|
||||||
|
|
||||||
// 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。
|
// 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。
|
||||||
AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error
|
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)
|
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)
|
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)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,52 @@ func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (doma
|
||||||
return domain.Contact{}, false, nil
|
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) {
|
func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||||
contact := domain.Contact{
|
contact := domain.Contact{
|
||||||
User: domain.User{
|
User: domain.User{
|
||||||
|
|
@ -366,6 +412,52 @@ func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64
|
||||||
return domain.Contact{}, false, nil
|
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) {
|
func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||||
remove := make(map[int64]struct{}, len(contactUserIDs))
|
remove := make(map[int64]struct{}, len(contactUserIDs))
|
||||||
for _, id := range contactUserIDs {
|
for _, id := range contactUserIDs {
|
||||||
|
|
|
||||||
75
internal/store/memory/privacy.go
Normal file
75
internal/store/memory/privacy.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,98 @@ func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (do
|
||||||
return contact, true, nil
|
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) {
|
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||||
entities, err := encodeMessageEntities(input.NoteEntities)
|
entities, err := encodeMessageEntities(input.NoteEntities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -295,6 +387,70 @@ func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int
|
||||||
return contact, true, nil
|
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) {
|
func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||||
if len(contactUserIDs) == 0 {
|
if len(contactUserIDs) == 0 {
|
||||||
return 0, nil
|
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) {
|
func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
|
||||||
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
|
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error {
|
||||||
next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{
|
return s.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date)
|
||||||
OwnerPeerType: string(ownerType),
|
}
|
||||||
OwnerPeerID: ownerID,
|
|
||||||
})
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{
|
_, err = s.db.Exec(ctx, `
|
||||||
OwnerPeerType: string(ownerType),
|
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order)
|
||||||
OwnerPeerID: ownerID,
|
VALUES ($1, $2, $3, $4, $5, true, $6)
|
||||||
PhotoID: photoID,
|
ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET
|
||||||
Date: int32(date),
|
date = EXCLUDED.date,
|
||||||
SortOrder: next + 1,
|
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) {
|
func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) {
|
||||||
id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{
|
return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
|
||||||
OwnerPeerType: string(ownerType),
|
}
|
||||||
OwnerPeerID: ownerID,
|
|
||||||
})
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return 0, false, nil
|
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) {
|
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 {
|
if len(ownerIDs) == 0 {
|
||||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||||
}
|
}
|
||||||
rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{
|
kind = normalizeProfilePhotoKind(kind)
|
||||||
OwnerPeerType: string(ownerType),
|
rows, err := s.db.Query(ctx, `
|
||||||
OwnerIds: ownerIDs,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make(map[int64]domain.ProfilePhotoRef, len(rows))
|
defer rows.Close()
|
||||||
for _, r := range rows {
|
out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs))
|
||||||
sizes, err := decodePhotoSizes(r.SizesJson)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[r.OwnerPeerID] = domain.ProfilePhotoRef{
|
out[ownerID] = domain.ProfilePhotoRef{
|
||||||
PhotoID: r.PhotoID,
|
PhotoID: photoID,
|
||||||
DCID: int(r.DcID),
|
DCID: int(dcID),
|
||||||
Stripped: domain.StrippedFromSizes(sizes),
|
Stripped: domain.StrippedFromSizes(sizes),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) {
|
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{
|
return s.ListProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID)
|
||||||
OwnerPeerType: string(ownerType),
|
}
|
||||||
OwnerPeerID: ownerID,
|
|
||||||
MaxID: maxID,
|
func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
|
||||||
OffsetCount: int32(offset),
|
kind = normalizeProfilePhotoKind(kind)
|
||||||
LimitCount: int32(limit),
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{
|
defer rows.Close()
|
||||||
OwnerPeerType: string(ownerType),
|
ids := make([]int64, 0, limit)
|
||||||
OwnerPeerID: ownerID,
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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) {
|
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 {
|
if len(photoIDs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{
|
kind = normalizeProfilePhotoKind(kind)
|
||||||
OwnerPeerType: string(ownerType),
|
rows, err := s.db.Query(ctx, `
|
||||||
OwnerPeerID: ownerID,
|
UPDATE profile_photos
|
||||||
PhotoIds: photoIDs,
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
internal/store/postgres/privacy.go
Normal file
125
internal/store/postgres/privacy.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
14
internal/store/privacy.go
Normal file
14
internal/store/privacy.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue