From 44bf7b812894f9c8d67cbfbb1a23b0091aa601c5 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 20 Mar 2024 16:37:21 +0100 Subject: [PATCH] Add notifications of severed relationships (#27511) --- .../severed_relationships_controller.rb | 61 +++++++++++++++++++ .../notifications/components/notification.jsx | 29 +++++++++ .../relationships_severance_event.jsx | 61 +++++++++++++++++++ app/javascript/mastodon/locales/en.json | 8 +++ .../mastodon/reducers/notifications.js | 1 + .../material-icons/400-24px/link_off-fill.svg | 1 + .../material-icons/400-24px/link_off.svg | 1 + .../account_relationship_severance_event.rb | 27 ++++++++ app/models/concerns/account/interactions.rb | 5 ++ app/models/concerns/account/merging.rb | 12 ++++ app/models/notification.rb | 9 +++ app/models/relationship_severance_event.rb | 56 +++++++++++++++++ app/models/severed_relationship.rb | 40 ++++++++++++ ...relationship_severance_event_serializer.rb | 9 +++ .../rest/notification_serializer.rb | 5 ++ ...after_block_domain_from_account_service.rb | 23 +++++-- app/services/block_domain_service.rb | 19 +++++- app/services/delete_account_service.rb | 22 +++++++ app/services/notify_service.rb | 4 +- app/services/purge_domain_service.rb | 24 ++++++-- app/services/suspend_account_service.rb | 18 ++++++ .../severed_relationships/index.html.haml | 34 +++++++++++ config/locales/en.yml | 12 ++++ config/navigation.rb | 6 +- config/routes.rb | 8 +++ ...44_create_relationship_severance_events.rb | 15 +++++ ...0312105620_create_severed_relationships.rb | 27 ++++++++ ...e_account_relationship_severance_events.rb | 14 +++++ db/schema.rb | 41 ++++++++++++- lib/mastodon/cli/maintenance.rb | 17 +++++- ...relationship_severance_event_fabricator.rb | 6 ++ ...relationship_severance_event_fabricator.rb | 6 ++ .../severed_relationship_fabricator.rb | 8 +++ .../relationship_severance_event_spec.rb | 49 +++++++++++++++ spec/models/severed_relationship_spec.rb | 45 ++++++++++++++ spec/requests/severed_relationships_spec.rb | 23 +++++++ ..._block_domain_from_account_service_spec.rb | 31 +++++++--- spec/services/block_domain_service_spec.rb | 49 ++++++--------- spec/services/suspend_account_service_spec.rb | 9 ++- 39 files changed, 781 insertions(+), 54 deletions(-) create mode 100644 app/controllers/severed_relationships_controller.rb create mode 100644 app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx create mode 100644 app/javascript/material-icons/400-24px/link_off-fill.svg create mode 100644 app/javascript/material-icons/400-24px/link_off.svg create mode 100644 app/models/account_relationship_severance_event.rb create mode 100644 app/models/relationship_severance_event.rb create mode 100644 app/models/severed_relationship.rb create mode 100644 app/serializers/rest/account_relationship_severance_event_serializer.rb create mode 100644 app/views/severed_relationships/index.html.haml create mode 100644 db/migrate/20240312100644_create_relationship_severance_events.rb create mode 100644 db/migrate/20240312105620_create_severed_relationships.rb create mode 100644 db/migrate/20240320140159_create_account_relationship_severance_events.rb create mode 100644 spec/fabricators/account_relationship_severance_event_fabricator.rb create mode 100644 spec/fabricators/relationship_severance_event_fabricator.rb create mode 100644 spec/fabricators/severed_relationship_fabricator.rb create mode 100644 spec/models/relationship_severance_event_spec.rb create mode 100644 spec/models/severed_relationship_spec.rb create mode 100644 spec/requests/severed_relationships_spec.rb diff --git a/app/controllers/severed_relationships_controller.rb b/app/controllers/severed_relationships_controller.rb new file mode 100644 index 000000000..8994fff0a --- /dev/null +++ b/app/controllers/severed_relationships_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class SeveredRelationshipsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_body_classes + before_action :set_cache_headers + + before_action :set_event, only: [:following, :followers] + + def index + @events = AccountRelationshipSeveranceEvent.where(account: current_account) + end + + def following + respond_to do |format| + format.csv { send_data following_data, filename: "following-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + def followers + respond_to do |format| + format.csv { send_data followers_data, filename: "followers-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" } + end + end + + private + + def set_event + @event = AccountRelationshipSeveranceEvent.find(params[:id]) + end + + def following_data + CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv| + @event.severed_relationships.active.where(local_account: current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')] + end + end + end + + def followers_data + CSV.generate(headers: ['Account address'], write_headers: true) do |csv| + @event.severed_relationships.passive.where(local_account: current_account).includes(:remote_account).reorder(id: :desc).each do |follow| + csv << [acct(follow.account)] + end + end + end + + def acct(account) + account.local? ? account.local_username_and_domain : account.acct + end + + def set_body_classes + @body_classes = 'admin' + end + + def set_cache_headers + response.cache_control.replace(private: true, no_store: true) + end +end diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index d7101f838..1af1eb78d 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -14,6 +14,7 @@ import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; +import LinkOffIcon from '@/material-icons/400-24px/link_off.svg?react'; import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; @@ -26,6 +27,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import FollowRequestContainer from '../containers/follow_request_container'; +import RelationshipsSeveranceEvent from './relationships_severance_event'; import Report from './report'; const messages = defineMessages({ @@ -36,6 +38,7 @@ const messages = defineMessages({ reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, status: { id: 'notification.status', defaultMessage: '{name} just posted' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, + severedRelationships: { id: 'notification.severed_relationships', defaultMessage: 'Relationships with {name} severed' }, adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, }); @@ -358,6 +361,30 @@ class Notification extends ImmutablePureComponent { ); } + renderRelationshipsSevered (notification) { + const { intl, unread } = this.props; + + if (!notification.get('event')) { + return null; + } + + return ( + +
+
+ + + + + +
+ + +
+
+ ); + } + renderAdminSignUp (notification, account, link) { const { intl, unread } = this.props; @@ -429,6 +456,8 @@ class Notification extends ImmutablePureComponent { return this.renderUpdate(notification, link); case 'poll': return this.renderPoll(notification, account); + case 'severed_relationships': + return this.renderRelationshipsSevered(notification); case 'admin.sign_up': return this.renderAdminSignUp(notification, account, link); case 'admin.report': diff --git a/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx b/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx new file mode 100644 index 000000000..12bc5f130 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/relationships_severance_event.jsx @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; + +import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; + +// This needs to be kept in sync with app/models/relationship_severance_event.rb +const messages = defineMessages({ + account_suspension: { id: 'relationship_severance_notification.types.account_suspension', defaultMessage: 'Account has been suspended' }, + domain_block: { id: 'relationship_severance_notification.types.domain_block', defaultMessage: 'Domain has been suspended' }, + user_domain_block: { id: 'relationship_severance_notification.types.user_domain_block', defaultMessage: 'You blocked this domain' }, +}); + +const RelationshipsSeveranceEvent = ({ event, hidden }) => { + const intl = useIntl(); + + if (hidden || !event) { + return null; + } + + return ( +
+
+
+ + {' · '} + { event.get('purged') ? ( + + ) : ( + + )} +
+ {intl.formatMessage(messages[event.get('type')])} +
+ +
+ + + +
+
+
+ ); + +}; + +RelationshipsSeveranceEvent.propTypes = { + event: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, +}; + +export default RelationshipsSeveranceEvent; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 376dfb7e4..ca6985336 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -471,6 +471,8 @@ "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your post", + "notification.severedRelationships": "Relationships with {name} severed", + "notification.severed_relationships": "Relationships with {name} severed", "notification.status": "{name} just posted", "notification.update": "{name} edited a post", "notification_requests.accept": "Accept", @@ -587,6 +589,12 @@ "refresh": "Refresh", "regeneration_indicator.label": "Loading…", "regeneration_indicator.sublabel": "Your home feed is being prepared!", + "relationship_severance_notification.purged_data": "purged by administrators", + "relationship_severance_notification.relationships": "{count, plural, one {# relationship} other {# relationships}}", + "relationship_severance_notification.types.account_suspension": "Account has been suspended", + "relationship_severance_notification.types.domain_block": "Domain has been suspended", + "relationship_severance_notification.types.user_domain_block": "You blocked this domain", + "relationship_severance_notification.view": "View", "relative_time.days": "{number}d", "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago", "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago", diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index b1c80b3d4..bc8593643 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -55,6 +55,7 @@ export const notificationToMap = notification => ImmutableMap({ created_at: notification.created_at, status: notification.status ? notification.status.id : null, report: notification.report ? fromJS(notification.report) : null, + event: notification.event ? fromJS(notification.event) : null, }); const normalizeNotification = (state, notification, usePendingItems) => { diff --git a/app/javascript/material-icons/400-24px/link_off-fill.svg b/app/javascript/material-icons/400-24px/link_off-fill.svg new file mode 100644 index 000000000..618e77534 --- /dev/null +++ b/app/javascript/material-icons/400-24px/link_off-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/link_off.svg b/app/javascript/material-icons/400-24px/link_off.svg new file mode 100644 index 000000000..618e77534 --- /dev/null +++ b/app/javascript/material-icons/400-24px/link_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/models/account_relationship_severance_event.rb b/app/models/account_relationship_severance_event.rb new file mode 100644 index 000000000..0cf3fb117 --- /dev/null +++ b/app/models/account_relationship_severance_event.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# +# == Schema Information +# +# Table name: account_relationship_severance_events +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# relationship_severance_event_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# +class AccountRelationshipSeveranceEvent < ApplicationRecord + belongs_to :account + belongs_to :relationship_severance_event + + delegate :severed_relationships, :type, :target_name, :purged, to: :relationship_severance_event, prefix: false + + before_create :set_relationships_count! + + private + + def set_relationships_count! + self.relationships_count = severed_relationships.where(local_account: account).count + end +end diff --git a/app/models/concerns/account/interactions.rb b/app/models/concerns/account/interactions.rb index 85363febf..a32697b66 100644 --- a/app/models/concerns/account/interactions.rb +++ b/app/models/concerns/account/interactions.rb @@ -83,6 +83,11 @@ module Account::Interactions has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account + with_options class_name: 'SeveredRelationship', dependent: :destroy do + has_many :severed_relationships, foreign_key: 'local_account_id', inverse_of: :local_account + has_many :remote_severed_relationships, foreign_key: 'remote_account_id', inverse_of: :remote_account + end + # Account notes has_many :account_notes, dependent: :destroy diff --git a/app/models/concerns/account/merging.rb b/app/models/concerns/account/merging.rb index 960ee1819..ebc57a122 100644 --- a/app/models/concerns/account/merging.rb +++ b/app/models/concerns/account/merging.rb @@ -48,6 +48,18 @@ module Account::Merging record.update_attribute(:account_warning_id, id) end + SeveredRelationship.where(local_account_id: other_account.id).reorder(nil).find_each do |record| + record.update_attribute(:local_account_id, id) + rescue ActiveRecord::RecordNotUnique + next + end + + SeveredRelationship.where(remote_account_id: other_account.id).reorder(nil).find_each do |record| + record.update_attribute(:remote_account_id, id) + rescue ActiveRecord::RecordNotUnique + next + end + # Some follow relationships have moved, so the cache is stale Rails.cache.delete_matched("followers_hash:#{id}:*") Rails.cache.delete_matched("relationships:#{id}:*") diff --git a/app/models/notification.rb b/app/models/notification.rb index 861a15436..8ee7e7725 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -54,6 +54,9 @@ class Notification < ApplicationRecord update: { filterable: false, }.freeze, + severed_relationships: { + filterable: false, + }.freeze, 'admin.sign_up': { filterable: false, }.freeze, @@ -86,6 +89,7 @@ class Notification < ApplicationRecord belongs_to :favourite, inverse_of: :notification belongs_to :poll, inverse_of: false belongs_to :report, inverse_of: false + belongs_to :relationship_severance_event, inverse_of: false end validates :type, inclusion: { in: TYPES } @@ -182,6 +186,11 @@ class Notification < ApplicationRecord self.from_account_id = activity&.status&.account_id when 'Account' self.from_account_id = activity&.id + when 'AccountRelationshipSeveranceEvent' + # These do not really have an originating account, but this is mandatory + # in the data model, and the recipient's account will by definition + # always exist + self.from_account_id = account_id end end diff --git a/app/models/relationship_severance_event.rb b/app/models/relationship_severance_event.rb new file mode 100644 index 000000000..d9775150e --- /dev/null +++ b/app/models/relationship_severance_event.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: relationship_severance_events +# +# id :bigint(8) not null, primary key +# type :integer not null +# target_name :string not null +# purged :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class RelationshipSeveranceEvent < ApplicationRecord + self.inheritance_column = nil + + has_many :severed_relationships, inverse_of: :relationship_severance_event, dependent: :delete_all + + enum type: { + domain_block: 0, + user_domain_block: 1, + account_suspension: 2, + } + + scope :about_local_account, ->(account) { where(id: SeveredRelationship.about_local_account(account).select(:relationship_severance_event_id)) } + + def import_from_active_follows!(follows) + import_from_follows!(follows, true) + end + + def import_from_passive_follows!(follows) + import_from_follows!(follows, false) + end + + def affected_local_accounts + Account.where(id: severed_relationships.select(:local_account_id)) + end + + private + + def import_from_follows!(follows, active) + SeveredRelationship.insert_all( + follows.pluck(:account_id, :target_account_id, :show_reblogs, :notify, :languages).map do |account_id, target_account_id, show_reblogs, notify, languages| + { + local_account_id: active ? account_id : target_account_id, + remote_account_id: active ? target_account_id : account_id, + show_reblogs: show_reblogs, + notify: notify, + languages: languages, + relationship_severance_event_id: id, + direction: active ? :active : :passive, + } + end + ) + end +end diff --git a/app/models/severed_relationship.rb b/app/models/severed_relationship.rb new file mode 100644 index 000000000..00a913f7f --- /dev/null +++ b/app/models/severed_relationship.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: severed_relationships +# +# id :bigint(8) not null, primary key +# relationship_severance_event_id :bigint(8) not null +# local_account_id :bigint(8) not null +# remote_account_id :bigint(8) not null +# direction :integer not null +# show_reblogs :boolean +# notify :boolean +# languages :string is an Array +# created_at :datetime not null +# updated_at :datetime not null +# +class SeveredRelationship < ApplicationRecord + belongs_to :relationship_severance_event + belongs_to :local_account, class_name: 'Account' + belongs_to :remote_account, class_name: 'Account' + + enum direction: { + passive: 0, # analogous to `local_account.passive_relationships` + active: 1, # analogous to `local_account.active_relationships` + } + + scope :about_local_account, ->(account) { where(local_account: account) } + + scope :active, -> { where(direction: :active) } + scope :passive, -> { where(direction: :passive) } + + def account + active? ? local_account : remote_account + end + + def target_account + active? ? remote_account : local_account + end +end diff --git a/app/serializers/rest/account_relationship_severance_event_serializer.rb b/app/serializers/rest/account_relationship_severance_event_serializer.rb new file mode 100644 index 000000000..2578e3a20 --- /dev/null +++ b/app/serializers/rest/account_relationship_severance_event_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::AccountRelationshipSeveranceEventSerializer < ActiveModel::Serializer + attributes :id, :type, :purged, :target_name, :created_at + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 137fc53dd..d7b88b7c3 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -6,6 +6,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer + belongs_to :relationship_severance_event, key: :event, if: :relationship_severance_event?, serializer: REST::AccountRelationshipSeveranceEventSerializer def id object.id.to_s @@ -18,4 +19,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer def report_type? object.type == :'admin.report' end + + def relationship_severance_event? + object.type == :severed_relationships + end end diff --git a/app/services/after_block_domain_from_account_service.rb b/app/services/after_block_domain_from_account_service.rb index 89d007c1c..adb17845c 100644 --- a/app/services/after_block_domain_from_account_service.rb +++ b/app/services/after_block_domain_from_account_service.rb @@ -9,18 +9,21 @@ class AfterBlockDomainFromAccountService < BaseService def call(account, domain) @account = account @domain = domain + @domain_block_event = nil clear_notifications! remove_follows! reject_existing_followers! reject_pending_follow_requests! + notify_of_severed_relationships! end private def remove_follows! - @account.active_relationships.where(target_account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow| - UnfollowService.new.call(@account, follow.target_account) + @account.active_relationships.where(target_account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).in_batches do |follows| + domain_block_event.import_from_active_follows!(follows) + follows.each { |follow| UnfollowService.new.call(@account, follow.target_account) } end end @@ -29,8 +32,9 @@ class AfterBlockDomainFromAccountService < BaseService end def reject_existing_followers! - @account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow| - reject_follow!(follow) + @account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).in_batches do |follows| + domain_block_event.import_from_passive_follows!(follows) + follows.each { |follow| reject_follow!(follow) } end end @@ -47,4 +51,15 @@ class AfterBlockDomainFromAccountService < BaseService ActivityPub::DeliveryWorker.perform_async(Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), @account.id, follow.account.inbox_url) end + + def notify_of_severed_relationships! + return if @domain_block_event.nil? + + event = AccountRelationshipSeveranceEvent.create!(account: @account, relationship_severance_event: @domain_block_event) + LocalNotificationWorker.perform_async(@account.id, event.id, 'AccountRelationshipSeveranceEvent', 'severed_relationships') + end + + def domain_block_event + @domain_block_event ||= RelationshipSeveranceEvent.create!(type: :user_domain_block, target_name: @domain) + end end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 76cc36ff6..00d020d2b 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -5,8 +5,11 @@ class BlockDomainService < BaseService def call(domain_block, update = false) @domain_block = domain_block + @domain_block_event = nil + process_domain_block! process_retroactive_updates! if update + notify_of_severed_relationships! end private @@ -37,7 +40,17 @@ class BlockDomainService < BaseService blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at, suspension_origin: :local) blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account| - DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at) + DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at, relationship_severance_event: domain_block_event) + end + end + + def notify_of_severed_relationships! + return if @domain_block_event.nil? + + # TODO: check how efficient that query is, also check `push_bulk`/`perform_bulk` + @domain_block_event.affected_local_accounts.reorder(nil).find_each do |account| + event = AccountRelationshipSeveranceEvent.create!(account: account, relationship_severance_event: @domain_block_event) + LocalNotificationWorker.perform_async(account.id, event.id, 'AccountRelationshipSeveranceEvent', 'severed_relationships') end end @@ -45,6 +58,10 @@ class BlockDomainService < BaseService domain_block.domain end + def domain_block_event + @domain_block_event ||= RelationshipSeveranceEvent.create!(type: :domain_block, target_name: blocked_domain) + end + def blocked_domain_accounts Account.by_domain_and_subdomains(blocked_domain) end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index 7c7cb97df..bdfe0e716 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -58,6 +58,8 @@ class DeleteAccountService < BaseService reports targeted_moderation_notes targeted_reports + severed_relationships + remote_severed_relationships ).freeze # Suspend or remove an account and remove as much of its data @@ -72,6 +74,7 @@ class DeleteAccountService < BaseService # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads # @option [Boolean] :skip_activitypub Skip sending ActivityPub payloads. Implied by :skip_side_effects # @option [Time] :suspended_at Only applicable when :reserve_username is true + # @option [RelationshipSeveranceEvent] :relationship_severance_event Event used to record severed relationships not initiated by the user def call(account, **options) @account = account @options = { reserve_username: true, reserve_email: true }.merge(options) @@ -84,6 +87,7 @@ class DeleteAccountService < BaseService @options[:skip_activitypub] = true if @options[:skip_side_effects] + record_severed_relationships! distribute_activities! purge_content! fulfill_deletion_request! @@ -266,6 +270,20 @@ class DeleteAccountService < BaseService end end + def record_severed_relationships! + return if relationship_severance_event.nil? + + @account.active_relationships.in_batches do |follows| + # NOTE: these follows are passive with regards to the local accounts + relationship_severance_event.import_from_passive_follows!(follows) + end + + @account.passive_relationships.in_batches do |follows| + # NOTE: these follows are active with regards to the local accounts + relationship_severance_event.import_from_active_follows!(follows) + end + end + def delete_actor_json @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account, always_sign: true)) end @@ -305,4 +323,8 @@ class DeleteAccountService < BaseService def skip_activitypub? @options[:skip_activitypub] end + + def relationship_severance_event + @options[:relationship_severance_event] + end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index f3d16f1be..c83e4c017 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -9,6 +9,8 @@ class NotifyService < BaseService update poll status + # TODO: this probably warrants an email notification + severed_relationships ).freeze class DismissCondition @@ -20,7 +22,7 @@ class NotifyService < BaseService def dismiss? blocked = @recipient.unavailable? - blocked ||= from_self? && @notification.type != :poll + blocked ||= from_self? && @notification.type != :poll && @notification.type != :severed_relationships return blocked if message? && from_staff? diff --git a/app/services/purge_domain_service.rb b/app/services/purge_domain_service.rb index 9df81f13e..ca0f0d441 100644 --- a/app/services/purge_domain_service.rb +++ b/app/services/purge_domain_service.rb @@ -2,10 +2,26 @@ class PurgeDomainService < BaseService def call(domain) - Account.remote.where(domain: domain).reorder(nil).find_each do |account| - DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) - end - CustomEmoji.remote.where(domain: domain).reorder(nil).find_each(&:destroy) + @domain = domain + + purge_relationship_severance_events! + purge_accounts! + purge_emojis! + Instance.refresh end + + def purge_relationship_severance_events! + RelationshipSeveranceEvent.where(type: [:domain_block, :user_domain_block], target_name: @domain).in_batches.update_all(purged: true) + end + + def purge_accounts! + Account.remote.where(domain: @domain).reorder(nil).find_each do |account| + DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) + end + end + + def purge_emojis! + CustomEmoji.remote.where(domain: @domain).reorder(nil).find_each(&:destroy) + end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 8d5446f1a..86c4ff641 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -8,6 +8,7 @@ class SuspendAccountService < BaseService def call(account) return unless account.suspended? + @relationship_severance_event = nil @account = account reject_remote_follows! @@ -15,6 +16,7 @@ class SuspendAccountService < BaseService unmerge_from_home_timelines! unmerge_from_list_timelines! privatize_media_attachments! + notify_of_severed_relationships! end private @@ -36,6 +38,8 @@ class SuspendAccountService < BaseService [Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url] end + relationship_severance_event.import_from_passive_follows!(follows) + follows.each(&:destroy) end end @@ -101,7 +105,21 @@ class SuspendAccountService < BaseService end end + def notify_of_severed_relationships! + return if @relationship_severance_event.nil? + + # TODO: check how efficient that query is, also check `push_bulk`/`perform_bulk` + @relationship_severance_event.affected_local_accounts.reorder(nil).find_each do |account| + event = AccountRelationshipSeveranceEvent.create!(account: account, relationship_severance_event: @relationship_severance_event) + LocalNotificationWorker.perform_async(account.id, event.id, 'AccountRelationshipSeveranceEvent', 'severed_relationships') + end + end + def signed_activity_json @signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account)) end + + def relationship_severance_event + @relationship_severance_event ||= RelationshipSeveranceEvent.create!(type: :account_suspension, target_name: @account.acct) + end end diff --git a/app/views/severed_relationships/index.html.haml b/app/views/severed_relationships/index.html.haml new file mode 100644 index 000000000..97bef8792 --- /dev/null +++ b/app/views/severed_relationships/index.html.haml @@ -0,0 +1,34 @@ +- content_for :page_title do + = t('settings.severed_relationships') + +%p.muted-hint= t('severed_relationships.preamble') + +- unless @events.empty? + .table-wrapper + %table.table + %thead + %tr + %th= t('exports.archive_takeout.date') + %th= t('severed_relationships.type') + %th= t('severed_relationships.lost_follows') + %th= t('severed_relationships.lost_followers') + %tbody + - @events.each do |event| + %tr + %td= l event.created_at + %td= t("severed_relationships.event_type.#{event.type}", target_name: event.target_name) + - if event.purged? + %td{ rowspan: 2 }= t('severed_relationships.purged') + - else + %td + - count = event.severed_relationships.active.where(local_account: current_account).count + - if count.zero? + = t('generic.none') + - else + = table_link_to 'download', t('severed_relationships.download', count: count), following_severed_relationship_path(event, format: :csv) + %td + - count = event.severed_relationships.passive.where(local_account: current_account).count + - if count.zero? + = t('generic.none') + - else + = table_link_to 'download', t('severed_relationships.download', count: count), followers_severed_relationship_path(event, format: :csv) diff --git a/config/locales/en.yml b/config/locales/en.yml index f45175e3f..823e720ea 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1660,10 +1660,22 @@ en: preferences: Preferences profile: Public profile relationships: Follows and followers + severed_relationships: Severed relationships statuses_cleanup: Automated post deletion strikes: Moderation strikes two_factor_authentication: Two-factor Auth webauthn_authentication: Security keys + severed_relationships: + download: Download (%{count}) + event_type: + account_suspension: Account suspension (%{target_name}) + domain_block: Server suspension (%{target_name}) + user_domain_block: You blocked %{target_name} + lost_followers: Lost followers + lost_follows: Lost follows + preamble: You may lose follows and followers when you block a domain or when your moderators decide to suspend a remote server. When that happens, you will be able to download lists of severed relationships, to be inspected and possibly imported on another server. + purged: Information about this server has been purged by your server's administrators. + type: Event statuses: attached: audio: diff --git a/config/navigation.rb b/config/navigation.rb index 734e5294b..791025d52 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -16,7 +16,11 @@ SimpleNavigation::Configuration.run do |navigation| s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_path end - n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? && !self_destruct } + n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? && !self_destruct } do |s| + s.item :current, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path + s.item :severed_relationships, safe_join([fa_icon('unlink fw'), t('settings.severed_relationships')]), severed_relationships_path + end + n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? && !self_destruct } n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional_or_moved? && !self_destruct } diff --git a/config/routes.rb b/config/routes.rb index 55f2de6a1..d032b5110 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -189,6 +189,14 @@ Rails.application.routes.draw do end resource :relationships, only: [:show, :update] + resources :severed_relationships, only: [:index] do + member do + constraints(format: :csv) do + get :followers + get :following + end + end + end resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update] get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy, format: false diff --git a/db/migrate/20240312100644_create_relationship_severance_events.rb b/db/migrate/20240312100644_create_relationship_severance_events.rb new file mode 100644 index 000000000..8c55fe330 --- /dev/null +++ b/db/migrate/20240312100644_create_relationship_severance_events.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateRelationshipSeveranceEvents < ActiveRecord::Migration[7.0] + def change + create_table :relationship_severance_events do |t| + t.integer :type, null: false + t.string :target_name, null: false + t.boolean :purged, null: false, default: false + + t.timestamps + + t.index [:type, :target_name] + end + end +end diff --git a/db/migrate/20240312105620_create_severed_relationships.rb b/db/migrate/20240312105620_create_severed_relationships.rb new file mode 100644 index 000000000..1ed911cd5 --- /dev/null +++ b/db/migrate/20240312105620_create_severed_relationships.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateSeveredRelationships < ActiveRecord::Migration[7.0] + def change + create_table :severed_relationships do |t| + # No need to have an index on this foreign key as it is covered by `index_severed_relationships_on_unique_tuples` + t.references :relationship_severance_event, null: false, foreign_key: { on_delete: :cascade }, index: false + + # No need to have an index on this foregin key as it is covered by `index_severed_relationships_on_local_account_and_event` + t.references :local_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade }, index: false + t.references :remote_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade } + + # Used to describe whether `local_account` is the active (follower) or passive (followed) part of the relationship + t.integer :direction, null: false + + # Those attributes are carried over from the `follows` table + t.boolean :show_reblogs + t.boolean :notify + t.string :languages, array: true + + t.timestamps + + t.index [:relationship_severance_event_id, :local_account_id, :direction, :remote_account_id], name: 'index_severed_relationships_on_unique_tuples', unique: true + t.index [:local_account_id, :relationship_severance_event_id], name: 'index_severed_relationships_on_local_account_and_event' + end + end +end diff --git a/db/migrate/20240320140159_create_account_relationship_severance_events.rb b/db/migrate/20240320140159_create_account_relationship_severance_events.rb new file mode 100644 index 000000000..160ef7621 --- /dev/null +++ b/db/migrate/20240320140159_create_account_relationship_severance_events.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateAccountRelationshipSeveranceEvents < ActiveRecord::Migration[7.1] + def change + create_table :account_relationship_severance_events do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true } + t.belongs_to :relationship_severance_event, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true } + + t.integer :relationships_count, default: 0, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b98253a6c..1e1d99f26 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do +ActiveRecord::Schema[7.1].define(version: 2024_03_20_140159) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -90,6 +90,16 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do t.index ["target_account_id"], name: "index_account_pins_on_target_account_id" end + create_table "account_relationship_severance_events", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "relationship_severance_event_id", null: false + t.integer "relationships_count", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_relationship_severance_events_on_account_id", unique: true + t.index ["relationship_severance_event_id"], name: "idx_on_relationship_severance_event_id_403f53e707", unique: true + end + create_table "account_stats", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "statuses_count", default: 0, null: false @@ -871,6 +881,15 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do t.string "url" end + create_table "relationship_severance_events", force: :cascade do |t| + t.integer "type", null: false + t.string "target_name", null: false + t.boolean "purged", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["type", "target_name"], name: "index_relationship_severance_events_on_type_and_target_name" + end + create_table "relays", force: :cascade do |t| t.string "inbox_url", default: "", null: false t.string "follow_activity_id" @@ -950,6 +969,21 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true end + create_table "severed_relationships", force: :cascade do |t| + t.bigint "relationship_severance_event_id", null: false + t.bigint "local_account_id", null: false + t.bigint "remote_account_id", null: false + t.integer "direction", null: false + t.boolean "show_reblogs" + t.boolean "notify" + t.string "languages", array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["local_account_id", "relationship_severance_event_id"], name: "index_severed_relationships_on_local_account_and_event" + t.index ["relationship_severance_event_id", "local_account_id", "direction", "remote_account_id"], name: "index_severed_relationships_on_unique_tuples", unique: true + t.index ["remote_account_id"], name: "index_severed_relationships_on_remote_account_id" + end + create_table "site_uploads", force: :cascade do |t| t.string "var", default: "", null: false t.string "file_file_name" @@ -1228,6 +1262,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do add_foreign_key "account_notes", "accounts", on_delete: :cascade add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "account_pins", "accounts", on_delete: :cascade + add_foreign_key "account_relationship_severance_events", "accounts", on_delete: :cascade + add_foreign_key "account_relationship_severance_events", "relationship_severance_events", on_delete: :cascade add_foreign_key "account_stats", "accounts", on_delete: :cascade add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade @@ -1320,6 +1356,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade + add_foreign_key "severed_relationships", "accounts", column: "local_account_id", on_delete: :cascade + add_foreign_key "severed_relationships", "accounts", column: "remote_account_id", on_delete: :cascade + add_foreign_key "severed_relationships", "relationship_severance_events", on_delete: :cascade add_foreign_key "status_edits", "accounts", on_delete: :nullify add_foreign_key "status_edits", "statuses", on_delete: :cascade add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade diff --git a/lib/mastodon/cli/maintenance.rb b/lib/mastodon/cli/maintenance.rb index 9f234e386..e6a346ae2 100644 --- a/lib/mastodon/cli/maintenance.rb +++ b/lib/mastodon/cli/maintenance.rb @@ -5,7 +5,7 @@ require_relative 'base' module Mastodon::CLI class Maintenance < Base MIN_SUPPORTED_VERSION = 2019_10_01_213028 - MAX_SUPPORTED_VERSION = 2023_09_07_150100 + MAX_SUPPORTED_VERSION = 2023_10_23_105620 # Stubs to enjoy ActiveRecord queries while not depending on a particular # version of the code/database @@ -39,6 +39,7 @@ module Mastodon::CLI class Webhook < ApplicationRecord; end class BulkImport < ApplicationRecord; end class SoftwareUpdate < ApplicationRecord; end + class SeveredRelationship < ApplicationRecord; end class DomainBlock < ApplicationRecord enum severity: { silence: 0, suspend: 1, noop: 2 } @@ -129,6 +130,20 @@ module Mastodon::CLI record.update_attribute(:account_warning_id, id) end end + + if ActiveRecord::Base.connection.table_exists?(:severed_relationships) + SeveredRelationship.where(local_account_id: other_account.id).reorder(nil).find_each do |record| + record.update_attribute(:local_account_id, id) + rescue ActiveRecord::RecordNotUnique + next + end + + SeveredRelationship.where(remote_account_id: other_account.id).reorder(nil).find_each do |record| + record.update_attribute(:remote_account_id, id) + rescue ActiveRecord::RecordNotUnique + next + end + end end end diff --git a/spec/fabricators/account_relationship_severance_event_fabricator.rb b/spec/fabricators/account_relationship_severance_event_fabricator.rb new file mode 100644 index 000000000..5580d5209 --- /dev/null +++ b/spec/fabricators/account_relationship_severance_event_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:account_relationship_severance_event) do + account + relationship_severance_event +end diff --git a/spec/fabricators/relationship_severance_event_fabricator.rb b/spec/fabricators/relationship_severance_event_fabricator.rb new file mode 100644 index 000000000..7fec14e9f --- /dev/null +++ b/spec/fabricators/relationship_severance_event_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:relationship_severance_event) do + type { :domain_block } + target_name { 'example.com' } +end diff --git a/spec/fabricators/severed_relationship_fabricator.rb b/spec/fabricators/severed_relationship_fabricator.rb new file mode 100644 index 000000000..6600b72cd --- /dev/null +++ b/spec/fabricators/severed_relationship_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:severed_relationship) do + local_account { Fabricate.build(:account) } + remote_account { Fabricate.build(:account) } + relationship_severance_event { Fabricate.build(:relationship_severance_event) } + direction { :active } +end diff --git a/spec/models/relationship_severance_event_spec.rb b/spec/models/relationship_severance_event_spec.rb new file mode 100644 index 000000000..93c0f1a26 --- /dev/null +++ b/spec/models/relationship_severance_event_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RelationshipSeveranceEvent do + let(:local_account) { Fabricate(:account) } + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + let(:event) { Fabricate(:relationship_severance_event) } + + describe '#import_from_active_follows!' do + before do + local_account.follow!(remote_account) + end + + it 'imports the follow relationships with the expected direction' do + event.import_from_active_follows!(local_account.active_relationships) + + relationships = event.severed_relationships.to_a + expect(relationships.size).to eq 1 + expect(relationships[0].account).to eq local_account + expect(relationships[0].target_account).to eq remote_account + end + end + + describe '#import_from_passive_follows!' do + before do + remote_account.follow!(local_account) + end + + it 'imports the follow relationships with the expected direction' do + event.import_from_passive_follows!(local_account.passive_relationships) + + relationships = event.severed_relationships.to_a + expect(relationships.size).to eq 1 + expect(relationships[0].account).to eq remote_account + expect(relationships[0].target_account).to eq local_account + end + end + + describe '#affected_local_accounts' do + before do + event.severed_relationships.create!(local_account: local_account, remote_account: remote_account, direction: :active) + end + + it 'correctly lists local accounts' do + expect(event.affected_local_accounts.to_a).to contain_exactly(local_account) + end + end +end diff --git a/spec/models/severed_relationship_spec.rb b/spec/models/severed_relationship_spec.rb new file mode 100644 index 000000000..0f922d798 --- /dev/null +++ b/spec/models/severed_relationship_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SeveredRelationship do + let(:local_account) { Fabricate(:account) } + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + let(:event) { Fabricate(:relationship_severance_event) } + + describe '#account' do + context 'when the local account is the follower' do + let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :active) } + + it 'returns the local account' do + expect(severed_relationship.account).to eq local_account + end + end + + context 'when the local account is being followed' do + let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :passive) } + + it 'returns the remote account' do + expect(severed_relationship.account).to eq remote_account + end + end + end + + describe '#target_account' do + context 'when the local account is the follower' do + let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :active) } + + it 'returns the remote account' do + expect(severed_relationship.target_account).to eq remote_account + end + end + + context 'when the local account is being followed' do + let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :passive) } + + it 'returns the local account' do + expect(severed_relationship.target_account).to eq local_account + end + end + end +end diff --git a/spec/requests/severed_relationships_spec.rb b/spec/requests/severed_relationships_spec.rb new file mode 100644 index 000000000..4063026d7 --- /dev/null +++ b/spec/requests/severed_relationships_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Severed relationships page' do + include RoutingHelper + + describe 'GET severed_relationships#index' do + let(:user) { Fabricate(:user) } + + before do + sign_in user + + Fabricate(:severed_relationship, local_account: user.account) + end + + it 'returns http success' do + get severed_relationships_path + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/services/after_block_domain_from_account_service_spec.rb b/spec/services/after_block_domain_from_account_service_spec.rb index 2fce424b1..12e780357 100644 --- a/spec/services/after_block_domain_from_account_service_spec.rb +++ b/spec/services/after_block_domain_from_account_service_spec.rb @@ -5,22 +5,33 @@ require 'rails_helper' RSpec.describe AfterBlockDomainFromAccountService do subject { described_class.new } - let!(:wolf) { Fabricate(:account, username: 'wolf', domain: 'evil.org', inbox_url: 'https://evil.org/inbox', protocol: :activitypub) } - let!(:alice) { Fabricate(:account, username: 'alice') } + let(:wolf) { Fabricate(:account, username: 'wolf', domain: 'evil.org', inbox_url: 'https://evil.org/wolf/inbox', protocol: :activitypub) } + let(:dog) { Fabricate(:account, username: 'dog', domain: 'evil.org', inbox_url: 'https://evil.org/dog/inbox', protocol: :activitypub) } + let(:alice) { Fabricate(:account, username: 'alice') } before do - allow(ActivityPub::DeliveryWorker).to receive(:perform_async) + wolf.follow!(alice) + alice.follow!(dog) end - it 'purge followers from blocked domain' do - wolf.follow!(alice) + around do |example| + Sidekiq::Testing.fake! do + example.run + end + end + + it 'purges followers from blocked domain, sends them Reject->Follow, and records severed relationships', :aggregate_failures do subject.call(alice, 'evil.org') + expect(wolf.following?(alice)).to be false - end + expect(ActivityPub::DeliveryWorker.jobs.pluck('args')).to contain_exactly( + [a_string_including('"type":"Reject"'), alice.id, wolf.inbox_url], + [a_string_including('"type":"Undo"'), alice.id, dog.inbox_url] + ) - it 'sends Reject->Follow to followers from blocked domain' do - wolf.follow!(alice) - subject.call(alice, 'evil.org') - expect(ActivityPub::DeliveryWorker).to have_received(:perform_async).once + severed_relationships = alice.severed_relationships.to_a + expect(severed_relationships.count).to eq 2 + expect(severed_relationships[0].relationship_severance_event).to eq severed_relationships[1].relationship_severance_event + expect(severed_relationships.map { |rel| [rel.account, rel.target_account] }).to contain_exactly([wolf, alice], [alice, dog]) end end diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb index 0f278293a..26f80eaf6 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -5,6 +5,8 @@ require 'rails_helper' RSpec.describe BlockDomainService do subject { described_class.new } + let(:local_account) { Fabricate(:account) } + let(:bystander) { Fabricate(:account, domain: 'evil.org') } let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } let!(:bad_status_plain) { Fabricate(:status, account: bad_account, text: 'You suck') } let!(:bad_status_with_attachment) { Fabricate(:status, account: bad_account, text: 'Hahaha') } @@ -13,62 +15,51 @@ RSpec.describe BlockDomainService do describe 'for a suspension' do before do + local_account.follow!(bad_account) + bystander.follow!(local_account) + end + + it 'creates a domain block, suspends remote accounts with appropriate suspension date, records severed relationships', :aggregate_failures do subject.call(DomainBlock.create!(domain: 'evil.org', severity: :suspend)) - end - it 'creates a domain block' do expect(DomainBlock.blocked?('evil.org')).to be true - end - it 'removes remote accounts from that domain' do + # Suspends account with appropriate suspension date expect(bad_account.reload.suspended?).to be true - end - - it 'records suspension date appropriately' do expect(bad_account.reload.suspended_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at - end - it 'keeps already-banned accounts banned' do + # Keep already-suspended account without updating the suspension date expect(already_banned_account.reload.suspended?).to be true - end - - it 'does not overwrite suspension date of already-banned accounts' do expect(already_banned_account.reload.suspended_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at - end - it 'removes the remote accounts\'s statuses and media attachments' do + # Removes content expect { bad_status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound + + # Records severed relationships + severed_relationships = local_account.severed_relationships.to_a + expect(severed_relationships.count).to eq 2 + expect(severed_relationships[0].relationship_severance_event).to eq severed_relationships[1].relationship_severance_event + expect(severed_relationships.map { |rel| [rel.account, rel.target_account] }).to contain_exactly([bystander, local_account], [local_account, bad_account]) end end describe 'for a silence with reject media' do - before do + it 'does not mark the domain as blocked, but silences accounts with an appropriate silencing date, clears media', :aggregate_failures, :sidekiq_inline do subject.call(DomainBlock.create!(domain: 'evil.org', severity: :silence, reject_media: true)) - end - it 'does not create a domain block' do expect(DomainBlock.blocked?('evil.org')).to be false - end - it 'silences remote accounts from that domain' do + # Silences account with appropriate silecing date expect(bad_account.reload.silenced?).to be true - end - - it 'records suspension date appropriately' do expect(bad_account.reload.silenced_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at - end - it 'keeps already-banned accounts banned' do + # Keeps already-silenced accounts without updating the silecing date expect(already_banned_account.reload.silenced?).to be true - end - - it 'does not overwrite suspension date of already-banned accounts' do expect(already_banned_account.reload.silenced_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at - end - it 'leaves the domains status and attachments, but clears media', :sidekiq_inline do + # Leaves posts but clears media expect { bad_status_plain.reload }.to_not raise_error expect { bad_status_with_attachment.reload }.to_not raise_error expect { bad_attachment.reload }.to_not raise_error diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb index d62f7ef0d..74ef8f60e 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -59,7 +59,7 @@ RSpec.describe SuspendAccountService, :sidekiq_inline do remote_follower.follow!(account) end - it 'sends an update actor to followers and reporters' do + it 'sends an Update actor activity to followers and reporters' do subject expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once @@ -85,9 +85,14 @@ RSpec.describe SuspendAccountService, :sidekiq_inline do account.follow!(local_followee) end - it 'sends a reject follow' do + it 'sends a Reject Follow activity, and records severed relationships', :aggregate_failures do subject + expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once + + severed_relationships = local_followee.severed_relationships.to_a + expect(severed_relationships.count).to eq 1 + expect(severed_relationships.map { |rel| [rel.account, rel.target_account] }).to contain_exactly([account, local_followee]) end end end