Add notifications of severed relationships (#27511)
This commit is contained in:
		
							
								
								
									
										61
									
								
								app/controllers/severed_relationships_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/controllers/severed_relationships_controller.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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 (
 | 
			
		||||
      <HotKeys handlers={this.getHandlers()}>
 | 
			
		||||
        <div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
 | 
			
		||||
          <div className='notification__message'>
 | 
			
		||||
            <Icon id='unlink' icon={LinkOffIcon} />
 | 
			
		||||
 | 
			
		||||
            <span title={notification.get('created_at')}>
 | 
			
		||||
              <FormattedMessage id='notification.severedRelationships' defaultMessage='Relationships with {name} severed' values={{ name: notification.getIn(['event', 'target_name']) }} />
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <RelationshipsSeveranceEvent event={notification.get('event')} />
 | 
			
		||||
        </div>
 | 
			
		||||
      </HotKeys>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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':
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (
 | 
			
		||||
    <div className='notification__report'>
 | 
			
		||||
      <div className='notification__report__details'>
 | 
			
		||||
        <div>
 | 
			
		||||
          <RelativeTimestamp timestamp={event.get('created_at')} short={false} />
 | 
			
		||||
          {' · '}
 | 
			
		||||
          { event.get('purged') ? (
 | 
			
		||||
            <FormattedMessage
 | 
			
		||||
              id='relationship_severance_notification.purged_data'
 | 
			
		||||
              defaultMessage='purged by administrators'
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <FormattedMessage
 | 
			
		||||
              id='relationship_severance_notification.relationships'
 | 
			
		||||
              defaultMessage='{count, plural, one {# relationship} other {# relationships}}'
 | 
			
		||||
              values={{ count: event.get('relationships_count', 0) }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          <br />
 | 
			
		||||
          <strong>{intl.formatMessage(messages[event.get('type')])}</strong>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className='notification__report__actions'>
 | 
			
		||||
          <a href='/severed_relationships' className='button' target='_blank' rel='noopener noreferrer'>
 | 
			
		||||
            <FormattedMessage id='relationship_severance_notification.view' defaultMessage='View' />
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
RelationshipsSeveranceEvent.propTypes = {
 | 
			
		||||
  event: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
  hidden: PropTypes.bool,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default RelationshipsSeveranceEvent;
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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) => {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/link_off-fill.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/javascript/material-icons/400-24px/link_off-fill.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m770-302-60-62q40-11 65-42.5t25-73.5q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 57-29.5 105T770-302ZM634-440l-80-80h86v80h-6ZM792-56 56-792l56-56 736 736-56 56ZM440-280H280q-83 0-141.5-58.5T80-480q0-69 42-123t108-71l74 74h-24q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h65l79 80H320Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 414 B  | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/link_off.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/javascript/material-icons/400-24px/link_off.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m770-302-60-62q40-11 65-42.5t25-73.5q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 57-29.5 105T770-302ZM634-440l-80-80h86v80h-6ZM792-56 56-792l56-56 736 736-56 56ZM440-280H280q-83 0-141.5-58.5T80-480q0-69 42-123t108-71l74 74h-24q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h65l79 80H320Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 414 B  | 
							
								
								
									
										27
									
								
								app/models/account_relationship_severance_event.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/models/account_relationship_severance_event.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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}:*")
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										56
									
								
								app/models/relationship_severance_event.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								app/models/relationship_severance_event.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										40
									
								
								app/models/severed_relationship.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/models/severed_relationship.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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?
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								app/views/severed_relationships/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/views/severed_relationships/index.html.haml
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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 }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
							
								
								
									
										27
									
								
								db/migrate/20240312105620_create_severed_relationships.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								db/migrate/20240312105620_create_severed_relationships.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
							
								
								
									
										41
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,6 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Fabricator(:account_relationship_severance_event) do
 | 
			
		||||
  account
 | 
			
		||||
  relationship_severance_event
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,6 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Fabricator(:relationship_severance_event) do
 | 
			
		||||
  type { :domain_block }
 | 
			
		||||
  target_name { 'example.com' }
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										8
									
								
								spec/fabricators/severed_relationship_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								spec/fabricators/severed_relationship_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										49
									
								
								spec/models/relationship_severance_event_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								spec/models/relationship_severance_event_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										45
									
								
								spec/models/severed_relationship_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								spec/models/severed_relationship_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										23
									
								
								spec/requests/severed_relationships_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								spec/requests/severed_relationships_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user