Add interface for moderation subscriptions and suggestions

This commit is contained in:
Claire
2026-08-21 18:01:31 +02:00
parent 811f2eb08d
commit cdaffd1d42
31 changed files with 906 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m456-320 104-104 104 104 56-56-104-104 104-104-56-56-104 104-104-104-56 56 104 104-104 104 56 56Zm-96 160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm400 0Z"/></svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M280-440h400v-80H280v80ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 415 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <strong>%{domain}</strong> 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:

View File

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

View File

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

View File

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