From f9326efef637a4bffe1f068208cfc0f4ec27482c Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Wed, 25 Feb 2026 15:32:07 +0100 Subject: [PATCH] Add moderation consequences for collections (#37974) --- app/helpers/admin/action_logs_helper.rb | 2 +- app/models/admin/moderation_action.rb | 64 +++++++++++++++---- app/models/collection.rb | 8 +++ app/policies/admin/collection_policy.rb | 19 ++++++ app/views/admin/reports/_actions.html.haml | 6 +- config/locales/en.yml | 2 + spec/models/admin/moderation_action_spec.rb | 48 ++++++++++++++ spec/models/collection_spec.rb | 14 ++++ spec/policies/admin/collection_policy_spec.rb | 24 +++++++ 9 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 app/policies/admin/collection_policy.rb create mode 100644 spec/policies/admin/collection_policy_spec.rb diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4a55a36ecd1..76edb965a6a 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -19,7 +19,7 @@ module Admin::ActionLogsHelper link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') - when 'Status' + when 'Status', 'Collection' link_to log.human_identifier, log.permalink when 'AccountWarning' link_to log.human_identifier, disputes_strike_path(log.target_id) diff --git a/app/models/admin/moderation_action.rb b/app/models/admin/moderation_action.rb index 915306d0f53..5ce640ec27d 100644 --- a/app/models/admin/moderation_action.rb +++ b/app/models/admin/moderation_action.rb @@ -18,6 +18,10 @@ class Admin::ModerationAction < Admin::BaseAction @statuses ||= Status.with_discarded.where(id: status_ids).reorder(nil) end + def collections + report.collections + end + def process_action! case type when 'delete' @@ -29,19 +33,16 @@ class Admin::ModerationAction < Admin::BaseAction def handle_delete! statuses.each { |status| authorize([:admin, status], :destroy?) } + collections.each { |collection| authorize([:admin, collection], :destroy?) } ApplicationRecord.transaction do - statuses.each do |status| - status.discard_with_reblogs - log_action(:destroy, status) - end - - report.resolve!(current_account) - log_action(:resolve, report) + delete_statuses! + delete_collections! + resolve_report! process_strike!(:delete_statuses) - statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local? + create_tombstones! unless target_account.local? end process_notification! @@ -50,10 +51,39 @@ class Admin::ModerationAction < Admin::BaseAction end def handle_mark_as_sensitive! - representative_account = Account.representative + collections.each { |collection| authorize([:admin, collection], :update?) } # Can't use a transaction here because UpdateStatusService queues # Sidekiq jobs + mark_statuses_as_sensitive! + mark_collections_as_sensitive! + + resolve_report! + process_strike!(:mark_statuses_as_sensitive) + process_notification! + end + + def delete_statuses! + statuses.each do |status| + status.discard_with_reblogs + log_action(:destroy, status) + end + end + + def delete_collections! + collections.each do |collection| + collection.destroy! + log_action(:destroy, collection) + end + end + + def create_tombstones! + (statuses + collections).each { |record| Tombstone.find_or_create_by(uri: record.uri, account: target_account, by_moderator: true) } + end + + def mark_statuses_as_sensitive! + representative_account = Account.representative + statuses.includes(:media_attachments, preview_cards_status: :preview_card).find_each do |status| next if status.discarded? || !(status.with_media? || status.with_preview_card?) @@ -66,14 +96,20 @@ class Admin::ModerationAction < Admin::BaseAction end log_action(:update, status) - - report.resolve!(current_account) - log_action(:resolve, report) end + end - process_strike!(:mark_statuses_as_sensitive) + def mark_collections_as_sensitive! + collections.each do |collection| + UpdateCollectionService.new.call(collection, sensitive: true) - process_notification! + log_action(:update, collection) + end + end + + def resolve_report! + report.resolve!(current_account) + log_action(:resolve, report) end def target_account diff --git a/app/models/collection.rb b/app/models/collection.rb index d8386e43b44..c018dd9fa42 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -69,6 +69,14 @@ class Collection < ApplicationRecord :featured_collection end + def to_log_human_identifier + account.acct + end + + def to_log_permalink + ActivityPub::TagManager.instance.uri_for(self) + end + private def tag_is_usable diff --git a/app/policies/admin/collection_policy.rb b/app/policies/admin/collection_policy.rb new file mode 100644 index 00000000000..c8fba74ef6a --- /dev/null +++ b/app/policies/admin/collection_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Admin::CollectionPolicy < ApplicationPolicy + def index? + role.can?(:manage_reports, :manage_users) + end + + def show? + role.can?(:manage_reports, :manage_users) + end + + def destroy? + role.can?(:manage_reports) + end + + def update? + role.can?(:manage_reports) + end +end diff --git a/app/views/admin/reports/_actions.html.haml b/app/views/admin/reports/_actions.html.haml index ef016e949bd..071c4634979 100644 --- a/app/views/admin/reports/_actions.html.haml +++ b/app/views/admin/reports/_actions.html.haml @@ -5,7 +5,7 @@ = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(report), method: :post, class: 'button' .report-actions__item__description = t('admin.reports.actions.resolve_description_html') - - if statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? } + - if report.collections.any? || statuses.any? { |status| (status.with_media? || status.with_preview_card?) && !status.discarded? } .report-actions__item .report-actions__item__button = form.button t('admin.reports.mark_as_sensitive'), @@ -18,8 +18,8 @@ = form.button t('admin.reports.delete_and_resolve'), name: :delete, class: 'button button--destructive', - disabled: statuses.empty?, - title: statuses.empty? ? t('admin.reports.actions_no_posts') : '' + disabled: (report.collections + statuses).empty?, + title: (report.collections + statuses).empty? ? t('admin.reports.actions_no_posts') : '' .report-actions__item__description = t('admin.reports.actions.delete_description_html') .report-actions__item diff --git a/config/locales/en.yml b/config/locales/en.yml index 93b8d4088e7..aeb0051a112 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -267,6 +267,7 @@ en: demote_user_html: "%{name} demoted user %{target}" destroy_announcement_html: "%{name} deleted announcement %{target}" destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}" + destroy_collection_html: "%{name} removed collection by %{target}" destroy_custom_emoji_html: "%{name} deleted emoji %{target}" destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}" destroy_domain_block_html: "%{name} unblocked domain %{target}" @@ -306,6 +307,7 @@ en: unsilence_account_html: "%{name} undid limit of %{target}'s account" unsuspend_account_html: "%{name} unsuspended %{target}'s account" update_announcement_html: "%{name} updated announcement %{target}" + update_collection_html: "%{name} updated collection by %{target}" update_custom_emoji_html: "%{name} updated emoji %{target}" update_domain_block_html: "%{name} updated domain block for %{target}" update_ip_block_html: "%{name} changed rule for IP %{target}" diff --git a/spec/models/admin/moderation_action_spec.rb b/spec/models/admin/moderation_action_spec.rb index b6c81d6c44d..fd2e7a4c4cb 100644 --- a/spec/models/admin/moderation_action_spec.rb +++ b/spec/models/admin/moderation_action_spec.rb @@ -32,6 +32,36 @@ RSpec.describe Admin::ModerationAction do end expect(report.reload).to be_action_taken end + + context 'with attached collections', feature: :collections do + let(:status_ids) { [] } + let(:collections) { Fabricate.times(2, :collection, account: target_account) } + + before do + report.collections = collections + end + + it 'deletes the collections and creates an action log' do + expect { subject.save! }.to change(Collection, :count).by(-2) + .and change(Admin::ActionLog, :count).by(3) + end + end + + context 'with a remote collection', feature: :collections do + let(:status_ids) { [] } + let(:collection) { Fabricate(:remote_collection) } + let(:target_account) { collection.account } + + before do + report.collections << collection + end + + it 'creates a tombstone' do + expect { subject.save! }.to change(Tombstone, :count).by(1) + + expect(Tombstone.last.uri).to eq collection.uri + end + end end context 'when `type` is `mark_as_sensitive`' do @@ -52,6 +82,24 @@ RSpec.describe Admin::ModerationAction do end expect(report.reload).to be_action_taken end + + context 'with attached collections', feature: :collections do + let(:status_ids) { [] } + let(:collections) { Fabricate.times(2, :collection, account: target_account) } + + before do + report.collections = collections + end + + it 'marks the collections as sensitive' do + subject.save! + + collections.each do |collection| + expect(collection.reload).to be_sensitive + end + expect(report.reload).to be_action_taken + end + end end end end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index b50969b68a9..ba1819fa6cd 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -138,4 +138,18 @@ RSpec.describe Collection do expect(subject.object_type).to eq :featured_collection end end + + describe '#to_log_human_identifier' do + subject { Fabricate(:collection) } + + it 'returns the account name' do + expect(subject.to_log_human_identifier).to eq subject.account.acct + end + end + + describe '#to_log_permalink' do + it 'includes the URI of the collection' do + expect(subject.to_log_permalink).to eq ActivityPub::TagManager.instance.uri_for(subject) + end + end end diff --git a/spec/policies/admin/collection_policy_spec.rb b/spec/policies/admin/collection_policy_spec.rb new file mode 100644 index 00000000000..69f3cb5fdac --- /dev/null +++ b/spec/policies/admin/collection_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::CollectionPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:admin_user).account } + let(:john) { Fabricate(:account) } + let(:collection) { Fabricate(:collection) } + + permissions :index?, :show?, :update?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Collection) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Collection) + end + end + end +end