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
|
|
@ -24,6 +24,7 @@ import (
|
|||
|
||||
const (
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionSetAccountRestricted = "account.set_restricted"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetUserFlags = "account.set_flags"
|
||||
|
|
@ -179,6 +180,12 @@ type CommandRepository interface {
|
|||
type RestrictionStore interface {
|
||||
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
||||
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
|
||||
GetAccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error)
|
||||
SetAccountRestriction(ctx context.Context, restriction domain.AccountRestriction) (domain.AccountRestriction, error)
|
||||
}
|
||||
|
||||
type accountRestrictionBatchStore interface {
|
||||
GetAccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error)
|
||||
}
|
||||
|
||||
type accountFreezeBatchStore interface {
|
||||
|
|
@ -825,6 +832,13 @@ type SetAccountFrozenRequest struct {
|
|||
AppealURL string `json:"freeze_appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type SetAccountRestrictedRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Restricted bool `json:"restricted"`
|
||||
Until time.Time `json:"restricted_until,omitempty"`
|
||||
}
|
||||
|
||||
type GrantPremiumRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
|
|
@ -1272,6 +1286,134 @@ func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequ
|
|||
})
|
||||
}
|
||||
|
||||
// AccountRestriction returns the durable narrower spam-restriction state. A
|
||||
// missing row is the only non-restricted default.
|
||||
func (s *Service) AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) {
|
||||
if s == nil || s.restrictions == nil || userID == 0 {
|
||||
return domain.AccountRestriction{}, false, nil
|
||||
}
|
||||
restriction, found, err := s.restrictions.GetAccountRestriction(ctx, userID)
|
||||
if err != nil || !found {
|
||||
return restriction, found, err
|
||||
}
|
||||
if err := validateAccountRestriction(restriction); err != nil {
|
||||
return domain.AccountRestriction{}, false, fmt.Errorf("invalid durable account restriction for user %d: %w", userID, err)
|
||||
}
|
||||
return restriction, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) {
|
||||
out := make(map[int64]domain.AccountRestriction)
|
||||
if s == nil || s.restrictions == nil || len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ids := uniqueFreezeUserIDs(userIDs)
|
||||
if batch, ok := s.restrictions.(accountRestrictionBatchStore); ok {
|
||||
const batchSize = 1000
|
||||
for start := 0; start < len(ids); start += batchSize {
|
||||
end := min(start+batchSize, len(ids))
|
||||
items, err := batch.GetAccountRestrictions(ctx, ids[start:end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, restriction := range items {
|
||||
if err := validateAccountRestriction(restriction); err != nil {
|
||||
return nil, fmt.Errorf("invalid durable account restriction for user %d: %w", id, err)
|
||||
}
|
||||
if restriction.Restricted {
|
||||
out[id] = restriction
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for _, id := range ids {
|
||||
restriction, found, err := s.AccountRestriction(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found && restriction.Restricted {
|
||||
out[id] = restriction
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateAccountRestriction(restriction domain.AccountRestriction) error {
|
||||
if !restriction.Restricted {
|
||||
if !restriction.Since.IsZero() || !restriction.Until.IsZero() {
|
||||
return fmt.Errorf("inactive restriction retains client-visible state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if restriction.Since.IsZero() || restriction.Since.Unix() <= 0 {
|
||||
return fmt.Errorf("active restriction has invalid since")
|
||||
}
|
||||
if !restriction.Until.IsZero() && (!restriction.Until.After(restriction.Since) || restriction.Until.Unix() > math.MaxInt32) {
|
||||
return fmt.Errorf("active restriction has invalid until")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAccountRestricted(ctx context.Context, req SetAccountRestrictedRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.restrictions == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if req.Restricted && !req.Until.IsZero() && req.Until.Unix() > math.MaxInt32 {
|
||||
return CommandResult{}, fmt.Errorf("restricted_until must be a valid int32 Unix timestamp")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountRestricted, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
if req.Restricted && !req.Until.IsZero() && !req.Until.After(now) {
|
||||
return CommandResult{}, fmt.Errorf("restricted_until must be in the future")
|
||||
}
|
||||
prev, found, err := s.restrictions.GetAccountRestriction(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
next := domain.AccountRestriction{
|
||||
UserID: req.UserID,
|
||||
Restricted: req.Restricted,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandID: req.CommandID,
|
||||
}
|
||||
if req.Restricted {
|
||||
next.Since = now
|
||||
if found && prev.Restricted {
|
||||
next.Since = prev.Since
|
||||
}
|
||||
next.Until = req.Until.UTC()
|
||||
}
|
||||
wouldChange := !found || prev.Restricted != next.Restricted ||
|
||||
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until)
|
||||
details := map[string]any{
|
||||
"previous_restricted": found && prev.Restricted,
|
||||
"new_restricted": req.Restricted,
|
||||
"would_change": wouldChange,
|
||||
}
|
||||
if req.Restricted {
|
||||
details["restricted_since"] = next.Since.Format(time.RFC3339)
|
||||
if !next.Until.IsZero() {
|
||||
details["restricted_until"] = next.Until.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.restrictions.SetAccountRestriction(ctx, next)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
details["version"] = updated.Version
|
||||
return CommandResult{Message: "account restriction updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
|
|
|
|||
|
|
@ -576,8 +576,10 @@ func (f *fakeBotService) AdminExportBotToken(_ context.Context, botUserID int64)
|
|||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
restrictionItems map[int64]domain.AccountRestriction
|
||||
restrictionSetCall int
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
|
|
@ -599,6 +601,25 @@ func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.Acco
|
|||
return r, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) GetAccountRestriction(_ context.Context, userID int64) (domain.AccountRestriction, bool, error) {
|
||||
if f.restrictionItems == nil {
|
||||
return domain.AccountRestriction{}, false, nil
|
||||
}
|
||||
r, ok := f.restrictionItems[userID]
|
||||
return r, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) SetAccountRestriction(_ context.Context, r domain.AccountRestriction) (domain.AccountRestriction, error) {
|
||||
if f.restrictionItems == nil {
|
||||
f.restrictionItems = map[int64]domain.AccountRestriction{}
|
||||
}
|
||||
f.restrictionSetCall++
|
||||
r.Version = f.restrictionItems[r.UserID].Version + 1
|
||||
r.UpdatedAt = fixedNow()
|
||||
f.restrictionItems[r.UserID] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type fakeBatchRestrictionStore struct {
|
||||
fakeRestrictionStore
|
||||
requests [][]int64
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue