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.
This commit is contained in:
Astra 2026-09-16 14:03:15 +01:00
parent 3ca8ef1a16
commit f33e25af8d
32 changed files with 750 additions and 44 deletions

View file

@ -77,6 +77,67 @@ func frozenMethodRequiresWriteGate(method string) bool {
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