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

@ -14,14 +14,21 @@ type accountFreezeFact struct {
found bool
}
type accountRestrictionFact struct {
value domain.AccountRestriction
found bool
}
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
// Contact/privacy/presence decisions remain outside and are evaluated after
// these facts are loaded.
type DurableUserProjectionFacts struct {
freezes AccountFreezeProvider
versions store.ReadModelVersionStore
freezes AccountFreezeProvider
restrictions AccountRestrictionProvider
versions store.ReadModelVersionStore
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
restrictionCache *readmodelcache.Cache[int64, accountRestrictionFact]
}
func NewDurableUserProjectionFacts(
@ -29,12 +36,20 @@ func NewDurableUserProjectionFacts(
versions store.ReadModelVersionStore,
maxEntries int,
) *DurableUserProjectionFacts {
// freezes optionally also implements AccountRestrictionProvider (the
// production admin.Service does); callers that only need freeze facts,
// such as tests, are unaffected.
restrictions, _ := freezes.(AccountRestrictionProvider)
return &DurableUserProjectionFacts{
freezes: freezes,
versions: versions,
freezes: freezes,
restrictions: restrictions,
versions: versions,
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
MaxEntries: maxEntries,
}),
restrictionCache: readmodelcache.New[int64, accountRestrictionFact](readmodelcache.Config[int64, accountRestrictionFact]{
MaxEntries: maxEntries,
}),
}
}
@ -94,6 +109,62 @@ func (f *DurableUserProjectionFacts) AccountFreeze(ctx context.Context, userID i
return value, found, nil
}
func (f *DurableUserProjectionFacts) AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) {
out := make(map[int64]domain.AccountRestriction)
ids := uniqueDurableFactUserIDs(userIDs)
if f == nil || f.restrictions == nil || len(ids) == 0 {
return out, nil
}
// Reuses the same user_visibility read model version as freeze facts:
// SetAccountRestriction bumps it on write, so a shared hash keeps both
// caches correctly invalidated without a separate model.
hashes, err := f.factHashes(ctx, readmodel.ModelUserVisibility, ids)
if err != nil {
return nil, err
}
loaded, err := f.restrictionCache.GetOrLoadBatch(ctx, ids,
func(userID int64) (int64, bool) {
hash := hashes[userID]
return hash, f.versions != nil && hash != 0
},
func(ctx context.Context, missing []int64) (map[int64]accountRestrictionFact, error) {
values, err := f.restrictions.AccountRestrictions(ctx, missing)
if err != nil {
return nil, err
}
entries := make(map[int64]accountRestrictionFact, len(missing))
for _, userID := range missing {
entry := accountRestrictionFact{}
if value, ok := values[userID]; ok {
entry = accountRestrictionFact{value: value, found: true}
}
entries[userID] = entry
}
return entries, nil
})
if err != nil {
return nil, err
}
for userID, entry := range loaded {
if entry.found {
out[userID] = entry.value
}
}
return out, nil
}
func (f *DurableUserProjectionFacts) AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) {
if userID == 0 {
return domain.AccountRestriction{}, false, nil
}
items, err := f.AccountRestrictions(ctx, []int64{userID})
if err != nil {
return domain.AccountRestriction{}, false, err
}
value, found := items[userID]
return value, found, nil
}
func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) {
out := make(map[int64]int64, len(userIDs))
if f == nil || f.versions == nil {
@ -119,9 +190,16 @@ func (f *DurableUserProjectionFacts) InvalidateAccountFreezeFact(userID int64) {
}
}
func (f *DurableUserProjectionFacts) InvalidateAccountRestrictionFact(userID int64) {
if f != nil && userID != 0 {
f.restrictionCache.Invalidate(userID)
}
}
func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() {
if f != nil {
f.freezeCache.Flush()
f.restrictionCache.Flush()
}
}