package rpc import ( "context" "strings" "telesrv/internal/domain" ) // Frozen accounts are read-only. Classifying the finite read vocabulary and // failing closed for every other semantic method also covers future handlers: // an unfamiliar mutation cannot silently bypass the account-level gate. var frozenReadOnlyOperationPrefixes = []string{ "can", "check", "find", "get", "load", "lookup", "query", "read", "resolve", "search", "translate", } var frozenAlwaysBlockedMethods = map[string]struct{}{ "channels.deleteMessages": {}, "channels.joinChannel": {}, "channels.searchPosts": {}, } // These methods are security/session housekeeping, read acknowledgements, or // lifecycle operations that must remain available in read-only mode. A frozen // user must be able to log out/delete the account, and clients must be able to // settle an incoming or already-active private call without entering a broken // half-state. Starting a new call remains gated through phone.requestCall. var frozenAllowedMutationNamedMethods = map[string]struct{}{ "account.changeAuthorizationSettings": {}, "account.deleteAccount": {}, "account.registerDevice": {}, "account.resetAuthorization": {}, "account.resetAuthorizations": {}, "account.unregisterDevice": {}, "account.updateDeviceLocked": {}, "account.updateStatus": {}, "messages.readHistory": {}, "messages.readMentions": {}, "messages.readMessageContents": {}, "messages.readReactions": {}, "messages.receivedMessages": {}, "messages.receivedQueue": {}, "messages.reportMessagesDelivery": {}, "messages.viewSponsoredMessage": {}, "channels.readHistory": {}, "channels.readMessageContents": {}, "phone.acceptCall": {}, "phone.confirmCall": {}, "phone.discardCall": {}, "phone.receivedCall": {}, "phone.saveCallDebug": {}, "phone.sendSignalingData": {}, "phone.setCallRating": {}, "stories.incrementStoryViews": {}, } func frozenMethodRequiresWriteGate(method string) bool { if _, blocked := frozenAlwaysBlockedMethods[method]; blocked { return true } if _, allowed := frozenAllowedMutationNamedMethods[method]; allowed { return false } if strings.HasPrefix(method, "auth.") { return false } dot := strings.IndexByte(method, '.') if dot < 0 || dot == len(method)-1 { return false } operation := method[dot+1:] for _, prefix := range frozenReadOnlyOperationPrefixes { if strings.HasPrefix(operation, prefix) { return false } } return true } // restrictedAlwaysBlockedMethods covers the narrower spam restriction: unlike // freeze it does not touch reads or existing-membership actions, it only // blocks starting a NEW channel/group membership (public join or private // invite link). Non-contact messaging is peer-dependent and is gated // separately in the send path, not here. var restrictedAlwaysBlockedMethods = map[string]struct{}{ "channels.joinChannel": {}, "messages.importChatInvite": {}, } func (r *Router) checkRestrictedRPC(ctx context.Context, method string) error { if r == nil || r.deps.AccountRestriction == nil { return nil } if _, blocked := restrictedAlwaysBlockedMethods[method]; !blocked { return nil } userID, authorized := UserIDFrom(ctx) if !authorized || userID == 0 { return nil } restriction, found, err := r.deps.AccountRestriction.AccountRestriction(ctx, userID) if err != nil { return internalErr() } if found && restriction.Restricted { return restrictedMethodInvalidErr() } return nil } // ensureNotRestrictedFromMessaging enforces the peer-dependent half of the // spam restriction: a restricted sender may still message existing contacts, // but not start a new conversation with a peer outside their own contact // list. Unlike checkRestrictedRPC this cannot be a method-name gate, since the // same method (messages.sendMessage) is allowed or blocked depending on who // the peer is. func (r *Router) ensureNotRestrictedFromMessaging(ctx context.Context, senderUserID, recipientUserID int64) error { if r == nil || r.deps.AccountRestriction == nil || senderUserID == 0 || recipientUserID == 0 || senderUserID == recipientUserID { return nil } restriction, found, err := r.deps.AccountRestriction.AccountRestriction(ctx, senderUserID) if err != nil { return internalErr() } if !found || !restriction.Restricted { return nil } if r.deps.Contacts == nil { return restrictedNoncontactErr() } isContact, err := r.deps.Contacts.IsContact(ctx, senderUserID, recipientUserID) if err != nil { return internalErr() } if isContact { return nil } return restrictedNoncontactErr() } func (r *Router) checkFrozenRPC(ctx context.Context, method string) error { if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) { return nil } userID, authorized := UserIDFrom(ctx) if !authorized || userID == 0 { return nil } freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID) if err != nil { return internalErr() } if found && freeze.Frozen { return frozenMethodInvalidErr() } return nil } // checkFrozenChannelParticipants implements Telegram's narrower read rule for // the methods documented with FROZEN_PARTICIPANT_MISSING: an account freeze // does not hide joined channels, but it removes public/linked guest preview. func (r *Router) checkFrozenChannelParticipants(ctx context.Context, userID int64, channelIDs ...int64) error { if r == nil || r.deps.AccountFreeze == nil || r.deps.Channels == nil || userID == 0 || len(channelIDs) == 0 { return nil } freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID) if err != nil { return internalErr() } if !found || !freeze.Frozen { return nil } seen := make(map[int64]struct{}, len(channelIDs)) for _, channelID := range channelIDs { if channelID <= 0 { continue } if _, duplicate := seen[channelID]; duplicate { continue } seen[channelID] = struct{}{} view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID) if err != nil { return channelInvalidErr(err) } if view.Self.UserID != userID || view.Self.Status != domain.ChannelMemberActive || view.Self.Guest { return frozenParticipantMissingErr() } } return nil }