owpengram-server/internal/rpc/frozen_gate.go
Astra f33e25af8d admin: add account spam restriction (join/message gate)
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.
2026-09-16 14:03:15 +01:00

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
}