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
|
|
@ -303,6 +303,130 @@ func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
|
|||
return r, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) GetAccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at
|
||||
FROM account_message_restrictions
|
||||
WHERE user_id = $1`, userID)
|
||||
r, err := scanAccountRestriction(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRestriction{}, false, nil
|
||||
}
|
||||
return domain.AccountRestriction{}, false, fmt.Errorf("get account restriction: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) GetAccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) {
|
||||
out := make(map[int64]domain.AccountRestriction)
|
||||
if s == nil || s.db == nil || len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at
|
||||
FROM account_message_restrictions
|
||||
WHERE user_id = ANY($1::bigint[]) AND restricted = true`, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account restrictions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
restriction, err := scanAccountRestriction(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account restriction: %w", err)
|
||||
}
|
||||
out[restriction.UserID] = restriction
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account restrictions: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) SetAccountRestriction(ctx context.Context, restriction domain.AccountRestriction) (domain.AccountRestriction, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return setAccountRestrictionRow(ctx, s.db, restriction)
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountRestriction{}, fmt.Errorf("begin set account restriction: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
out, err := setAccountRestrictionRow(ctx, tx, restriction)
|
||||
if err != nil {
|
||||
return domain.AccountRestriction{}, err
|
||||
}
|
||||
// Reuses the same user_visibility read model as account freeze so the RPC
|
||||
// gate and user projection caches invalidate on the same signal.
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil {
|
||||
return domain.AccountRestriction{}, fmt.Errorf("bump restricted user visibility: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountRestriction{}, fmt.Errorf("commit set account restriction: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setAccountRestrictionRow(ctx context.Context, db sqlcgen.DBTX, restriction domain.AccountRestriction) (domain.AccountRestriction, error) {
|
||||
var since, until any
|
||||
if restriction.Restricted {
|
||||
since = restriction.Since
|
||||
if !restriction.Until.IsZero() {
|
||||
until = restriction.Until
|
||||
}
|
||||
}
|
||||
row := db.QueryRow(ctx, `
|
||||
INSERT INTO account_message_restrictions (
|
||||
user_id, restricted, restricted_since, restricted_until, reason, actor, command_id, updated_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
restricted = EXCLUDED.restricted,
|
||||
restricted_since = EXCLUDED.restricted_since,
|
||||
restricted_until = EXCLUDED.restricted_until,
|
||||
reason = EXCLUDED.reason,
|
||||
actor = EXCLUDED.actor,
|
||||
command_id = EXCLUDED.command_id,
|
||||
version = account_message_restrictions.version + 1,
|
||||
updated_at = now()
|
||||
RETURNING user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at`,
|
||||
restriction.UserID, restriction.Restricted, since, until, restriction.Reason, restriction.Actor, restriction.CommandID,
|
||||
)
|
||||
out, err := scanAccountRestriction(row)
|
||||
if err != nil {
|
||||
return domain.AccountRestriction{}, fmt.Errorf("set account restriction: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanAccountRestriction(row accountFreezeScanner) (domain.AccountRestriction, error) {
|
||||
var r domain.AccountRestriction
|
||||
var since, until pgtype.Timestamptz
|
||||
var updated time.Time
|
||||
if err := row.Scan(
|
||||
&r.UserID, &r.Restricted, &r.Version, &since, &until,
|
||||
&r.Reason, &r.Actor, &r.CommandID, &updated,
|
||||
); err != nil {
|
||||
return domain.AccountRestriction{}, err
|
||||
}
|
||||
if since.Valid {
|
||||
r.Since = since.Time
|
||||
}
|
||||
if until.Valid {
|
||||
r.Until = until.Time
|
||||
}
|
||||
r.UpdatedAt = updated
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
|
||||
const maxAccountFreezeNotificationAudience = 4096
|
||||
_, err := tx.Exec(ctx, `
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue