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:
parent
3ca8ef1a16
commit
f33e25af8d
32 changed files with 750 additions and 44 deletions
|
|
@ -550,6 +550,12 @@ type AccountFreezeService interface {
|
|||
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
||||
}
|
||||
|
||||
// AccountRestrictionService exposes the narrower spam-restriction fact used
|
||||
// by the central RPC join gate and the private-message send path.
|
||||
type AccountRestrictionService interface {
|
||||
AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error)
|
||||
}
|
||||
|
||||
// AccountFreezeNotificationService owns the durable non-PTS notification
|
||||
// queue. It is intentionally separate from AccountFreezeService so hot
|
||||
// read-only gates can use a versioned fact cache without disabling queue
|
||||
|
|
@ -607,6 +613,10 @@ type UserEmojiStatusUpdatesService interface {
|
|||
type ContactsService interface {
|
||||
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
|
||||
ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error)
|
||||
// IsContact reports whether peerUserID is in userID's own contact list.
|
||||
// Used by the account-restriction send gate, which is a one-directional
|
||||
// check unrelated to the recipient's privacy settings.
|
||||
IsContact(ctx context.Context, userID, peerUserID int64) (bool, error)
|
||||
AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
|
||||
AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error)
|
||||
ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error)
|
||||
|
|
@ -1149,6 +1159,7 @@ type Deps struct {
|
|||
AppUpdates updatecdn.Resolver
|
||||
AccountFreeze AccountFreezeService
|
||||
AccountFreezeNotifications AccountFreezeNotificationService
|
||||
AccountRestriction AccountRestrictionService
|
||||
AICompose AIComposeService
|
||||
Ephemeral EphemeralService
|
||||
EphemeralPush store.EphemeralPushBroker
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@ func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") }
|
|||
func frozenMethodInvalidErr() error { return tgerr.New(420, "FROZEN_METHOD_INVALID") }
|
||||
func frozenParticipantMissingErr() error { return tgerr.New(400, "FROZEN_PARTICIPANT_MISSING") }
|
||||
|
||||
// restrictedMethodInvalidErr covers a spam-restricted account attempting to
|
||||
// join a new channel/group (public join or private invite link).
|
||||
func restrictedMethodInvalidErr() error { return tgerr.New(420, "USER_RESTRICTED") }
|
||||
|
||||
// restrictedNoncontactErr covers a spam-restricted account attempting to
|
||||
// start a new conversation with a peer that is not in its contacts.
|
||||
func restrictedNoncontactErr() error { return tgerr.New(403, "USER_RESTRICTED_NONCONTACT") }
|
||||
|
||||
func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") }
|
||||
|
||||
func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -309,6 +309,9 @@ func (r *Router) DispatchAdmitted(
|
|||
if err := r.checkFrozenRPC(ctx, method); err != nil {
|
||||
return nil, method, err
|
||||
}
|
||||
if err := r.checkRestrictedRPC(ctx, method); err != nil {
|
||||
return nil, method, err
|
||||
}
|
||||
if profileKnown && profileEvidenceFresh {
|
||||
r.maybeMarkSessionReceivesUpdates(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, toPeer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
absentIDs := make([]int, len(absentIndexes))
|
||||
absentRandomIDs := make([]int64, len(absentIndexes))
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
sendErr = peerIDInvalidErr()
|
||||
return nil, sendErr
|
||||
}
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||||
sendErr = err
|
||||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
idempotencyFingerprint, err := sendMessageIdempotencyFingerprint(req)
|
||||
if err != nil {
|
||||
sendErr = internalErr()
|
||||
|
|
|
|||
|
|
@ -809,6 +809,9 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int, meta *r
|
|||
if err := r.checkFrozenRPC(ctx, tlTypeName(id)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.checkRestrictedRPC(ctx, tlTypeName(id)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates
|
||||
// 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复:
|
||||
// 它重连后不重建同步基线(pts 在进程内存里),只发普通业务请求,置位
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
|||
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
|
@ -544,6 +547,9 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
|
|||
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
|
||||
for i, item := range req.MultiMedia {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue