From cdaffd1d42e314118ae4b695b53dd042ae5253ee Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 21 Aug 2026 18:01:31 +0200 Subject: [PATCH] Add interface for moderation subscriptions and suggestions --- .../moderation_subscriptions_controller.rb | 96 +++++++++++ .../moderation_suggestions_controller.rb | 159 ++++++++++++++++++ .../concerns/accountable_concern.rb | 4 +- app/helpers/admin/action_logs_helper.rb | 2 + app/helpers/admin/content_policies_helper.rb | 14 ++ .../admin/moderation_subscriptions_helper.rb | 32 ++++ .../admin/moderation_suggestions_helper.rb | 32 ++++ .../material-icons/400-24px/backspace.svg | 1 + .../400-24px/do_not_disturb_on.svg | 1 + app/javascript/styles/mastodon/admin.scss | 42 +++++ app/models/moderation_subscription.rb | 27 +++ app/models/moderation_suggestion.rb | 17 ++ .../moderation_subscription_policy.rb | 23 +++ app/policies/moderation_suggestion_policy.rb | 19 +++ .../_advisory.html.haml | 6 + .../moderation_subscriptions/_form.html.haml | 50 ++++++ .../_moderation_subscription.html.haml | 10 ++ .../moderation_subscriptions/edit.html.haml | 12 ++ .../moderation_subscriptions/index.html.haml | 20 +++ .../moderation_subscriptions/logs.html.haml | 16 ++ .../moderation_subscriptions/new.html.haml | 12 ++ .../shared/_links.html.haml | 6 + .../moderation_subscriptions/show.html.haml | 53 ++++++ .../_moderation_suggestion_target.html.haml | 38 +++++ .../confirm_downgrade.html.haml | 13 ++ .../confirm_reject.html.haml | 21 +++ .../moderation_suggestions/index.html.haml | 19 +++ config/locales/en.yml | 94 +++++++++++ config/navigation.rb | 1 + config/routes/admin.rb | 12 ++ .../admin/moderation_subscriptions_spec.rb | 56 ++++++ 31 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 app/controllers/admin/moderation_subscriptions_controller.rb create mode 100644 app/controllers/admin/moderation_suggestions_controller.rb create mode 100644 app/helpers/admin/moderation_subscriptions_helper.rb create mode 100644 app/helpers/admin/moderation_suggestions_helper.rb create mode 100644 app/javascript/material-icons/400-24px/backspace.svg create mode 100644 app/javascript/material-icons/400-24px/do_not_disturb_on.svg create mode 100644 app/policies/moderation_subscription_policy.rb create mode 100644 app/policies/moderation_suggestion_policy.rb create mode 100644 app/views/admin/moderation_subscriptions/_advisory.html.haml create mode 100644 app/views/admin/moderation_subscriptions/_form.html.haml create mode 100644 app/views/admin/moderation_subscriptions/_moderation_subscription.html.haml create mode 100644 app/views/admin/moderation_subscriptions/edit.html.haml create mode 100644 app/views/admin/moderation_subscriptions/index.html.haml create mode 100644 app/views/admin/moderation_subscriptions/logs.html.haml create mode 100644 app/views/admin/moderation_subscriptions/new.html.haml create mode 100644 app/views/admin/moderation_subscriptions/shared/_links.html.haml create mode 100644 app/views/admin/moderation_subscriptions/show.html.haml create mode 100644 app/views/admin/moderation_suggestions/_moderation_suggestion_target.html.haml create mode 100644 app/views/admin/moderation_suggestions/confirm_downgrade.html.haml create mode 100644 app/views/admin/moderation_suggestions/confirm_reject.html.haml create mode 100644 app/views/admin/moderation_suggestions/index.html.haml create mode 100644 spec/system/admin/moderation_subscriptions_spec.rb diff --git a/app/controllers/admin/moderation_subscriptions_controller.rb b/app/controllers/admin/moderation_subscriptions_controller.rb new file mode 100644 index 00000000000..eb517c8ede9 --- /dev/null +++ b/app/controllers/admin/moderation_subscriptions_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Admin::ModerationSubscriptionsController < Admin::BaseController + before_action :set_moderation_subscriptions, only: :index + before_action :set_moderation_subscription, only: [:show, :edit, :update, :destroy] + before_action :set_subscribed_advisories, only: :show + + def index + authorize :moderation_subscription, :index? + end + + def show + authorize @moderation_subscription, :show? + end + + def new + authorize :moderation_subscription, :create? + @moderation_subscription = ModerationSubscription.new + end + + def edit + authorize @moderation_subscription, :update? + end + + def create + authorize :moderation_subscription, :create? + + # TODO: require confirmation for dangerous subscriptions + + @moderation_subscription = ModerationSubscription.new(resource_params.merge('type' => 'csv_list')) + + if @moderation_subscription.save + log_action :create, @moderation_subscription + redirect_to admin_moderation_subscriptions_path + else + render :new + end + end + + def update + authorize @moderation_subscription, :update? + + # TODO: require confirmation for dangerous subscriptions + + if @moderation_subscription.update(resource_params) + log_action :update, @moderation_subscription + redirect_to admin_moderation_subscription_path(@moderation_subscription) + else + render :edit + end + end + + def destroy + authorize @moderation_subscription, :destroy? + + ModerationSubscription.transaction do + # Re-run moderation list service with empty list to ensure + # Retractions are handled properly + @moderation_subscription.advisories.delete_all + ProcessModerationListsService.new.call + + @moderation_subscription.destroy! + log_action :destroy, @moderation_subscription + end + + redirect_to admin_moderation_subscriptions_path, notice: I18n.t('admin.moderation_subscriptions.destroyed_msg') + end + + def logs + authorize :moderation_subscription, :index? + + @action_logs = Admin::ActionLog.latest.where.not(moderation_subscription_id: nil).page(params[:page]) + end + + private + + def resource_params + params.expect( + moderation_subscription: [ + :name, :url, :priority, :list_action, :apply_conditions, :retract_automatically + ] + ) + end + + def set_moderation_subscriptions + @moderation_subscriptions = ModerationSubscription.order(priority: :asc) + end + + def set_moderation_subscription + @moderation_subscription = ModerationSubscription.find(params[:id]) + end + + def set_subscribed_advisories + @subscribed_advisories = @moderation_subscription.advisories.order(id: :asc).page(params[:page]) + end +end diff --git a/app/controllers/admin/moderation_suggestions_controller.rb b/app/controllers/admin/moderation_suggestions_controller.rb new file mode 100644 index 00000000000..9ca358511d6 --- /dev/null +++ b/app/controllers/admin/moderation_suggestions_controller.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +class Admin::ModerationSuggestionsController < Admin::BaseController + before_action :set_moderation_suggestion_targets, only: :index + before_action :set_moderation_suggestions_by_target, only: :index + before_action :set_moderation_advisories_by_target, only: :index + before_action :set_current_state_by_target, only: :index + before_action :set_moderation_suggestion, only: [:destroy, :apply] + + def index + authorize :moderation_suggestion, :index? + end + + def destroy + authorize @moderation_suggestion, :dismiss? + + # TODO: log? + + # Actually dismiss all of the suggestions for the same target + ModerationSuggestion.where(target_type: @moderation_suggestion.target_type, target_key: @moderation_suggestion.target_key).update_all(state: :dismissed) + + redirect_to admin_moderation_suggestions_path, notice: I18n.t('admin.moderation_suggestions.destroyed_msg', target_key: @moderation_suggestion.target_key) + end + + def apply + authorize @moderation_suggestion, :apply? + + case [@moderation_suggestion.target_type, @moderation_suggestion.action] + when ['domain', 'accept'] + domain_allow = @moderation_suggestion.to_domain_allow + + ApplicationRecord.transaction do + # TODO: log + domain_allow.save! + @moderation_suggestion.mark_as_applied! + end + + redirect_to admin_moderation_suggestions_path, notice: I18n.t('admin.moderation_suggestions.applied_msg', target_key: @moderation_suggestion.target_key) + when ['domain', 'reject'], ['domain', 'limit'] + domain_block = @moderation_suggestion.to_domain_block + + # TODO: factor with `DomainBlocksController#create`? + existing_domain_block = DomainBlock.rule_for(domain_block.domain) + + # We can't create a laxer block for a subdomain than we have for a domain + if existing_domain_block.present? && existing_domain_block.domain != TagManager.instance.normalize_domain(domain_block.domain) && !domain_block.stricter_than?(existing_domain_block) + return redirect_to admin_moderation_suggestions_path, + alert: I18n.t('admin.moderation_suggestions.unable_to_apply_msg', + target_key: @moderation_suggestion.target_key) + end + + update = false + + # Allow transparently upgrading a domain block + if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(domain_block.domain) + # Downgrading requires confirmation + return render :confirm_downgrade unless domain_block.stricter_than?(existing_domain_block) || params[:confirm_downgrade] + + # Transparent upgrading is allowed + existing_domain_block.assign_attributes(domain_block.attributes.without('id', 'created_at', 'updated_at')) + domain_block = existing_domain_block + + update = domain_block.severity_changed? + end + + # Require explicit confirmation on block + return render :confirm_reject if requires_confirmation?(domain_block) + + domain_block.save! + # TODO: is this the way we want to log it? + log_action (update ? :update : :create), domain_block, moderation_subscription_id: domain_block.moderation_subscription_id + DomainBlockWorker.perform_async(domain_block.id, update) + @moderation_suggestion.mark_as_applied! + + redirect_to admin_moderation_suggestions_path, notice: I18n.t('admin.moderation_suggestions.applied_msg', target_key: @moderation_suggestion.target_key) + when ['domain', 'retract'] + ApplicationRecord.transaction do + if Rails.configuration.x.mastodon.limited_federation_mode + domain_allow = DomainAllow.find_by(domain: @moderation_suggestion.target_key) + UnallowDomainService.new.call(domain_allow) + # TODO: is this the way we want to log it? + log_action :destroy, domain_allow, moderation_subscription_id: domain_block.moderation_subscription_id + else + domain_block = DomainBlock.find_by(domain: @moderation_suggestion.target_key) + UnblockDomainService.new.call(domain_block) + # TODO: is this the way we want to log it? + log_action :destroy, domain_block, moderation_subscription_id: domain_block.moderation_subscription_id + end + + @moderation_suggestion.mark_as_applied! + end + + redirect_to admin_moderation_suggestions_path, notice: I18n.t('admin.moderation_suggestions.retracted_msg', target_key: @moderation_suggestion.target_key) + end + end + + private + + def requires_confirmation?(domain_block) + domain_block.valid? && (domain_block.new_record? || domain_block.severity_changed?) && domain_block.suspend? && !params[:confirm] + end + + def set_moderation_suggestion_targets + @moderation_suggestion_targets = ModerationSuggestion.where(state: ['new', 'mailed']).reorder([target_type: :asc, target_key: :asc]).distinct.pluck(:target_type, :target_key) + end + + def set_moderation_suggestions_by_target + @moderation_suggestions_by_target = begin + @moderation_suggestion_targets.group_by(&:first).reduce(ModerationSuggestion.none) do |scope, (target_type, target_pairs)| + scope.or(ModerationSuggestion.where(target_type: target_type, target_key: target_pairs.map(&:second))) + end + end.group_by { |suggestion| [suggestion.target_type, suggestion.target_key] } + end + + def set_moderation_advisories_by_target + @moderation_advisories_by_target = begin + @moderation_suggestion_targets.group_by(&:first).reduce(SubscribedAdvisory.none) do |scope, (target_type, target_pairs)| + scope.or(SubscribedAdvisory.joins(:moderation_subscription).where(target_type: target_type, target_key: target_pairs.map(&:second))) + end + end.group_by { |advisory| [advisory.target_type, advisory.target_key] } + + @moderation_advisories_by_target.each_value do |advisories| + advisories.sort_by! { |advisory| advisory.moderation_subscription.priority } + end + + @moderation_advisories_by_target + end + + def set_current_state_by_target + # TODO: handle other target types + + if Rails.configuration.x.mastodon.limited_federation_mode + @current_state_by_target = DomainAllow.where(domain: @moderation_suggestion_targets.filter_map { |type, key| key if type == 'domain' }).pluck(:domain).to_h { |domain| [['domain', domain], 'accept'] } + @current_state_by_target.default = 'reject' + else + @current_state_by_target = DomainBlock.where(domain: @moderation_suggestion_targets.filter_map { |type, key| key if type == 'domain' }).to_h do |domain_block| + action = begin + case domain_block.severity + when 'suspend' + 'reject' + when 'silence' + 'limit' + else + 'accept' + end + end + + [['domain', domain_block.domain], action] + end + + @current_state_by_target.default = 'accept' + end + @current_state_by_target + end + + def set_moderation_suggestion + @moderation_suggestion = ModerationSuggestion.find(params[:id]) + end +end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb index 9c16d573c57..0e075084e2e 100644 --- a/app/controllers/concerns/accountable_concern.rb +++ b/app/controllers/concerns/accountable_concern.rb @@ -3,9 +3,9 @@ module AccountableConcern extend ActiveSupport::Concern - def log_action(action, target) + def log_action(action, target, **) current_account .action_logs - .create(action:, target:) + .create(action:, target:, **) end end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 2f675aeb7da..c3730c0100e 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -39,6 +39,8 @@ module Admin::ActionLogsHelper link_to log.human_identifier, admin_relays_path when 'Tag' link_to log.human_identifier, admin_tag_path(log.target_id) + when 'ModerationSubscription' + link_to log.human_identifier, admin_moderation_subscription_path(log.target_id) end end diff --git a/app/helpers/admin/content_policies_helper.rb b/app/helpers/admin/content_policies_helper.rb index 11c1109ed41..05df1b2f9fa 100644 --- a/app/helpers/admin/content_policies_helper.rb +++ b/app/helpers/admin/content_policies_helper.rb @@ -7,4 +7,18 @@ module Admin::ContentPoliciesHelper .map { |policy| I18n.t("admin.instances.content_policies.policies.#{policy}") } .join(' · ') end + + def moderation_subscription_policy_list(moderation_subscription) + policies = [] + + if moderation_subscription.apply_automatically? + policies << (moderation_subscription.preserve_relationships? ? :apply_advisories_safely : :apply_advisories) + end + + policies << :apply_retractions if moderation_subscription.retract_automatically? + + policies + .map { |policy| I18n.t("admin.moderation_subscriptions.policies.#{policy}") } + .join(' · ') + end end diff --git a/app/helpers/admin/moderation_subscriptions_helper.rb b/app/helpers/admin/moderation_subscriptions_helper.rb new file mode 100644 index 00000000000..c61fb38c8cb --- /dev/null +++ b/app/helpers/admin/moderation_subscriptions_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin::ModerationSubscriptionsHelper + def moderation_subscription_action_label(type) + safe_join( + [ + t("admin.moderation_subscriptions.list_actions.#{type}"), + content_tag(:span, t("admin.moderation_subscriptions.list_actions_hint.#{type}"), class: 'hint'), + ] + ) + end + + def moderation_subscription_apply_conditions_label(type) + safe_join( + [ + t("admin.moderation_subscriptions.apply_conditions.#{type}"), + content_tag(:span, t("admin.moderation_subscriptions.apply_conditions_hint.#{type}"), class: 'hint'), + ] + ) + end + + def moderation_subscription_badge(subscription) + action = subscription.list_action || 'default' + return unless %w(accept reject default).include?(action) + + content_tag( + :span, + I18n.t("admin.moderation_subscriptions.list_actions_short.#{action}"), + class: ['information-badge', action] + ) + end +end diff --git a/app/helpers/admin/moderation_suggestions_helper.rb b/app/helpers/admin/moderation_suggestions_helper.rb new file mode 100644 index 00000000000..c501e2ebe62 --- /dev/null +++ b/app/helpers/admin/moderation_suggestions_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Admin::ModerationSuggestionsHelper + ACTION_ICON_NAMES = { + accept: 'check', + reject: 'block', + limit: 'do_not_disturb_on', + retract: 'backspace', + }.freeze + + def advisory_action_icon(action) + material_symbol(ACTION_ICON_NAMES.fetch(action.to_sym)) + end + + def advisory_action_link(action, suggestion) + link_to( + safe_join([advisory_action_icon(action), t("admin.moderation_suggestions.apply_#{action}")]), + suggestion ? apply_admin_moderation_suggestion_path(suggestion) : nil, + { class: ['button', 'button-secondary', disabled: !suggestion], method: :post } + ) + end + + def suggestion_status_badge(current_state) + return unless %w(accept limit reject).include?(current_state) + + content_tag( + :span, + t("admin.moderation_suggestions.current_states.#{current_state}"), + class: ['information-badge', current_state] + ) + end +end diff --git a/app/javascript/material-icons/400-24px/backspace.svg b/app/javascript/material-icons/400-24px/backspace.svg new file mode 100644 index 00000000000..51a173b20fb --- /dev/null +++ b/app/javascript/material-icons/400-24px/backspace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/do_not_disturb_on.svg b/app/javascript/material-icons/400-24px/do_not_disturb_on.svg new file mode 100644 index 00000000000..2cdfd907ca1 --- /dev/null +++ b/app/javascript/material-icons/400-24px/do_not_disturb_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 5917f64963d..9655a7f1765 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -2511,3 +2511,45 @@ a.sparkline { } } } + +// TODO: Temporary hacks for moderation subscriptions interface, rework after design pass + +.information-badge { + &.accept { + color: var(--color-text-success); + background-color: var(--color-bg-success-softest); + border-color: var(--color-border-success-soft); + } + + &.reject { + color: var(--color-text-error); + background-color: var(--color-bg-error-softest); + border-color: var(--color-border-error-soft); + } + + &.limit { + color: var(--color-text-warning); + background-color: var(--color-bg-warning-softest); + border-color: var(--color-border-warning-soft); + } +} + +.permissions-list__item__text__type a { + color: inherit; +} + +.moderation_suggestion-retract { + color: inherit; +} + +.moderation_suggestion-limit { + color: var(--color-text-warning); +} + +.moderation_suggestion-reject { + color: var(--color-text-error); +} + +.permissions-list__item .table-action-link { + padding: 0; +} diff --git a/app/models/moderation_subscription.rb b/app/models/moderation_subscription.rb index e51dd3ecb32..64d1eb639a8 100644 --- a/app/models/moderation_subscription.rb +++ b/app/models/moderation_subscription.rb @@ -40,4 +40,31 @@ class ModerationSubscription < ApplicationRecord accept: 0, reject: 1, }, suffix: :action + + def to_log_human_identifier + name + end + + def apply_conditions + if apply_automatically? + preserve_relationships? ? :safely : :always + else + :never + end + end + + def apply_conditions=(conditions) + case conditions.to_s + when 'never' + self.apply_automatically = false + when 'always' + self.apply_automatically = true + self.preserve_relationships = false + when 'safely' + self.apply_automatically = true + self.preserve_relationships = true + else + raise ArgumentError + end + end end diff --git a/app/models/moderation_suggestion.rb b/app/models/moderation_suggestion.rb index a45c66e9374..ae04e774b2c 100644 --- a/app/models/moderation_suggestion.rb +++ b/app/models/moderation_suggestion.rb @@ -36,6 +36,23 @@ class ModerationSuggestion < ApplicationRecord before_validation :normalize_target_key + def mark_as_applied! + update!(state: :applied) + ModerationSuggestion.where(target_type: target_type, target_key: target_key).where.not(id: id).update_all(state: :dismissed) + end + + def to_domain_allow + raise ArgumentError unless target_type == 'domain' && action == 'accept' + + DomainAllow.new(domain: target_key, moderation_subscription_id: moderation_subscription_id) + end + + def to_domain_block + raise ArgumentError unless target_type == 'domain' && %w(limit reject).include?(action) + + DomainBlock.new(domain: target_key, moderation_subscription_id: moderation_subscription_id, severity: action == 'reject' ? :suspend : :silence) + end + private def normalize_target_key diff --git a/app/policies/moderation_subscription_policy.rb b/app/policies/moderation_subscription_policy.rb new file mode 100644 index 00000000000..346a2f6ecab --- /dev/null +++ b/app/policies/moderation_subscription_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ModerationSubscriptionPolicy < ApplicationPolicy + def index? + role.can?(:manage_federation) + end + + def show? + role.can?(:manage_federation) + end + + def create? + role.can?(:manage_federation) + end + + def update? + role.can?(:manage_federation) + end + + def destroy? + role.can?(:manage_federation) + end +end diff --git a/app/policies/moderation_suggestion_policy.rb b/app/policies/moderation_suggestion_policy.rb new file mode 100644 index 00000000000..2ab55a81256 --- /dev/null +++ b/app/policies/moderation_suggestion_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ModerationSuggestionPolicy < ApplicationPolicy + def index? + role.can?(:manage_federation) + end + + def show? + role.can?(:manage_federation) + end + + def dismiss? + role.can?(:manage_federation) + end + + def apply? + role.can?(:manage_federation) + end +end diff --git a/app/views/admin/moderation_subscriptions/_advisory.html.haml b/app/views/admin/moderation_subscriptions/_advisory.html.haml new file mode 100644 index 00000000000..120a919fad2 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/_advisory.html.haml @@ -0,0 +1,6 @@ +.directory__tag + = link_to admin_instance_path(advisory.target_key) do + %h4 + = advisory.target_key + + %small= t('admin.moderation_subscriptions.advisory_action', action: t("admin.moderation_subscriptions.advisory_actions.#{advisory.action}")) diff --git a/app/views/admin/moderation_subscriptions/_form.html.haml b/app/views/admin/moderation_subscriptions/_form.html.haml new file mode 100644 index 00000000000..f316a78e03f --- /dev/null +++ b/app/views/admin/moderation_subscriptions/_form.html.haml @@ -0,0 +1,50 @@ +.fields-group + = form.input :name, + label: t('admin.moderation_subscriptions.name'), + wrapper: :with_label + +.fields-group + = form.input :url, + label: t('admin.moderation_subscriptions.url'), + wrapper: :with_label + +.fields-group + = form.input :priority, + label: t('admin.moderation_subscriptions.priority'), + hint: t('admin.moderation_subscriptions.priority_hint'), + wrapper: :with_label + +.fields-group + = form.input :list_action, + as: :radio_buttons, + collection: [nil, 'accept', 'reject'], + collection_wrapper_tag: 'ul', + item_wrapper_tag: 'li', + label_method: ->(type) { moderation_subscription_action_label(type || 'default') }, + label: t('admin.moderation_subscriptions.list_action'), + wrapper: :with_label + +.fields-group + = form.input :apply_conditions, + as: :radio_buttons, + collection: %w(never safely always), + collection_wrapper_tag: 'ul', + item_wrapper_tag: 'li', + include_blank: false, + label_method: ->(type) { moderation_subscription_apply_conditions_label(type) }, + label: t('admin.moderation_subscriptions.apply_automatically'), + wrapper: :with_label + +.fields-group + = form.input :override_unattributed, + as: :boolean, + wrapper: :with_label, + label: t('admin.moderation_subscriptions.override_unattributed'), + hint: t('admin.moderation_subscriptions.override_unattributed_hint') + +.fields-group + = form.input :retract_automatically, + as: :boolean, + wrapper: :with_label, + label: t('admin.moderation_subscriptions.retract_automatically'), + hint: t('admin.moderation_subscriptions.retract_automatically_hint') diff --git a/app/views/admin/moderation_subscriptions/_moderation_subscription.html.haml b/app/views/admin/moderation_subscriptions/_moderation_subscription.html.haml new file mode 100644 index 00000000000..9d9d0c99632 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/_moderation_subscription.html.haml @@ -0,0 +1,10 @@ +.applications-list__item + = link_to admin_moderation_subscription_path(moderation_subscription), class: 'announcements-list__item__title' do + = moderation_subscription.name + = moderation_subscription_badge(moderation_subscription) + + .announcements-list__item__permissions + .announcements-list__item__meta + = moderation_subscription.url + .announcements-list__item__meta + = moderation_subscription_policy_list(moderation_subscription) diff --git a/app/views/admin/moderation_subscriptions/edit.html.haml b/app/views/admin/moderation_subscriptions/edit.html.haml new file mode 100644 index 00000000000..9db67a4866c --- /dev/null +++ b/app/views/admin/moderation_subscriptions/edit.html.haml @@ -0,0 +1,12 @@ +- content_for :page_title do + = I18n.t('admin.moderation_subscriptions.show.title', name: @moderation_subscription.name) + += simple_form_for [:admin, @moderation_subscription] do |form| + = render 'shared/error_messages', object: @moderation_subscription + + = render form + + .actions + = form.button :button, + t('generic.save_changes'), + type: :submit diff --git a/app/views/admin/moderation_subscriptions/index.html.haml b/app/views/admin/moderation_subscriptions/index.html.haml new file mode 100644 index 00000000000..eff2a9735d5 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/index.html.haml @@ -0,0 +1,20 @@ +- content_for(:page_title) do + = t('admin.moderation_subscriptions.title') + +- content_for(:heading) do + .content__heading__row + %h1= yield :page_title + + .content__heading__actions + = link_to t('admin.moderation_subscriptions.add_new'), new_admin_moderation_subscription_path, class: 'button', id: 'add-subscription-button' if can?(:create, :moderation_subscription) + + = render 'admin/moderation_subscriptions/shared/links' + +%hr.spacer/ + +- if @moderation_subscriptions.empty? + .muted-hint.center-text + = t 'admin.moderation_subscriptions.empty' +- else + .applications-list + = render partial: 'moderation_subscription', collection: @moderation_subscriptions diff --git a/app/views/admin/moderation_subscriptions/logs.html.haml b/app/views/admin/moderation_subscriptions/logs.html.haml new file mode 100644 index 00000000000..0246fd5e224 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/logs.html.haml @@ -0,0 +1,16 @@ +- content_for(:heading) do + .content__heading__row + %h1= t('admin.moderation_subscriptions_logs.title') + + = render 'admin/moderation_subscriptions/shared/links' + +%hr.spacer/ + +- if @action_logs.empty? + .muted-hint.center-text + = t 'admin.action_logs.empty' +- else + .report-notes + = render partial: 'admin/action_logs/action_log', collection: @action_logs + += paginate @action_logs diff --git a/app/views/admin/moderation_subscriptions/new.html.haml b/app/views/admin/moderation_subscriptions/new.html.haml new file mode 100644 index 00000000000..b5bdc337005 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/new.html.haml @@ -0,0 +1,12 @@ +- content_for :page_title do + = t('admin.moderation_subscriptions.new.title') + += simple_form_for [:admin, @moderation_subscription] do |form| + = render 'shared/error_messages', object: @moderation_subscription + + = render form + + .actions + = form.button :button, + t('.create'), + type: :submit diff --git a/app/views/admin/moderation_subscriptions/shared/_links.html.haml b/app/views/admin/moderation_subscriptions/shared/_links.html.haml new file mode 100644 index 00000000000..05db9dda5cd --- /dev/null +++ b/app/views/admin/moderation_subscriptions/shared/_links.html.haml @@ -0,0 +1,6 @@ +.content__heading__tabs + = render_navigation renderer: :links do |primary| + :ruby + primary.item :suggestions, safe_join([material_symbol('flag'), t('admin.moderation_subscriptions.tabs.suggestions')]), admin_moderation_suggestions_path + primary.item :subscriptions, safe_join([material_symbol('edit'), t('admin.moderation_subscriptions.tabs.subscriptions')]), admin_moderation_subscriptions_path + primary.item :logs, safe_join([material_symbol('list'), t('admin.moderation_subscriptions.tabs.logs')]), logs_admin_moderation_subscriptions_path diff --git a/app/views/admin/moderation_subscriptions/show.html.haml b/app/views/admin/moderation_subscriptions/show.html.haml new file mode 100644 index 00000000000..578c7bcc0b8 --- /dev/null +++ b/app/views/admin/moderation_subscriptions/show.html.haml @@ -0,0 +1,53 @@ +- content_for :page_title do + = I18n.t('admin.moderation_subscriptions.show.title', name: @moderation_subscription.name) + +- content_for :heading_actions do + - if can?(:destroy, @moderation_subscription) + = link_to t('admin.moderation_subscriptions.delete'), + admin_moderation_subscription_path(@moderation_subscription), + method: :delete, + data: { confirm: t('admin.moderation_subscriptions.show.delete_confirm') }, + class: 'button button--destructive' + +.table-wrapper + %table.table.inline-table + %tbody + %tr + %th= t('admin.moderation_subscriptions.priority') + %td= @moderation_subscription.priority + %tr + %th= t('admin.moderation_subscriptions.type') + %td= t("admin.moderation_subscriptions.types.#{@moderation_subscription.type}") + %tr + %th= t('admin.moderation_subscriptions.url') + %td= @moderation_subscription.url + %tr + %th= t('admin.moderation_subscriptions.list_action') + %td= t("admin.moderation_subscriptions.list_actions.#{@moderation_subscription.list_action || 'default'}") + %tr + %th= t('admin.moderation_subscriptions.apply_automatically') + %td= t("admin.moderation_subscriptions.apply_conditions.#{@moderation_subscription.apply_conditions}") + %tr + %th= t('admin.moderation_subscriptions.override_unattributed') + %td= @moderation_subscription.override_unattributed? ? t('simple_form.yes') : t('simple_form.no') + %tr + %th= t('admin.moderation_subscriptions.retract_automatically') + %td= @moderation_subscription.retract_automatically? ? t('admin.moderation_subscriptions.apply_conditions.always') : t('admin.moderation_subscriptions.apply_conditions.never') + - if @moderation_subscription.last_synced_at.present? + %tr + %th= t('admin.moderation_subscriptions.last_synced_at') + %td + %time.formatted{ datetime: @moderation_subscription.last_synced_at.iso8601 }= @moderation_subscription.last_synced_at + +.action-buttons + = link_to t('admin.moderation_subscriptions.show.edit'), edit_admin_moderation_subscription_path(@moderation_subscription), class: 'button' if can?(:update, @moderation_subscription) + +%hr.spacer/ + +- if @subscribed_advisories.empty? + .muted-hint.center-text + = t('admin.moderation_subscriptions.show.empty') +- else + = render partial: 'advisory', collection: @subscribed_advisories + += paginate @subscribed_advisories diff --git a/app/views/admin/moderation_suggestions/_moderation_suggestion_target.html.haml b/app/views/admin/moderation_suggestions/_moderation_suggestion_target.html.haml new file mode 100644 index 00000000000..30b77b8d142 --- /dev/null +++ b/app/views/admin/moderation_suggestions/_moderation_suggestion_target.html.haml @@ -0,0 +1,38 @@ +.applications-list__item + - target_type, target_key = moderation_suggestion_target + - suggestions = moderation_suggestions_by_target[[target_type, target_key]] + + = link_to admin_instance_path(target_key), class: 'announcements-list__item__title' do + = target_key + = suggestion_status_badge(current_state_by_target[[target_type, target_key]]) + + .announcements-list__item__action-bar + .announcements-list__item__meta + = t('admin.moderation_suggestions.description') + + .announcements-list__item__actions + = table_link_to 'close', t('admin.moderation_suggestions.dismiss'), admin_moderation_suggestion_path(suggestions.first), method: :delete + + .announcements-list__item__permissions + %ul.permissions-list + - actions = (moderation_advisories_by_target[[target_type, target_key]] || []).pluck(:action).uniq + - actions = ['retract'] + actions if suggestions&.any? { |suggestion| suggestion.action == 'retract' } + - actions.map do |action| + :ruby + advisories = moderation_advisories_by_target.fetch([target_type, target_key], []).filter { |advisory| advisory.action == action } + moderation_suggestion = suggestions&.find { |suggestion| suggestion.action == action } + subscriptions = (advisories.map(&:moderation_subscription) + [moderation_suggestion&.moderation_subscription]).compact.uniq + + %li.permissions-list__item + .permissions-list__item__icon{ class: "moderation_suggestion-#{action}" } + = advisory_action_icon(action) + .permissions-list__item__text + .permissions-list__item__text__title + = t(action, scope: 'admin.moderation_suggestions.actions') + .permissions-list__item__text__type + = t('admin.moderation_suggestions.suggested_from_html', link: to_sentence(subscriptions.map { |subscription| link_to(subscription.name, admin_moderation_subscription_path(subscription)) })) + - if moderation_suggestion && can?(:apply, moderation_suggestion) + = table_link_to 'check', + t("admin.moderation_suggestions.apply_#{moderation_suggestion.action}"), + apply_admin_moderation_suggestion_path(moderation_suggestion), + method: :post diff --git a/app/views/admin/moderation_suggestions/confirm_downgrade.html.haml b/app/views/admin/moderation_suggestions/confirm_downgrade.html.haml new file mode 100644 index 00000000000..f4baea43ec8 --- /dev/null +++ b/app/views/admin/moderation_suggestions/confirm_downgrade.html.haml @@ -0,0 +1,13 @@ +- content_for :page_title do + = t('admin.domain_blocks.confirm_downgrade.title', domain: Addressable::IDNA.to_unicode(@moderation_suggestion.target_key)) + += simple_form_for @moderation_suggestion, url: apply_admin_moderation_suggestion_path, method: :post do |f| + %p.hint= t('admin.domain_blocks.confirm_downgrade.preamble_html', domain: Addressable::IDNA.to_unicode(@moderation_suggestion.target_key)) + + = f.hidden_field :id + + %hr.spacer + + .actions + = link_to t('admin.domain_blocks.confirm_downgrade.cancel'), admin_moderation_suggestions_path, class: 'button button-secondary' + = f.button :submit, t('admin.domain_blocks.confirm_downgrade.confirm'), class: 'button button--dangerous', name: :confirm_downgrade diff --git a/app/views/admin/moderation_suggestions/confirm_reject.html.haml b/app/views/admin/moderation_suggestions/confirm_reject.html.haml new file mode 100644 index 00000000000..cf33f64237d --- /dev/null +++ b/app/views/admin/moderation_suggestions/confirm_reject.html.haml @@ -0,0 +1,21 @@ +- content_for :page_title do + = t('admin.domain_blocks.confirm_suspension.title', domain: Addressable::IDNA.to_unicode(@moderation_suggestion.target_key)) + += simple_form_for @moderation_suggestion, url: apply_admin_moderation_suggestion_path, method: :post do |f| + %p.hint= t('admin.domain_blocks.confirm_suspension.preamble_html', domain: Addressable::IDNA.to_unicode(@moderation_suggestion.target_key)) + %ul.hint + %li= t('admin.domain_blocks.confirm_suspension.stop_communication') + %li= t('admin.domain_blocks.confirm_suspension.remove_all_data') + %li= t('admin.domain_blocks.confirm_suspension.undo_relationships') + %li.negative-hint= t('admin.domain_blocks.confirm_suspension.permanent_action') + + = f.hidden_field :id + + %hr.spacer + + = react_admin_component :impact_report, + domain: @moderation_suggestion.target_key + + .actions + = link_to t('admin.domain_blocks.confirm_suspension.cancel'), admin_moderation_suggestions_path, class: 'button button-secondary' + = f.button :submit, t('admin.domain_blocks.confirm_suspension.confirm'), class: 'button button--dangerous', name: :confirm diff --git a/app/views/admin/moderation_suggestions/index.html.haml b/app/views/admin/moderation_suggestions/index.html.haml new file mode 100644 index 00000000000..5ec3a4f4568 --- /dev/null +++ b/app/views/admin/moderation_suggestions/index.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('admin.moderation_suggestions.title') + +- content_for(:heading) do + .content__heading__row + %h1= yield :page_title + + = render 'admin/moderation_subscriptions/shared/links' + +%hr.spacer/ + +- if @moderation_suggestion_targets.empty? + .muted-hint.center-text + = t 'admin.moderation_suggestions.empty' +- else + .applications-list + = render partial: 'moderation_suggestion_target', + collection: @moderation_suggestion_targets, + locals: { moderation_advisories_by_target: @moderation_advisories_by_target, moderation_suggestions_by_target: @moderation_suggestions_by_target, current_state_by_target: @current_state_by_target } diff --git a/config/locales/en.yml b/config/locales/en.yml index 8a0eeb75ae4..d647a8100d0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -279,6 +279,7 @@ en: create_domain_block_html: "%{name} blocked domain %{target}" create_email_domain_block_html: "%{name} blocked email domain %{target}" create_ip_block_html: "%{name} created rule for IP %{target}" + create_moderation_subscription_html: "%{name} created moderation subscription %{target}" create_relay_html: "%{name} created a relay %{target}" create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}" create_user_role_html: "%{name} created %{target} role" @@ -293,6 +294,7 @@ en: destroy_email_domain_block_html: "%{name} unblocked email domain %{target}" destroy_instance_html: "%{name} purged domain %{target}" destroy_ip_block_html: "%{name} deleted rule for IP %{target}" + destroy_moderation_subscription_html: "%{name} deleted moderation subscription %{target}" destroy_relay_html: "%{name} deleted the relay %{target}" destroy_status_html: "%{name} removed post by %{target}" destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}" @@ -342,6 +344,7 @@ en: update_username_block_html: "%{name} updated rule for usernames containing %{target}" usable_tag_html: can be used; deleted_account: deleted account + deleted_suggestion: a deleted moderation subscription empty: No logs found. filter_by_action: Filter by action filter_by_user: Filter by user @@ -460,6 +463,11 @@ en: undo: Disallow federation with domain domain_blocks: add_new: Add new domain block + confirm_downgrade: + cancel: Cancel + confirm: Downgrade + preamble_html: You are about to downgrade the existing block on %{domain} and its subdomains. + title: Confirm domain block downgrade for %{domain} confirm_suspension: cancel: Cancel confirm: Suspend @@ -758,6 +766,92 @@ en: reset: Reset search: Search title: IP rules + moderation_subscriptions: + add_new: Add subscription + advisory_action: 'Action: %{action}' + advisory_actions: + accept: allow + limit: silence + reject: suspend + apply_automatically: Apply advisories automatically + apply_conditions: + always: Always + never: Never + safely: Only if it does not break relationships + apply_conditions_hint: + always: Always apply recommended actions automatically. Only use this option for lists you fully trust. + never: Never apply recommended actions automatically. Always generate a suggestion instead. + safely: Apply recommended actions unless they would break existing follow relationships with local users. In such cases, a suggestion will be generated instead. + delete: Delete + destroyed_msg: Successfully deleted moderation subscription + empty: No moderation list has been subscribed to yet. + last_synced_at: Last synced at + list_action: Subscription type + list_actions: + accept: Allow-list + default: Mixed (Allow & Deny) + reject: Block-list + list_actions_hint: + accept: Items with no specified action will be treated as allowed. Blocks will be ignored. + default: Items with no specified action will be treated as blocks. Blocks and allows will both be treated as such. + reject: Items with no specified action will be treated as blocks. Allows will be ignored. + list_actions_short: + accept: Allow-list + default: Mixed + reject: Block-list + name: Name + new: + create: Subscribe + title: New moderation subscription + override_unattributed: Override manual blocks + override_unattributed_hint: Allow this subscription to automatically upgrade or take over existing manual blocks when automatically applying advisories. + policies: + apply_advisories: Automatically applies advisories + apply_advisories_safely: Automatically applies advisories that do not break relationships + apply_retractions: Automatically applies retractions + priority: Priority + priority_hint: Moderation subscriptions are evaluated in order of priority, subscriptions with a lower number override subscriptions with a higher one. + retract_automatically: Retract advisories automatically + retract_automatically_hint: Whether to allow retracting or downgrading policy changes when the subscription removes a corresponding item. When automatic retraction is enabled and an equivalent advisory exists in a lower-priority list, it will be adopted. When automatic retraction is disabled, a suggestion will be generated instead. + show: + delete_confirm: Are you sure you want to delete this moderation subscription? + edit: Edit subscription + empty: This subscription has no advisory yet. + title: 'Moderation subscription: %{name}' + tabs: + logs: Logs + subscriptions: Subscriptions + suggestions: Suggestions + title: Moderation subscriptions + type: Type + types: + csv_list: CSV list + url: URL + moderation_subscriptions_logs: + title: Moderation subscriptions logs + moderation_suggestions: + actions: + accept: Allow + limit: Limit + reject: Suspend + retract: Retract current policy + applied_msg: Successfully applied suggestion for %{target_key} + apply_accept: Allow + apply_limit: Limit + apply_reject: Suspend + apply_retract: Retract + current_states: + accept: currently allowed + limit: currently limited + reject: currently suspended + description: Below are suggestions from your moderation subscriptions, ordered by priority. + destroyed_msg: Successfully dismissed suggestion for %{target_key} + dismiss: Dismiss suggestions + empty: There is no moderation suggestion at this time. + retracted_msg: Successfully retracted policy for %{target_key} + suggested_from_html: Suggested by %{link} + title: Moderation suggestions + unable_to_apply_msg: Unable to apply suggestion for %{target_key} relationships: title: "%{acct}'s relationships" relays: diff --git a/config/navigation.rb b/config/navigation.rb index 361e06b3a4b..0b1fc77bb5d 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -62,6 +62,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :instances, safe_join([material_symbol('cloud'), t('admin.instances.title')]), admin_instances_path(limited: limited_federation_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows|/admin/export_domain_blocks}, if: lambda { current_user.can?(:manage_federation) } + s.item :moderation_subscriptions, safe_join([material_symbol('cloud_download'), t('admin.moderation_subscriptions.title')]), admin_moderation_suggestions_path, highlights_on: %r{/admin/moderation_subscriptions|/admin/moderation_suggestions}, if: -> { current_user.can?(:manage_federation) } s.item :email_domain_blocks, safe_join([material_symbol('mail'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) } s.item :username_blocks, safe_join([material_symbol('supervised_user_circle_off'), t('admin.username_blocks.title')]), admin_username_blocks_path, highlights_on: %r{/admin/username_blocks}, if: -> { current_user.can?(:manage_blocks) } s.item :ip_blocks, safe_join([material_symbol('hide_source'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) } diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 38f40052413..a367b77f143 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -110,6 +110,18 @@ namespace :admin do resources :moderation_notes, module: :instances, only: [:create, :destroy] end + resources :moderation_subscriptions do + collection do + get :logs + end + end + + resources :moderation_suggestions do + member do + post :apply + end + end + resources :rules, only: [:index, :new, :create, :edit, :update, :destroy] do member do post :move_up diff --git a/spec/system/admin/moderation_subscriptions_spec.rb b/spec/system/admin/moderation_subscriptions_spec.rb new file mode 100644 index 00000000000..9a8b2a5991a --- /dev/null +++ b/spec/system/admin/moderation_subscriptions_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'using the moderation subscription interface' do + before do + sign_in Fabricate(:admin_user), scope: :user + end + + context 'with a new subscription' do + it 'allows saving it, editing it and deleting it' do + visit admin_moderation_subscriptions_path + expect(page) + .to have_title(I18n.t('admin.moderation_subscriptions.title')) + + # Navigate to new subscription form + click_on I18n.t('admin.moderation_subscriptions.add_new') + expect(page) + .to have_title(I18n.t('admin.moderation_subscriptions.new.title')) + + # Submitting while leaving out some required fields + fill_in 'moderation_subscription_name', with: 'My first blocklist' + expect { click_on I18n.t('admin.moderation_subscriptions.new.create') } + .to_not change(ModerationSubscription, :count) + expect(page) + .to have_text(/errors below/) + + # Filling in the missing fields and submitting + fill_in 'moderation_subscription_url', with: 'https://example.org/blocklist.csv' + fill_in 'moderation_subscription_priority', with: 0 + expect { click_on I18n.t('admin.moderation_subscriptions.new.create') } + .to change(ModerationSubscription, :count).by(1) + + expect(page) + .to have_title(I18n.t('admin.moderation_subscriptions.title')) + + expect(ModerationSubscription.find_by(name: 'My first blocklist')) + .to have_attributes(url: 'https://example.org/blocklist.csv') + + # The new subscription is displayed and can be visited + click_on 'My first blocklist' + expect(page) + .to have_title(I18n.t('admin.moderation_subscriptions.show.title', name: 'My first blocklist')) + + # The subscription can be edited + click_on I18n.t('admin.moderation_subscriptions.show.edit') + fill_in 'moderation_subscription_priority', with: 10 + expect { click_on I18n.t('generic.save_changes') } + .to change { ModerationSubscription.find_by(name: 'My first blocklist').priority }.from(0).to(10) + + # The subscription can be deleted + expect { click_on I18n.t('admin.moderation_subscriptions.delete') } + .to change(ModerationSubscription, :count).by(-1) + end + end +end