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