Adds a narrower spam sanction alongside the existing account freeze: a restricted account keeps every existing membership and conversation, but cannot join new channels/groups (public join or invite link) and cannot start a new conversation with a non-contact. Reachable both as a standalone admin action and as a decision on a reported user's moderation case, with the same idempotent-supersession and appeal wiring freeze already has.
191 lines
6.3 KiB
Go
191 lines
6.3 KiB
Go
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
|
|
}
|