diff --git a/app/controllers/admin/emergency_rules_controller.rb b/app/controllers/admin/emergency_rules_controller.rb
new file mode 100644
index 00000000000000..82e90df14b6974
--- /dev/null
+++ b/app/controllers/admin/emergency_rules_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Admin
+ class EmergencyRulesController < BaseController
+ before_action :set_rule, except: [:index]
+
+ def index
+ authorize [:emergency, :rule], :index?
+
+ @rules = Emergency::Rule.all.to_a
+ end
+
+ def deactivate
+ authorize @rule, :deactivate?
+ # TODO: log?
+ @rule.deactivate!
+
+ redirect_to admin_emergency_rules_path, notice: I18n.t('admin.emergency_rules.deactivated_msg')
+ end
+
+ private
+
+ def set_rule
+ @rule = Emergency::Rule.find(params[:id])
+ end
+ end
+end
diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb
index 6096ff1381e7bb..e82cbd471d92f6 100644
--- a/app/helpers/admin/dashboard_helper.rb
+++ b/app/helpers/admin/dashboard_helper.rb
@@ -36,4 +36,12 @@ def relevant_account_timestamp(account)
content_tag(:time, l(timestamp), class: 'time-ago', datetime: timestamp.iso8601, title: l(timestamp))
end
+
+ def emergency_rule_notice
+ return unless Emergency::Rule.any_active?
+
+ content_tag(:div, class: 'flash-message warning') do
+ I18n.t('admin.emergency_rules.notice_html', path: admin_emergency_rules_path).html_safe
+ end
+ end
end
diff --git a/app/policies/emergency/rule_policy.rb b/app/policies/emergency/rule_policy.rb
new file mode 100644
index 00000000000000..e2f2c494056cac
--- /dev/null
+++ b/app/policies/emergency/rule_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Emergency::RulePolicy < ApplicationPolicy
+ def create?
+ false # TODO
+ end
+
+ def index?
+ role.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks)
+ end
+
+ def deactivate?
+ role.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks)
+ end
+end
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 8354441895deb8..218d329f41fca3 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -1,6 +1,8 @@
- content_for :page_title do
= t('admin.accounts.title')
+= emergency_rule_notice
+
= form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
.filters
.filter-subset.filter-subset--with-select
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 3597152e09ed49..907e1a25c12d52 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,3 +1,5 @@
+= emergency_rule_notice
+
- content_for :page_title do
= t('admin.dashboard.title')
diff --git a/app/views/admin/emergency_rules/_emergency_rule.html.haml b/app/views/admin/emergency_rules/_emergency_rule.html.haml
new file mode 100644
index 00000000000000..361c0f21efdca4
--- /dev/null
+++ b/app/views/admin/emergency_rules/_emergency_rule.html.haml
@@ -0,0 +1,24 @@
+.filters-list__item
+ .filters-list__item__title
+ = emergency_rule.name
+
+ - if emergency_rule.active?
+ .expiration
+ - if can?(:deactivate, emergency_rule)
+ = link_to t('admin.emergency_rules.index.deactivate'), deactivate_admin_emergency_rule_path(emergency_rule.id), method: :post, class: 'button button-tertiary button--destructive'
+ - else
+ = t('admin.emergency_rules.active')
+
+ .filters-list__item__permissions
+ %ul.permissions-list
+ = render partial: 'trigger', collection: emergency_rule.triggers
+ = render partial: 'rate_limit_action', collection: emergency_rule.rate_limit_actions
+ = render partial: 'setting_override_action', collection: emergency_rule.setting_override_actions
+
+ .announcements-list__item__action-bar
+ .announcements-list__item__meta
+ -# TODO: history
+ - if emergency_rule.duration.present?
+ = t('admin.emergency_rules.expires_after_duration', duration: distance_of_time_in_words(Time.at(0).utc, Time.at(emergency_rule.duration).utc))
+ - else
+ = t('admin.emergency_rules.no_expiration')
diff --git a/app/views/admin/emergency_rules/_rate_limit_action.html.haml b/app/views/admin/emergency_rules/_rate_limit_action.html.haml
new file mode 100644
index 00000000000000..f0b47ba3a0fd78
--- /dev/null
+++ b/app/views/admin/emergency_rules/_rate_limit_action.html.haml
@@ -0,0 +1,8 @@
+%li.permissions-list__item
+ .permissions-list__item__icon
+ = fa_icon('gavel')
+ .permissions-list__item__text
+ .permissions-list__item__text__title
+ = rate_limit_action.new_users_only? ? t('admin.emergency_rules.rate_limit_actions.new_users_title') : t('admin.emergency_rules.rate_limit_actions.title')
+ .permissions-list__item__text__type
+ = rate_limit_action.new_users_only? ? t('admin.emergency_rules.rate_limit_actions.new_users_description') : t('admin.emergency_rules.rate_limit_actions.description')
diff --git a/app/views/admin/emergency_rules/_setting_override_action.html.haml b/app/views/admin/emergency_rules/_setting_override_action.html.haml
new file mode 100644
index 00000000000000..57bf105e4179cf
--- /dev/null
+++ b/app/views/admin/emergency_rules/_setting_override_action.html.haml
@@ -0,0 +1,8 @@
+%li.permissions-list__item
+ .permissions-list__item__icon
+ = fa_icon('gavel')
+ .permissions-list__item__text
+ .permissions-list__item__text__title
+ = t("admin.emergency_rules.setting_override_actions.#{setting_override_action.setting}.title_#{setting_override_action.value}")
+ .permissions-list__item__text__type
+ = t('admin.emergency_rules.setting_override_actions.description')
diff --git a/app/views/admin/emergency_rules/_trigger.html.haml b/app/views/admin/emergency_rules/_trigger.html.haml
new file mode 100644
index 00000000000000..8c2665ee231844
--- /dev/null
+++ b/app/views/admin/emergency_rules/_trigger.html.haml
@@ -0,0 +1,9 @@
+%li.permissions-list__item
+ .permissions-list__item__icon
+ = fa_icon('cogs')
+ .permissions-list__item__text
+ .permissions-list__item__text__title
+ = t("admin.emergency_rules.triggers.title.#{trigger.event}")
+ .permissions-list__item__text__type
+ - period = t("admin.emergency_rules.triggers.periods.#{trigger.duration_bucket}")
+ = t('admin.emergency_rules.triggers.description', threshold: trigger.threshold, period: period)
diff --git a/app/views/admin/emergency_rules/index.html.haml b/app/views/admin/emergency_rules/index.html.haml
new file mode 100644
index 00000000000000..a74da1f27d23ea
--- /dev/null
+++ b/app/views/admin/emergency_rules/index.html.haml
@@ -0,0 +1,13 @@
+- content_for :page_title do
+ = t('admin.emergency_rules.index.title')
+
+- content_for :heading_actions do
+ = link_to t('admin.emergency_rules.index.add'), new_admin_emergency_rule_path, class: 'button' if can?(:create, [:emergency, :rule])
+
+ %p.lead= t('admin.emergency_rules.index.preamble')
+
+- if @rules.empty?
+ .muted-hint.center-text= t 'admin.emergency_rules.index.empty'
+- else
+ .applications-list
+ = render partial: 'emergency_rule', collection: @rules
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index e2a9868aa54e1b..234f4993877a2f 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -1,6 +1,8 @@
- content_for :page_title do
= t('admin.reports.title')
+= emergency_rule_notice
+
.filters
.filter-subset
%strong= t('admin.reports.status')
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 8463d4297d5853..7c4203495e9987 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -60,6 +60,7 @@ ignore_unused:
- 'admin.action_logs.actions.*'
- 'admin.reports.summary.action_preambles.*'
- 'admin.reports.summary.actions.*'
+ - 'admin.emergency_rules.setting_override_actions.*.title_*'
- 'admin_mailer.new_appeal.actions.*'
- 'statuses.attached.*'
- 'move_handler.carry_{mutes,blocks}_over_text'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 50f814a81d56a6..7d41e3bc1e19d2 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -444,6 +444,40 @@ en:
resolved_dns_records_hint_html: The domain name resolves to the following MX domains, which are ultimately responsible for accepting e-mail. Blocking an MX domain will block sign-ups from any e-mail address which uses the same MX domain, even if the visible domain name is different. Be careful not to block major e-mail providers.
resolved_through_html: Resolved through %{domain}
title: Blocked e-mail domains
+ emergency_rules:
+ active: Active
+ deactivated_msg: Successfully deactivated emergency rule
+ expires_after_duration: Deactivates %{duration} after most recent trigger
+ index:
+ add: Add new rule
+ deactivate: Deactivate
+ empty: There are currently no emergency rules set up on this server.
+ preamble: Emergency rules are configurable automated rules meant to give moderators some time to breath when the server is facing an unusual surge of activity.
+ title: Emergency rules
+ no_expiration: Needs to be manually disabled once triggered
+ notice_html: An emergency rule is currently enabled.
+ rate_limit_actions:
+ description: All local accounts get heavily rate-limited when this rule is active
+ new_users_description: Accounts confirmed around or after this rule got triggered get heavily rate-limited
+ new_users_title: Heavily rate-limit new users
+ title: Heavily rate-limit users
+ setting_override_actions:
+ captcha_enabled:
+ title_true: Enable CAPTCHA
+ description: Overrides the server settings when this rule is active
+ registrations_mode:
+ title_approved: Switch registrations to approval-based
+ title_none: Close registrations
+ triggers:
+ description: Triggers when reaching %{threshold} per %{period}
+ periods:
+ day: day
+ hour: hour
+ minute: minute
+ title:
+ local:confirmations: New account confirmations
+ local:posts: New posts
+ local:signups: New registrations
export_domain_allows:
new:
title: Import domain allows
diff --git a/config/navigation.rb b/config/navigation.rb
index 1e7acf3b9c61d8..41905c253ed3d5 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -49,6 +49,7 @@
s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), 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 :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) }
+ s.item :emergency_rules, safe_join([fa_icon('shield fw'), t('admin.emergency_rules.index.title')]), admin_emergency_rules_path, highlights_on: %r{/admin/emergency_rules}, if: -> { current_user.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks) }
end
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), nil, if: -> { current_user.can?(:view_dashboard, :manage_settings, :manage_rules, :manage_announcements, :manage_custom_emojis, :manage_webhooks, :manage_federation) && !self_destruct } do |s|
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 207cb0580dcaaf..40fbaa38a4fec2 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -203,4 +203,10 @@
end
resources :software_updates, only: [:index]
+
+ resources :emergency_rules do
+ member do
+ post :deactivate
+ end
+ end
end
diff --git a/spec/requests/admin/emergency_rules_controller_spec.rb b/spec/requests/admin/emergency_rules_controller_spec.rb
new file mode 100644
index 00000000000000..134dc60f562dbb
--- /dev/null
+++ b/spec/requests/admin/emergency_rules_controller_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::EmergencyRulesController do
+ before do
+ Fabricate('Emergency::Trigger')
+ Fabricate('Emergency::SettingOverrideAction')
+ Fabricate('Emergency::RateLimitAction')
+ end
+
+ describe 'the index route' do
+ context 'when not logged in' do
+ it 'returns HTTP forbidden' do
+ get '/admin/emergency_rules'
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'when logged in as a regular user' do
+ before do
+ sign_in Fabricate(:user), scope: :user
+ end
+
+ it 'returns HTTP forbidden' do
+ get '/admin/emergency_rules'
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'when logged in as a moderator' do
+ let(:user) { Fabricate(:user, role: UserRole.find_by!(name: 'Moderator')) }
+
+ before do
+ sign_in user, scope: :user
+ end
+
+ it 'returns HTTP success' do
+ get '/admin/emergency_rules'
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+
+ describe 'the deactivation route' do
+ let(:rule) { Fabricate('Emergency::Rule') }
+
+ before do
+ rule.trigger!(1.day.ago)
+ end
+
+ context 'when not logged in' do
+ it 'returns HTTP forbidden' do
+ post "/admin/emergency_rules/#{rule.id}/deactivate"
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'when logged in as a regular user' do
+ before do
+ sign_in Fabricate(:user), scope: :user
+ end
+
+ it 'returns HTTP forbidden' do
+ post "/admin/emergency_rules/#{rule.id}/deactivate"
+
+ expect(response).to have_http_status(403)
+ end
+ end
+
+ context 'when logged in as a moderator' do
+ let(:user) { Fabricate(:user, role: UserRole.find_by!(name: 'Moderator')) }
+
+ before do
+ sign_in user, scope: :user
+ end
+
+ it 'redirects' do
+ post "/admin/emergency_rules/#{rule.id}/deactivate"
+
+ expect(response).to redirect_to '/admin/emergency_rules'
+ end
+
+ it 'deactivates the rule' do
+ expect { post "/admin/emergency_rules/#{rule.id}/deactivate" }.to change { rule.reload.active? }.from(true).to(false)
+ end
+ end
+ end
+end