From f44b28a24d774d7ab347edca106d71348db5630e Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 17 May 2023 21:17:02 +0200 Subject: [PATCH] Add basic admin UI for emergency rules --- .../admin/emergency_rules_controller.rb | 27 ++++++ app/helpers/admin/dashboard_helper.rb | 8 ++ app/policies/emergency/rule_policy.rb | 15 +++ app/views/admin/accounts/index.html.haml | 2 + app/views/admin/dashboard/index.html.haml | 2 + .../emergency_rules/_emergency_rule.html.haml | 24 +++++ .../_rate_limit_action.html.haml | 8 ++ .../_setting_override_action.html.haml | 8 ++ .../admin/emergency_rules/_trigger.html.haml | 9 ++ .../admin/emergency_rules/index.html.haml | 13 +++ app/views/admin/reports/index.html.haml | 2 + config/i18n-tasks.yml | 1 + config/locales/en.yml | 34 +++++++ config/navigation.rb | 1 + config/routes/admin.rb | 6 ++ .../admin/emergency_rules_controller_spec.rb | 93 +++++++++++++++++++ 16 files changed, 253 insertions(+) create mode 100644 app/controllers/admin/emergency_rules_controller.rb create mode 100644 app/policies/emergency/rule_policy.rb create mode 100644 app/views/admin/emergency_rules/_emergency_rule.html.haml create mode 100644 app/views/admin/emergency_rules/_rate_limit_action.html.haml create mode 100644 app/views/admin/emergency_rules/_setting_override_action.html.haml create mode 100644 app/views/admin/emergency_rules/_trigger.html.haml create mode 100644 app/views/admin/emergency_rules/index.html.haml create mode 100644 spec/requests/admin/emergency_rules_controller_spec.rb 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