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
|
|
@ -1143,6 +1143,7 @@ func run(logger *zap.Logger) error {
|
||||||
contacts.WithPhotoProvider(cachedPhotos),
|
contacts.WithPhotoProvider(cachedPhotos),
|
||||||
contacts.WithPrivacyEvaluator(privacyService),
|
contacts.WithPrivacyEvaluator(privacyService),
|
||||||
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
|
contacts.WithAccountRestrictionProvider(userProjectionFacts),
|
||||||
contacts.WithReadModelVersions(readModelVersionStore),
|
contacts.WithReadModelVersions(readModelVersionStore),
|
||||||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||||||
)
|
)
|
||||||
|
|
@ -1370,7 +1371,7 @@ func run(logger *zap.Logger) error {
|
||||||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithAccountRestrictionProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||||||
privacyService.ConfigureReadModels(usersService, channelStore)
|
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||||
botsService.SetAIChatGenerator(aiComposeService)
|
botsService.SetAIChatGenerator(aiComposeService)
|
||||||
|
|
@ -1379,6 +1380,7 @@ func run(logger *zap.Logger) error {
|
||||||
dialogs.WithPhotoProvider(cachedPhotos),
|
dialogs.WithPhotoProvider(cachedPhotos),
|
||||||
dialogs.WithPrivacyEvaluator(privacyService),
|
dialogs.WithPrivacyEvaluator(privacyService),
|
||||||
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
|
dialogs.WithAccountRestrictionProvider(userProjectionFacts),
|
||||||
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
||||||
dialogs.WithReadModelVersions(readModelVersionStore),
|
dialogs.WithReadModelVersions(readModelVersionStore),
|
||||||
dialogs.WithDialogHydrationCaches(
|
dialogs.WithDialogHydrationCaches(
|
||||||
|
|
@ -1426,6 +1428,7 @@ func run(logger *zap.Logger) error {
|
||||||
messageapp.WithPhotoProvider(cachedPhotos),
|
messageapp.WithPhotoProvider(cachedPhotos),
|
||||||
messageapp.WithPrivacyEvaluator(privacyService),
|
messageapp.WithPrivacyEvaluator(privacyService),
|
||||||
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||||||
|
messageapp.WithAccountRestrictionProvider(userProjectionFacts),
|
||||||
messageapp.WithReadModelVersions(readModelVersionStore),
|
messageapp.WithReadModelVersions(readModelVersionStore),
|
||||||
messageapp.WithBotResponder(botsService),
|
messageapp.WithBotResponder(botsService),
|
||||||
messageapp.WithSendPermissionChecker(adminService),
|
messageapp.WithSendPermissionChecker(adminService),
|
||||||
|
|
@ -1621,6 +1624,7 @@ func run(logger *zap.Logger) error {
|
||||||
AppUpdates: appUpdateResolver,
|
AppUpdates: appUpdateResolver,
|
||||||
AccountFreeze: userProjectionFacts,
|
AccountFreeze: userProjectionFacts,
|
||||||
AccountFreezeNotifications: adminService,
|
AccountFreezeNotifications: adminService,
|
||||||
|
AccountRestriction: userProjectionFacts,
|
||||||
AICompose: aiComposeService,
|
AICompose: aiComposeService,
|
||||||
Ephemeral: ephemeralService,
|
Ephemeral: ephemeralService,
|
||||||
EphemeralPush: ephemeralStore,
|
EphemeralPush: ephemeralStore,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS account_message_restrictions;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS account_message_restrictions (
|
||||||
|
user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
restricted boolean NOT NULL DEFAULT false,
|
||||||
|
version bigint NOT NULL DEFAULT 0,
|
||||||
|
restricted_since timestamptz,
|
||||||
|
restricted_until timestamptz,
|
||||||
|
reason text NOT NULL DEFAULT '',
|
||||||
|
actor text NOT NULL DEFAULT '',
|
||||||
|
command_id text NOT NULL DEFAULT '',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT account_message_restrictions_user_id_check CHECK (user_id > 0),
|
||||||
|
CONSTRAINT account_message_restrictions_shape_check CHECK (
|
||||||
|
(restricted AND restricted_since IS NOT NULL
|
||||||
|
AND (restricted_until IS NULL OR restricted_until > restricted_since)
|
||||||
|
AND (restricted_until IS NULL OR restricted_until <= to_timestamp(2147483647)))
|
||||||
|
OR
|
||||||
|
(NOT restricted AND restricted_since IS NULL AND restricted_until IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS account_message_restrictions_restricted_idx
|
||||||
|
ON account_message_restrictions (user_id)
|
||||||
|
WHERE restricted;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
ALTER TABLE public.moderation_actions
|
||||||
|
DROP CONSTRAINT moderation_actions_kind_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.moderation_actions
|
||||||
|
ADD CONSTRAINT moderation_actions_kind_check CHECK (kind IN (
|
||||||
|
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
|
||||||
|
'unfreeze_account', 'delete_private_message',
|
||||||
|
'delete_channel_message', 'delete_account'
|
||||||
|
));
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
ALTER TABLE public.moderation_actions
|
||||||
|
DROP CONSTRAINT moderation_actions_kind_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.moderation_actions
|
||||||
|
ADD CONSTRAINT moderation_actions_kind_check CHECK (kind IN (
|
||||||
|
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
|
||||||
|
'unfreeze_account', 'restrict_account', 'unrestrict_account',
|
||||||
|
'delete_private_message', 'delete_channel_message', 'delete_account'
|
||||||
|
));
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ActionSetAccountFrozen = "account.set_frozen"
|
ActionSetAccountFrozen = "account.set_frozen"
|
||||||
|
ActionSetAccountRestricted = "account.set_restricted"
|
||||||
ActionGrantPremium = "account.grant_premium"
|
ActionGrantPremium = "account.grant_premium"
|
||||||
ActionSetVerified = "account.set_verified"
|
ActionSetVerified = "account.set_verified"
|
||||||
ActionSetUserFlags = "account.set_flags"
|
ActionSetUserFlags = "account.set_flags"
|
||||||
|
|
@ -179,6 +180,12 @@ type CommandRepository interface {
|
||||||
type RestrictionStore interface {
|
type RestrictionStore interface {
|
||||||
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
||||||
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, 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 {
|
type accountFreezeBatchStore interface {
|
||||||
|
|
@ -825,6 +832,13 @@ type SetAccountFrozenRequest struct {
|
||||||
AppealURL string `json:"freeze_appeal_url,omitempty"`
|
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 {
|
type GrantPremiumRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
UserID int64 `json:"user_id"`
|
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) {
|
func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) {
|
||||||
if req.UserID <= 0 {
|
if req.UserID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||||
|
|
|
||||||
|
|
@ -576,8 +576,10 @@ func (f *fakeBotService) AdminExportBotToken(_ context.Context, botUserID int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeRestrictionStore struct {
|
type fakeRestrictionStore struct {
|
||||||
items map[int64]domain.AccountFreeze
|
items map[int64]domain.AccountFreeze
|
||||||
setCalls int
|
setCalls int
|
||||||
|
restrictionItems map[int64]domain.AccountRestriction
|
||||||
|
restrictionSetCall int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
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
|
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 {
|
type fakeBatchRestrictionStore struct {
|
||||||
fakeRestrictionStore
|
fakeRestrictionStore
|
||||||
requests [][]int64
|
requests [][]int64
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type Config struct {
|
||||||
type Service interface {
|
type Service interface {
|
||||||
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
|
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
|
||||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||||
|
SetAccountRestricted(ctx context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error)
|
||||||
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
|
||||||
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error)
|
||||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||||
|
|
@ -182,6 +183,7 @@ func (s *Server) routes() http.Handler {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
|
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
|
||||||
|
mux.HandleFunc("POST /v1/accounts/set-restricted", s.authenticated(s.handleSetAccountRestricted))
|
||||||
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
|
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
|
||||||
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
|
||||||
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
|
||||||
|
|
@ -309,6 +311,15 @@ func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request)
|
||||||
writeCommandResult(w, result, err)
|
writeCommandResult(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetAccountRestricted(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req admin.SetAccountRestrictedRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.SetAccountRestricted(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) {
|
||||||
var req admin.GrantPremiumRequest
|
var req admin.GrantPremiumRequest
|
||||||
if !decodeJSON(w, r, &req) {
|
if !decodeJSON(w, r, &req) {
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,10 @@ func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.Set
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeService) SetAccountRestricted(_ context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,15 @@ type phonePrivacyService interface {
|
||||||
|
|
||||||
// Service 提供通讯录查询。
|
// Service 提供通讯录查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
users store.UserStore
|
users store.UserStore
|
||||||
photos userprojection.ProfilePhotoProvider
|
photos userprojection.ProfilePhotoProvider
|
||||||
privacy phonePrivacyService
|
privacy phonePrivacyService
|
||||||
freezes userprojection.AccountFreezeProvider
|
freezes userprojection.AccountFreezeProvider
|
||||||
projector *userprojection.Projector
|
restrictions userprojection.AccountRestrictionProvider
|
||||||
versions store.ReadModelVersionStore
|
projector *userprojection.Projector
|
||||||
cache *contactListReadModelCache
|
versions store.ReadModelVersionStore
|
||||||
|
cache *contactListReadModelCache
|
||||||
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||||
// while true, Search never returns @marksbot (domain.VerifierBotUserID),
|
// while true, Search never returns @marksbot (domain.VerifierBotUserID),
|
||||||
// so the account is unreachable for a client that doesn't already know it.
|
// so the account is unreachable for a client that doesn't already know it.
|
||||||
|
|
@ -59,6 +60,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||||
return func(s *Service) { s.freezes = p }
|
return func(s *Service) { s.freezes = p }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||||
|
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||||
|
return func(s *Service) { s.restrictions = p }
|
||||||
|
}
|
||||||
|
|
||||||
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||||
// while true, Search filters @marksbot out of every result set.
|
// while true, Search filters @marksbot out of every result set.
|
||||||
func WithHideThirdPartyVerification(hidden bool) Option {
|
func WithHideThirdPartyVerification(hidden bool) Option {
|
||||||
|
|
@ -101,6 +107,7 @@ func (s *Service) rebuildProjector() {
|
||||||
userprojection.WithPhotoProvider(s.photos),
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||||
|
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +133,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
|
||||||
return list, false, nil
|
return list, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsContact reports whether contactUserID is in userID's own contact list.
|
||||||
|
func (s *Service) IsContact(ctx context.Context, userID, contactUserID int64) (bool, error) {
|
||||||
|
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_, found, err := s.contacts.Get(ctx, userID, contactUserID)
|
||||||
|
return found, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||||
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
||||||
return domain.Contact{}, ErrContactIDInvalid
|
return domain.Contact{}, ErrContactIDInvalid
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type Service struct {
|
||||||
photos userprojection.ProfilePhotoProvider
|
photos userprojection.ProfilePhotoProvider
|
||||||
privacy userprojection.PrivacyEvaluator
|
privacy userprojection.PrivacyEvaluator
|
||||||
freezes userprojection.AccountFreezeProvider
|
freezes userprojection.AccountFreezeProvider
|
||||||
|
restrictions userprojection.AccountRestrictionProvider
|
||||||
premium PremiumChecker
|
premium PremiumChecker
|
||||||
projector *userprojection.Projector
|
projector *userprojection.Projector
|
||||||
versions store.ReadModelVersionStore
|
versions store.ReadModelVersionStore
|
||||||
|
|
@ -63,6 +64,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||||
return func(s *Service) { s.freezes = p }
|
return func(s *Service) { s.freezes = p }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||||
|
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||||
|
return func(s *Service) { s.restrictions = p }
|
||||||
|
}
|
||||||
|
|
||||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||||
return func(s *Service) { s.versions = v }
|
return func(s *Service) { s.versions = v }
|
||||||
|
|
@ -132,6 +138,7 @@ func (s *Service) rebuildProjector() {
|
||||||
userprojection.WithPhotoProvider(s.photos),
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||||
|
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@ import (
|
||||||
|
|
||||||
// Service 提供消息历史、搜索与已读业务。
|
// Service 提供消息历史、搜索与已读业务。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
messages store.MessageStore
|
messages store.MessageStore
|
||||||
dialogs store.DialogStore
|
dialogs store.DialogStore
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
photos userprojection.ProfilePhotoProvider
|
photos userprojection.ProfilePhotoProvider
|
||||||
privacy userprojection.PrivacyEvaluator
|
privacy userprojection.PrivacyEvaluator
|
||||||
freezes userprojection.AccountFreezeProvider
|
freezes userprojection.AccountFreezeProvider
|
||||||
versions store.ReadModelVersionStore
|
restrictions userprojection.AccountRestrictionProvider
|
||||||
projector *userprojection.Projector
|
versions store.ReadModelVersionStore
|
||||||
|
projector *userprojection.Projector
|
||||||
// viewerProjectionComplete is true only when every viewer-scoped user
|
// viewerProjectionComplete is true only when every viewer-scoped user
|
||||||
// overlay used by the shared RPC Users service is configured here too.
|
// overlay used by the shared RPC Users service is configured here too.
|
||||||
// A partially configured service may still project the dependencies it has,
|
// A partially configured service may still project the dependencies it has,
|
||||||
|
|
@ -67,6 +68,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||||
return func(s *Service) { s.freezes = p }
|
return func(s *Service) { s.freezes = p }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||||
|
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||||
|
return func(s *Service) { s.restrictions = p }
|
||||||
|
}
|
||||||
|
|
||||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||||
func WithBotResponder(r BotResponder) Option {
|
func WithBotResponder(r BotResponder) Option {
|
||||||
return func(s *Service) { s.botResponder = r }
|
return func(s *Service) { s.botResponder = r }
|
||||||
|
|
@ -96,6 +102,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
||||||
userprojection.WithPhotoProvider(s.photos),
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||||
|
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||||
)
|
)
|
||||||
s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil
|
s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil
|
||||||
return s
|
return s
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
|
|
||||||
type moderationAdminActions interface {
|
type moderationAdminActions interface {
|
||||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||||
|
SetAccountRestricted(ctx context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error)
|
||||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||||
|
|
@ -99,6 +100,10 @@ type freezeAccountActionPayload struct {
|
||||||
AppealURL string `json:"appeal_url,omitempty"`
|
AppealURL string `json:"appeal_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type restrictAccountActionPayload struct {
|
||||||
|
Until time.Time `json:"until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type deletePrivateMessageActionPayload struct {
|
type deletePrivateMessageActionPayload struct {
|
||||||
OwnerUserID int64 `json:"owner_user_id"`
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
IDs []int `json:"ids"`
|
IDs []int `json:"ids"`
|
||||||
|
|
@ -181,6 +186,23 @@ func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCa
|
||||||
Until: payload.Until, AppealURL: payload.AppealURL,
|
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
|
case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount:
|
||||||
|
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload restrictAccountActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
restricted := action.Kind == domain.ModerationActionRestrictAccount
|
||||||
|
if !restricted && !payload.Until.IsZero() {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
_, err := e.admin.SetAccountRestricted(ctx, admin.SetAccountRestrictedRequest{
|
||||||
|
CommandMeta: meta, UserID: detail.Case.Target.ID, Restricted: restricted,
|
||||||
|
Until: payload.Until,
|
||||||
|
})
|
||||||
|
return err
|
||||||
case domain.ModerationActionDeletePrivateMessage:
|
case domain.ModerationActionDeletePrivateMessage:
|
||||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
|
|
@ -252,6 +274,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
||||||
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||||
flagActions := 0
|
flagActions := 0
|
||||||
freezeActions := 0
|
freezeActions := 0
|
||||||
|
restrictActions := 0
|
||||||
hasDeleteAccount := false
|
hasDeleteAccount := false
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
if _, duplicate := seen[action.Kind]; duplicate {
|
if _, duplicate := seen[action.Kind]; duplicate {
|
||||||
|
|
@ -278,6 +301,18 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
||||||
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
}
|
}
|
||||||
|
case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount:
|
||||||
|
restrictActions++
|
||||||
|
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload restrictAccountActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if action.Kind == domain.ModerationActionUnrestrictAccount && !payload.Until.IsZero() {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
case domain.ModerationActionDeletePrivateMessage:
|
case domain.ModerationActionDeletePrivateMessage:
|
||||||
var payload deletePrivateMessageActionPayload
|
var payload deletePrivateMessageActionPayload
|
||||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
|
@ -311,7 +346,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if flagActions > 1 || freezeActions > 1 ||
|
if flagActions > 1 || freezeActions > 1 || restrictActions > 1 ||
|
||||||
(hasDeleteAccount && len(actions) != 1) {
|
(hasDeleteAccount && len(actions) != 1) {
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type captureModerationAdmin struct {
|
type captureModerationAdmin struct {
|
||||||
userFlags []admin.SetUserFlagsRequest
|
userFlags []admin.SetUserFlagsRequest
|
||||||
frozen []admin.SetAccountFrozenRequest
|
frozen []admin.SetAccountFrozenRequest
|
||||||
|
restricted []admin.SetAccountRestrictedRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type captureModerationAccountDeleter struct {
|
type captureModerationAccountDeleter struct {
|
||||||
|
|
@ -42,6 +43,11 @@ func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.S
|
||||||
return admin.CommandResult{}, nil
|
return admin.CommandResult{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *captureModerationAdmin) SetAccountRestricted(_ context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) {
|
||||||
|
a.restricted = append(a.restricted, req)
|
||||||
|
return admin.CommandResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||||
a.userFlags = append(a.userFlags, req)
|
a.userFlags = append(a.userFlags, req)
|
||||||
return admin.CommandResult{}, nil
|
return admin.CommandResult{}, nil
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDec
|
||||||
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||||
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||||
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||||
var flagsActive, freezeActive, irreversible bool
|
var flagsActive, freezeActive, restrictActive, irreversible bool
|
||||||
for _, action := range history {
|
for _, action := range history {
|
||||||
if action.Status != domain.ModerationActionSucceeded {
|
if action.Status != domain.ModerationActionSucceeded {
|
||||||
continue
|
continue
|
||||||
|
|
@ -148,6 +148,10 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d
|
||||||
freezeActive = true
|
freezeActive = true
|
||||||
case domain.ModerationActionUnfreezeAccount:
|
case domain.ModerationActionUnfreezeAccount:
|
||||||
freezeActive = false
|
freezeActive = false
|
||||||
|
case domain.ModerationActionRestrictAccount:
|
||||||
|
restrictActive = true
|
||||||
|
case domain.ModerationActionUnrestrictAccount:
|
||||||
|
restrictActive = false
|
||||||
case domain.ModerationActionDeletePrivateMessage,
|
case domain.ModerationActionDeletePrivateMessage,
|
||||||
domain.ModerationActionDeleteChannelMessage,
|
domain.ModerationActionDeleteChannelMessage,
|
||||||
domain.ModerationActionDeleteAccount:
|
domain.ModerationActionDeleteAccount:
|
||||||
|
|
@ -157,13 +161,16 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d
|
||||||
if irreversible {
|
if irreversible {
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
}
|
}
|
||||||
expected := make(map[domain.ModerationActionKind]bool, 2)
|
expected := make(map[domain.ModerationActionKind]bool, 3)
|
||||||
if flagsActive {
|
if flagsActive {
|
||||||
expected[domain.ModerationActionClearPeerFlags] = true
|
expected[domain.ModerationActionClearPeerFlags] = true
|
||||||
}
|
}
|
||||||
if freezeActive {
|
if freezeActive {
|
||||||
expected[domain.ModerationActionUnfreezeAccount] = true
|
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||||
}
|
}
|
||||||
|
if restrictActive {
|
||||||
|
expected[domain.ModerationActionUnrestrictAccount] = true
|
||||||
|
}
|
||||||
if len(actions) != len(expected) {
|
if len(actions) != len(expected) {
|
||||||
return domain.ErrModerationActionInvalid
|
return domain.ErrModerationActionInvalid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,21 @@ type accountFreezeFact struct {
|
||||||
found bool
|
found bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type accountRestrictionFact struct {
|
||||||
|
value domain.AccountRestriction
|
||||||
|
found bool
|
||||||
|
}
|
||||||
|
|
||||||
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
|
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
|
||||||
// Contact/privacy/presence decisions remain outside and are evaluated after
|
// Contact/privacy/presence decisions remain outside and are evaluated after
|
||||||
// these facts are loaded.
|
// these facts are loaded.
|
||||||
type DurableUserProjectionFacts struct {
|
type DurableUserProjectionFacts struct {
|
||||||
freezes AccountFreezeProvider
|
freezes AccountFreezeProvider
|
||||||
versions store.ReadModelVersionStore
|
restrictions AccountRestrictionProvider
|
||||||
|
versions store.ReadModelVersionStore
|
||||||
|
|
||||||
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
|
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
|
||||||
|
restrictionCache *readmodelcache.Cache[int64, accountRestrictionFact]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDurableUserProjectionFacts(
|
func NewDurableUserProjectionFacts(
|
||||||
|
|
@ -29,12 +36,20 @@ func NewDurableUserProjectionFacts(
|
||||||
versions store.ReadModelVersionStore,
|
versions store.ReadModelVersionStore,
|
||||||
maxEntries int,
|
maxEntries int,
|
||||||
) *DurableUserProjectionFacts {
|
) *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{
|
return &DurableUserProjectionFacts{
|
||||||
freezes: freezes,
|
freezes: freezes,
|
||||||
versions: versions,
|
restrictions: restrictions,
|
||||||
|
versions: versions,
|
||||||
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
|
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
|
||||||
MaxEntries: maxEntries,
|
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
|
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) {
|
func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) {
|
||||||
out := make(map[int64]int64, len(userIDs))
|
out := make(map[int64]int64, len(userIDs))
|
||||||
if f == nil || f.versions == nil {
|
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() {
|
func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() {
|
||||||
if f != nil {
|
if f != nil {
|
||||||
f.freezeCache.Flush()
|
f.freezeCache.Flush()
|
||||||
|
f.restrictionCache.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ type AccountFreezeProvider interface {
|
||||||
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountRestrictionProvider returns durable narrower spam-restriction facts
|
||||||
|
// for a bounded batch. Like AccountFreezeProvider, the projector only exposes
|
||||||
|
// them to viewers other than the restricted user.
|
||||||
|
type AccountRestrictionProvider interface {
|
||||||
|
AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error)
|
||||||
|
}
|
||||||
|
|
||||||
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
||||||
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
||||||
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
||||||
|
|
@ -56,10 +63,11 @@ var privacyProjectionKeys = []domain.PrivacyKey{
|
||||||
// Projector builds the current viewer's user view for RPC response payloads.
|
// Projector builds the current viewer's user view for RPC response payloads.
|
||||||
// It intentionally stays in app/domain types; tg.* conversion remains in rpc.
|
// It intentionally stays in app/domain types; tg.* conversion remains in rpc.
|
||||||
type Projector struct {
|
type Projector struct {
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
photos ProfilePhotoProvider
|
photos ProfilePhotoProvider
|
||||||
privacy PrivacyEvaluator
|
privacy PrivacyEvaluator
|
||||||
freezes AccountFreezeProvider
|
freezes AccountFreezeProvider
|
||||||
|
restrictions AccountRestrictionProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures a Projector.
|
// Option configures a Projector.
|
||||||
|
|
@ -85,6 +93,12 @@ func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
||||||
return func(p *Projector) { p.freezes = provider }
|
return func(p *Projector) { p.freezes = provider }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountRestrictionProvider enables viewer-scoped restricted-account
|
||||||
|
// visibility (the narrower spam restriction).
|
||||||
|
func WithAccountRestrictionProvider(provider AccountRestrictionProvider) Option {
|
||||||
|
return func(p *Projector) { p.restrictions = provider }
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a user projector.
|
// New creates a user projector.
|
||||||
func New(opts ...Option) *Projector {
|
func New(opts ...Option) *Projector {
|
||||||
p := &Projector{}
|
p := &Projector{}
|
||||||
|
|
@ -100,7 +114,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users)
|
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, p.restrictions, viewerUserID, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One applies ForViewer to a single user.
|
// One applies ForViewer to a single user.
|
||||||
|
|
@ -149,6 +163,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
||||||
personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef
|
personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef
|
||||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||||
freezes map[int64]domain.AccountFreeze
|
freezes map[int64]domain.AccountFreeze
|
||||||
|
restrictions map[int64]domain.AccountRestriction
|
||||||
)
|
)
|
||||||
g, gctx := errgroup.WithContext(ctx)
|
g, gctx := errgroup.WithContext(ctx)
|
||||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用。
|
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用。
|
||||||
|
|
@ -186,6 +201,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if p.restrictions != nil && len(ids) > 0 {
|
||||||
|
g.Go(func() error {
|
||||||
|
var err error
|
||||||
|
restrictions, err = p.restrictions.AccountRestrictions(gctx, ids)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
if err := g.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +245,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
|
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
|
||||||
|
pj = applyAccountRestrictionProjection(pj, viewer, restrictions[u.ID])
|
||||||
cache[u.ID] = pj
|
cache[u.ID] = pj
|
||||||
projected[i] = pj
|
projected[i] = pj
|
||||||
}
|
}
|
||||||
|
|
@ -363,7 +386,7 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u
|
||||||
return projected[0], nil
|
return projected[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, restrictionsProvider AccountRestrictionProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +400,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
contactsByID map[int64]domain.Contact
|
contactsByID map[int64]domain.Contact
|
||||||
visibility map[int64]map[domain.PrivacyKey]bool
|
visibility map[int64]map[domain.PrivacyKey]bool
|
||||||
freezes map[int64]domain.AccountFreeze
|
freezes map[int64]domain.AccountFreeze
|
||||||
|
restrictions map[int64]domain.AccountRestriction
|
||||||
)
|
)
|
||||||
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
||||||
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
||||||
|
|
@ -449,6 +473,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if restrictionsProvider != nil && len(ids) > 0 {
|
||||||
|
g.Go(func() error {
|
||||||
|
m, err := restrictionsProvider.AccountRestrictions(gctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
restrictions = m
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
if err := g.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +514,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
|
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
|
||||||
|
projected = applyAccountRestrictionProjection(projected, viewerUserID, restrictions[u.ID])
|
||||||
cache[u.ID] = projected
|
cache[u.ID] = projected
|
||||||
out[i] = projected
|
out[i] = projected
|
||||||
}
|
}
|
||||||
|
|
@ -488,6 +523,8 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
||||||
|
|
||||||
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
|
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
|
||||||
// Base users and self users must never retain a viewer-scoped restriction.
|
// Base users and self users must never retain a viewer-scoped restriction.
|
||||||
|
// This runs first in the projection chain, so it is the one place that
|
||||||
|
// establishes the nil baseline; applyAccountRestrictionProjection appends.
|
||||||
user.RestrictionReasons = nil
|
user.RestrictionReasons = nil
|
||||||
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
|
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
|
||||||
return user
|
return user
|
||||||
|
|
@ -496,6 +533,14 @@ func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze d
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyAccountRestrictionProjection(user domain.User, viewerUserID int64, restriction domain.AccountRestriction) domain.User {
|
||||||
|
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !restriction.Restricted {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
user.RestrictionReasons = append(user.RestrictionReasons, domain.AccountRestrictedRestrictionReasons()...)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||||
if privacy == nil || viewerUserID == 0 {
|
if privacy == nil || viewerUserID == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,14 @@ type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
||||||
|
|
||||||
// Service 提供用户查询。
|
// Service 提供用户查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
users store.UserStore
|
users store.UserStore
|
||||||
cache store.UserCache
|
cache store.UserCache
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
photos ProfilePhotoProvider
|
photos ProfilePhotoProvider
|
||||||
privacy userprojection.PrivacyEvaluator
|
privacy userprojection.PrivacyEvaluator
|
||||||
freezes userprojection.AccountFreezeProvider
|
freezes userprojection.AccountFreezeProvider
|
||||||
projector *userprojection.Projector
|
restrictions userprojection.AccountRestrictionProvider
|
||||||
|
projector *userprojection.Projector
|
||||||
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||||
// while true, ResolveUsername never resolves @marksbot
|
// while true, ResolveUsername never resolves @marksbot
|
||||||
// (domain.VerifierBotUserID), so a client cannot discover it by username.
|
// (domain.VerifierBotUserID), so a client cannot discover it by username.
|
||||||
|
|
@ -97,6 +98,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||||
return func(s *Service) { s.freezes = p }
|
return func(s *Service) { s.freezes = p }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||||
|
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||||
|
return func(s *Service) { s.restrictions = p }
|
||||||
|
}
|
||||||
|
|
||||||
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||||
// while true, ResolveUsername treats @marksbot as not found.
|
// while true, ResolveUsername treats @marksbot as not found.
|
||||||
func WithHideThirdPartyVerification(hidden bool) Option {
|
func WithHideThirdPartyVerification(hidden bool) Option {
|
||||||
|
|
@ -135,6 +141,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
||||||
userprojection.WithPhotoProvider(s.photos),
|
userprojection.WithPhotoProvider(s.photos),
|
||||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||||
|
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||||
)
|
)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,21 @@ type AccountFreezeNotification struct {
|
||||||
Frozen bool
|
Frozen bool
|
||||||
Attempts int
|
Attempts int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountRestriction is the durable, narrower spam sanction: unlike
|
||||||
|
// AccountFreeze it does not make the account read-only. A restricted account
|
||||||
|
// keeps every existing membership and conversation; it can only not join new
|
||||||
|
// channels/groups and not start new conversations with non-contacts. Until,
|
||||||
|
// when set, is when the restriction auto-lifts; a zero Until means it stays
|
||||||
|
// in effect until an admin clears it.
|
||||||
|
type AccountRestriction struct {
|
||||||
|
UserID int64
|
||||||
|
Restricted bool
|
||||||
|
Version int64
|
||||||
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
|
Reason string
|
||||||
|
Actor string
|
||||||
|
CommandID string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ const (
|
||||||
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
|
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
|
||||||
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
|
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
|
||||||
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
|
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
|
||||||
|
ModerationActionRestrictAccount ModerationActionKind = "restrict_account"
|
||||||
|
ModerationActionUnrestrictAccount ModerationActionKind = "unrestrict_account"
|
||||||
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
|
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
|
||||||
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
|
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
|
||||||
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
|
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
|
||||||
|
|
@ -121,6 +123,7 @@ func (k ModerationActionKind) Valid() bool {
|
||||||
case ModerationActionMarkScam, ModerationActionMarkFake,
|
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||||
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
|
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
|
||||||
ModerationActionUnfreezeAccount,
|
ModerationActionUnfreezeAccount,
|
||||||
|
ModerationActionRestrictAccount, ModerationActionUnrestrictAccount,
|
||||||
ModerationActionDeletePrivateMessage,
|
ModerationActionDeletePrivateMessage,
|
||||||
ModerationActionDeleteChannelMessage,
|
ModerationActionDeleteChannelMessage,
|
||||||
ModerationActionDeleteAccount:
|
ModerationActionDeleteAccount:
|
||||||
|
|
@ -159,8 +162,9 @@ func (s ModerationActionStatus) Valid() bool {
|
||||||
type ModerationSanctionFamily string
|
type ModerationSanctionFamily string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
|
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
|
||||||
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
|
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
|
||||||
|
ModerationSanctionAccountRestrict ModerationSanctionFamily = "account_restrict"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
||||||
|
|
@ -170,6 +174,8 @@ func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool)
|
||||||
return ModerationSanctionPeerFlags, true
|
return ModerationSanctionPeerFlags, true
|
||||||
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
||||||
return ModerationSanctionAccountFreeze, true
|
return ModerationSanctionAccountFreeze, true
|
||||||
|
case ModerationActionRestrictAccount, ModerationActionUnrestrictAccount:
|
||||||
|
return ModerationSanctionAccountRestrict, true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,14 @@ func AccountFrozenRestrictionReasons() []UserRestrictionReason {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AccountRestrictedRestrictionReasons() []UserRestrictionReason {
|
||||||
|
return []UserRestrictionReason{{
|
||||||
|
Platform: "all",
|
||||||
|
Reason: "spam-restricted",
|
||||||
|
Text: "This account has been restricted for suspected spam.",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||||
// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。
|
// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。
|
||||||
func (u User) PremiumActiveAt(now int64) bool {
|
func (u User) PremiumActiveAt(now int64) bool {
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,12 @@ type AccountFreezeService interface {
|
||||||
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
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
|
// AccountFreezeNotificationService owns the durable non-PTS notification
|
||||||
// queue. It is intentionally separate from AccountFreezeService so hot
|
// queue. It is intentionally separate from AccountFreezeService so hot
|
||||||
// read-only gates can use a versioned fact cache without disabling queue
|
// read-only gates can use a versioned fact cache without disabling queue
|
||||||
|
|
@ -607,6 +613,10 @@ type UserEmojiStatusUpdatesService interface {
|
||||||
type ContactsService interface {
|
type ContactsService interface {
|
||||||
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
|
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
|
||||||
ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, 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)
|
AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
|
||||||
AcceptContact(ctx context.Context, userID, contactUserID int64) (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)
|
ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error)
|
||||||
|
|
@ -1149,6 +1159,7 @@ type Deps struct {
|
||||||
AppUpdates updatecdn.Resolver
|
AppUpdates updatecdn.Resolver
|
||||||
AccountFreeze AccountFreezeService
|
AccountFreeze AccountFreezeService
|
||||||
AccountFreezeNotifications AccountFreezeNotificationService
|
AccountFreezeNotifications AccountFreezeNotificationService
|
||||||
|
AccountRestriction AccountRestrictionService
|
||||||
AICompose AIComposeService
|
AICompose AIComposeService
|
||||||
Ephemeral EphemeralService
|
Ephemeral EphemeralService
|
||||||
EphemeralPush store.EphemeralPushBroker
|
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 frozenMethodInvalidErr() error { return tgerr.New(420, "FROZEN_METHOD_INVALID") }
|
||||||
func frozenParticipantMissingErr() error { return tgerr.New(400, "FROZEN_PARTICIPANT_MISSING") }
|
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 photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") }
|
||||||
|
|
||||||
func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") }
|
func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") }
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,67 @@ func frozenMethodRequiresWriteGate(method string) bool {
|
||||||
return true
|
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 {
|
func (r *Router) checkFrozenRPC(ctx context.Context, method string) error {
|
||||||
if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) {
|
if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,9 @@ func (r *Router) DispatchAdmitted(
|
||||||
if err := r.checkFrozenRPC(ctx, method); err != nil {
|
if err := r.checkFrozenRPC(ctx, method); err != nil {
|
||||||
return nil, method, err
|
return nil, method, err
|
||||||
}
|
}
|
||||||
|
if err := r.checkRestrictedRPC(ctx, method); err != nil {
|
||||||
|
return nil, method, err
|
||||||
|
}
|
||||||
if profileKnown && profileEvidenceFresh {
|
if profileKnown && profileEvidenceFresh {
|
||||||
r.maybeMarkSessionReceivesUpdates(ctx)
|
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 {
|
if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, toPeer.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
absentIDs := make([]int, len(absentIndexes))
|
absentIDs := make([]int, len(absentIndexes))
|
||||||
absentRandomIDs := make([]int64, len(absentIndexes))
|
absentRandomIDs := make([]int64, len(absentIndexes))
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
||||||
sendErr = peerIDInvalidErr()
|
sendErr = peerIDInvalidErr()
|
||||||
return nil, sendErr
|
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)
|
idempotencyFingerprint, err := sendMessageIdempotencyFingerprint(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendErr = internalErr()
|
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 {
|
if err := r.checkFrozenRPC(ctx, tlTypeName(id)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := r.checkRestrictedRPC(ctx, tlTypeName(id)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates
|
// 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates
|
||||||
// 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复:
|
// 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复:
|
||||||
// 它重连后不重建同步基线(pts 在进程内存里),只发普通业务请求,置位
|
// 它重连后不重建同步基线(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 {
|
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); err != nil {
|
||||||
return nil, false, err
|
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 {
|
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
|
||||||
return nil, false, err
|
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 {
|
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
|
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
|
||||||
for i, item := range req.MultiMedia {
|
for i, item := range req.MultiMedia {
|
||||||
|
|
|
||||||
|
|
@ -954,6 +954,10 @@ func (s *ModerationReportStore) memoryCaseOwnsRemedySanctionsLocked(item domain.
|
||||||
if latest.Kind != domain.ModerationActionFreezeAccount {
|
if latest.Kind != domain.ModerationActionFreezeAccount {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
case domain.ModerationActionUnrestrictAccount:
|
||||||
|
if latest.Kind != domain.ModerationActionRestrictAccount {
|
||||||
|
return false
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -968,6 +972,8 @@ func memoryModerationActionsValid(target domain.Peer, actions []domain.Moderatio
|
||||||
domain.ModerationActionClearPeerFlags:
|
domain.ModerationActionClearPeerFlags:
|
||||||
case domain.ModerationActionFreezeAccount,
|
case domain.ModerationActionFreezeAccount,
|
||||||
domain.ModerationActionUnfreezeAccount,
|
domain.ModerationActionUnfreezeAccount,
|
||||||
|
domain.ModerationActionRestrictAccount,
|
||||||
|
domain.ModerationActionUnrestrictAccount,
|
||||||
domain.ModerationActionDeletePrivateMessage,
|
domain.ModerationActionDeletePrivateMessage,
|
||||||
domain.ModerationActionDeleteAccount:
|
domain.ModerationActionDeleteAccount:
|
||||||
if target.Type != domain.PeerTypeUser {
|
if target.Type != domain.PeerTypeUser {
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,130 @@ func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
|
||||||
return r, nil
|
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 {
|
func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
|
||||||
const maxAccountFreezeNotificationAudience = 4096
|
const maxAccountFreezeNotificationAudience = 4096
|
||||||
_, err := tx.Exec(ctx, `
|
_, err := tx.Exec(ctx, `
|
||||||
|
|
|
||||||
|
|
@ -969,6 +969,10 @@ LIMIT 1`, string(current.Target.Type), current.Target.ID,
|
||||||
if domain.ModerationActionKind(kind) != domain.ModerationActionFreezeAccount {
|
if domain.ModerationActionKind(kind) != domain.ModerationActionFreezeAccount {
|
||||||
return domain.ErrModerationActionConflict
|
return domain.ErrModerationActionConflict
|
||||||
}
|
}
|
||||||
|
case domain.ModerationActionUnrestrictAccount:
|
||||||
|
if domain.ModerationActionKind(kind) != domain.ModerationActionRestrictAccount {
|
||||||
|
return domain.ErrModerationActionConflict
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return domain.ErrModerationActionConflict
|
return domain.ErrModerationActionConflict
|
||||||
}
|
}
|
||||||
|
|
@ -989,6 +993,11 @@ func moderationSanctionKinds(family domain.ModerationSanctionFamily) []string {
|
||||||
string(domain.ModerationActionFreezeAccount),
|
string(domain.ModerationActionFreezeAccount),
|
||||||
string(domain.ModerationActionUnfreezeAccount),
|
string(domain.ModerationActionUnfreezeAccount),
|
||||||
}
|
}
|
||||||
|
case domain.ModerationSanctionAccountRestrict:
|
||||||
|
return []string{
|
||||||
|
string(domain.ModerationActionRestrictAccount),
|
||||||
|
string(domain.ModerationActionUnrestrictAccount),
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -1002,6 +1011,8 @@ func validateModerationActionsForTarget(target domain.Peer, actions []domain.Mod
|
||||||
// Both users and channels support protocol scam/fake flags.
|
// Both users and channels support protocol scam/fake flags.
|
||||||
case domain.ModerationActionFreezeAccount,
|
case domain.ModerationActionFreezeAccount,
|
||||||
domain.ModerationActionUnfreezeAccount,
|
domain.ModerationActionUnfreezeAccount,
|
||||||
|
domain.ModerationActionRestrictAccount,
|
||||||
|
domain.ModerationActionUnrestrictAccount,
|
||||||
domain.ModerationActionDeletePrivateMessage,
|
domain.ModerationActionDeletePrivateMessage,
|
||||||
domain.ModerationActionDeleteAccount:
|
domain.ModerationActionDeleteAccount:
|
||||||
if target.Type != domain.PeerTypeUser {
|
if target.Type != domain.PeerTypeUser {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue