242 lines
6.9 KiB
Go
242 lines
6.9 KiB
Go
package privacy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/readmodelcache"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultPrivacyViewerFactsTTL = 10 * time.Minute
|
|
defaultPrivacyMembershipTTL = 24 * time.Hour
|
|
|
|
privacyViewerFactsMaxEntries = 8192
|
|
privacyMembershipMaxEntries = 65536
|
|
// A single legal cold batch must not replace the complete long-lived pair
|
|
// cache. Large projections still use one exact store batch, but bypass LRU
|
|
// admission and return the loaded facts directly.
|
|
privacyMembershipBatchAdmissionMaxPairs = privacyMembershipMaxEntries / 4
|
|
)
|
|
|
|
// baseUserProvider returns viewer-independent user facts through the users read
|
|
// model. Implementations must batch cold misses rather than issue one query per
|
|
// user.
|
|
type baseUserProvider interface {
|
|
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
|
}
|
|
|
|
// channelMembershipProvider is the exact-pair cold loader behind the bounded
|
|
// membership read model. Cache-admitted batches load only misses; oversized
|
|
// non-admitted batches reload once without polluting the long-lived LRU.
|
|
type channelMembershipProvider interface {
|
|
FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error)
|
|
}
|
|
|
|
type viewerFacts struct {
|
|
Found bool
|
|
Bot bool
|
|
PremiumUntil int64
|
|
}
|
|
|
|
type membershipKey struct {
|
|
ChatID int64
|
|
UserID int64
|
|
}
|
|
|
|
type evaluationNeeds struct {
|
|
viewerBase bool
|
|
chatIDs []int64
|
|
}
|
|
|
|
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
|
|
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
|
|
MaxEntries: privacyViewerFactsMaxEntries,
|
|
TTL: defaultPrivacyViewerFactsTTL,
|
|
})
|
|
}
|
|
|
|
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
|
|
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
|
|
MaxEntries: privacyMembershipMaxEntries,
|
|
TTL: defaultPrivacyMembershipTTL,
|
|
KeyString: func(key membershipKey) string {
|
|
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
|
|
},
|
|
})
|
|
}
|
|
|
|
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
|
|
var needs evaluationNeeds
|
|
seenChats := make(map[int64]struct{})
|
|
for _, rule := range rules.Rules {
|
|
switch rule.Kind {
|
|
case domain.PrivacyRuleAllowPremium,
|
|
domain.PrivacyRuleAllowBots,
|
|
domain.PrivacyRuleDisallowBots:
|
|
needs.viewerBase = true
|
|
case domain.PrivacyRuleAllowChatParticipants,
|
|
domain.PrivacyRuleDisallowChatParticipants:
|
|
for _, chatID := range rule.ChatIDs {
|
|
if chatID <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seenChats[chatID]; ok {
|
|
continue
|
|
}
|
|
seenChats[chatID] = struct{}{}
|
|
needs.chatIDs = append(needs.chatIDs, chatID)
|
|
}
|
|
}
|
|
}
|
|
return needs
|
|
}
|
|
|
|
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
|
|
if src.viewerBase {
|
|
dst.viewerBase = true
|
|
}
|
|
if len(src.chatIDs) == 0 {
|
|
return
|
|
}
|
|
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
|
|
for _, id := range dst.chatIDs {
|
|
seen[id] = struct{}{}
|
|
}
|
|
for _, id := range src.chatIDs {
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
dst.chatIDs = append(dst.chatIDs, id)
|
|
}
|
|
}
|
|
|
|
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
|
|
ids := dedupNonZero(viewerUserIDs)
|
|
if len(ids) == 0 {
|
|
return map[int64]viewerFacts{}, nil
|
|
}
|
|
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
|
|
out := make(map[int64]viewerFacts, len(missing))
|
|
for _, id := range missing {
|
|
out[id] = viewerFacts{} // negative cache: user was not found.
|
|
}
|
|
if s == nil || s.baseUsers == nil {
|
|
return out, nil
|
|
}
|
|
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, user := range users {
|
|
if user.ID == 0 {
|
|
continue
|
|
}
|
|
out[user.ID] = viewerFacts{
|
|
Found: true,
|
|
Bot: user.Bot,
|
|
PremiumUntil: int64(user.PremiumUntil),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
if s == nil || s.viewerFacts == nil {
|
|
return loadMissing(ctx, ids)
|
|
}
|
|
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
|
|
func(int64) (int64, bool) { return 0, true },
|
|
loadMissing,
|
|
)
|
|
}
|
|
|
|
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
|
|
chats := dedupNonZero(chatIDs)
|
|
viewers := dedupNonZero(viewerUserIDs)
|
|
if len(chats) == 0 || len(viewers) == 0 {
|
|
return map[membershipKey]bool{}, nil
|
|
}
|
|
if !activeChannelMembershipPairsAllowed(0, len(chats), len(viewers)) {
|
|
return nil, activeChannelMembershipPairLimitError()
|
|
}
|
|
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
|
for _, chatID := range chats {
|
|
for _, viewerID := range viewers {
|
|
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
|
}
|
|
}
|
|
return s.loadMembershipFactsForKeys(ctx, keys)
|
|
}
|
|
|
|
func activeChannelMembershipPairsAllowed(current, channelCount, userCount int) bool {
|
|
if current < 0 || channelCount < 0 || userCount < 0 || current > store.MaxActiveChannelMemberPairs {
|
|
return false
|
|
}
|
|
if channelCount == 0 || userCount == 0 {
|
|
return true
|
|
}
|
|
return channelCount <= (store.MaxActiveChannelMemberPairs-current)/userCount
|
|
}
|
|
|
|
func activeChannelMembershipPairLimitError() error {
|
|
return fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
|
}
|
|
|
|
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
|
if ctx == nil || !facts.Found {
|
|
return
|
|
}
|
|
ctx.ViewerIsBot = facts.Bot
|
|
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
|
|
}
|
|
|
|
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
|
|
if ctx == nil || len(chatIDs) == 0 {
|
|
return
|
|
}
|
|
for _, chatID := range chatIDs {
|
|
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
|
|
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
|
|
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
|
|
if s == nil || s.viewerFacts == nil {
|
|
return
|
|
}
|
|
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
|
|
}
|
|
|
|
// InvalidateMembership invalidates one membership pair after a channel-member change.
|
|
func (s *Service) InvalidateMembership(channelID, userID int64) {
|
|
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
|
|
return
|
|
}
|
|
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
|
|
}
|
|
|
|
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
|
|
func (s *Service) InvalidateChannelMemberships(channelID int64) {
|
|
if s == nil || s.membershipFacts == nil || channelID == 0 {
|
|
return
|
|
}
|
|
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
|
|
}
|
|
|
|
func (s *Service) flushFactCaches() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if s.viewerFacts != nil {
|
|
s.viewerFacts.Flush()
|
|
}
|
|
if s.membershipFacts != nil {
|
|
s.membershipFacts.Flush()
|
|
}
|
|
}
|