Compare commits

...

4 Commits

17 changed files with 293 additions and 8 deletions

View 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 = RelationshipSeveranceEvent.about_account(current_account)
end
def following
respond_to do |format|
format.csv { send_data following_data, filename: 'following-TODO.csv' }
end
end
def followers
respond_to do |format|
format.csv { send_data followers_data, filename: 'followers-TODO.csv' }
end
end
private
def set_event
@event = RelationshipSeveranceEvent.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.where(account: current_account).includes(:target_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.where(target_account: current_account).includes(: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

View File

@ -37,6 +37,7 @@ class Notification < ApplicationRecord
favourite
poll
update
relationships_severed
admin.sign_up
admin.report
).freeze
@ -63,6 +64,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 }
@ -156,6 +158,11 @@ class Notification < ApplicationRecord
self.from_account_id = activity&.status&.account_id
when 'Account'
self.from_account_id = activity&.id
when 'RelationshipSeveranceEvent'
# 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
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: relationship_severance_events
#
# id :bigint(8) not null, primary key
# type :integer 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,
}
scope :about_account, ->(account) { where(id: SeveredRelationship.about_account(account).select(:relationship_severance_event_id)) }
def import_from_follows!(follows)
SeveredRelationship.insert_all( # rubocop:disable Rails/SkipsModelValidations
follows.pluck(:account_id, :target_account_id, :show_reblogs, :notify, :languages).map do |account_id, target_account_id, show_reblogs, notify, languages|
{
account_id: account_id,
target_account_id: target_account_id,
show_reblogs: show_reblogs,
notify: notify,
languages: languages,
relationship_severance_event_id: id,
}
end
)
end
end

View File

@ -0,0 +1,23 @@
# 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
# account_id :bigint(8) not null
# target_account_id :bigint(8) 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 :account
belongs_to :target_account, class_name: 'Account'
scope :about_account, ->(account) { where(account: account).or(where(target_account: account)) }
end

View File

@ -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::RelationshipSeveranceEventSerializer
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 == :relationship_severance_event
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::RelationshipSeveranceEventSerializer < ActiveModel::Serializer
attributes :id, :type, :domain, :created_at
def id
object.id.to_s
end
end

View File

@ -9,6 +9,7 @@ class AfterBlockDomainFromAccountService < BaseService
def call(account, domain)
@account = account
@domain = domain
@domain_block_event = nil
clear_notifications!
remove_follows!
@ -19,8 +20,9 @@ class AfterBlockDomainFromAccountService < BaseService
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_follows!(follows)
follows.each { |follow| UnfollowService.new.call(@account, follow.target_account) }
end
end
@ -29,8 +31,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_follows!(follows)
follows.each { |follow| reject_follow!(follow) }
end
end
@ -47,4 +50,8 @@ class AfterBlockDomainFromAccountService < BaseService
ActivityPub::DeliveryWorker.perform_async(Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), @account.id, follow.account.inbox_url)
end
def domain_block_event
@domain_block_event ||= RelationshipSeveranceEvent.create!(type: :user_domain_block, domain: @domain)
end
end

View File

@ -5,6 +5,7 @@ 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
end
@ -37,7 +38,7 @@ 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
@ -45,6 +46,10 @@ class BlockDomainService < BaseService
domain_block.domain
end
def domain_block_event
@domain_block_event ||= RelationshipSeveranceEvent.create!(type: :domain_block, domain: blocked_domain)
end
def blocked_domain_accounts
Account.by_domain_and_subdomains(blocked_domain)
end

View File

@ -72,6 +72,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 +85,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 +268,18 @@ class DeleteAccountService < BaseService
end
end
def record_severed_relationships!
return if relationship_severance_event.nil?
Follow.where(target_account: @account).in_batches do |follows|
relationship_severance_event.import_from_follows!(follows)
end
Follow.where(account: @account).in_batches do |follows|
relationship_severance_event.import_from_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 +319,8 @@ class DeleteAccountService < BaseService
def skip_activitypub?
@options[:skip_activitypub]
end
def relationship_severance_event
@options[:relationship_severance_event]
end
end

View File

@ -109,7 +109,7 @@ class NotifyService < BaseService
def blocked?
blocked = @recipient.suspended?
blocked ||= from_self? && @notification.type != :poll
blocked ||= from_self? && @notification.type != :poll && @notification.type != :relationships_severed
return blocked if message? && from_staff?

View File

@ -0,0 +1,31 @@
- 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}")
%td
- count = event.severed_relationships.where(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.where(target_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)

View File

@ -1648,10 +1648,19 @@ 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:
domain_block: Server suspension
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.
type: Event
statuses:
attached:
audio:

View File

@ -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 }

View File

@ -161,6 +161,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

View File

@ -0,0 +1,12 @@
# 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 :domain
t.timestamps
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class CreateSeveredRelationships < ActiveRecord::Migration[7.0]
def change
create_table :severed_relationships do |t|
t.references :relationship_severance_event, null: false, foreign_key: { on_delete: :cascade }
t.references :account, null: false, foreign_key: { on_delete: :cascade }
t.references :target_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade }
t.boolean :show_reblogs
t.boolean :notify
t.string :languages, array: true
t.timestamps
t.index [:relationship_severance_event_id, :account_id, :target_account_id], name: 'index_severed_relationships_on_event_account_and_target_account', unique: true
t.index [:account_id, :relationship_severance_event_id], name: 'index_severed_relationships_on_account_and_event'
t.index [:target_account_id, :relationship_severance_event_id], name: 'index_severed_relationships_on_target_account_and_event'
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
ActiveRecord::Schema[7.0].define(version: 2023_10_23_105620) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -813,6 +813,13 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) do
t.bigint "status_id", null: false
end
create_table "relationship_severance_events", force: :cascade do |t|
t.integer "type", null: false
t.string "domain"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "relays", force: :cascade do |t|
t.string "inbox_url", default: "", null: false
t.string "follow_activity_id"
@ -891,6 +898,23 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) 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 "account_id", null: false
t.bigint "target_account_id", 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 ["account_id", "relationship_severance_event_id"], name: "index_severed_relationships_on_account_and_event"
t.index ["account_id"], name: "index_severed_relationships_on_account_id"
t.index ["relationship_severance_event_id", "account_id", "target_account_id"], name: "index_severed_relationships_on_event_account_and_target_account", unique: true
t.index ["relationship_severance_event_id"], name: "index_severed_relationships_on_relationship_severance_event_id"
t.index ["target_account_id", "relationship_severance_event_id"], name: "index_severed_relationships_on_target_account_and_event"
t.index ["target_account_id"], name: "index_severed_relationships_on_target_account_id"
end
create_table "site_uploads", force: :cascade do |t|
t.string "var", default: "", null: false
t.string "file_file_name"
@ -1252,6 +1276,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_07_150100) 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: "target_account_id", on_delete: :cascade
add_foreign_key "severed_relationships", "accounts", 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