diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index e7f56e243..e0ae71b9f 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -28,7 +28,7 @@ module Admin @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest - @warnings = @account.strikes.custom.latest + @warnings = @account.strikes.includes(:target_account, :account, :appeal).latest @domain_block = DomainBlock.rule_for(@account.domain) end @@ -146,7 +146,7 @@ module Admin end def filter_params - params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS) + params.slice(:page, *AccountFilter::KEYS).permit(:page, *AccountFilter::KEYS) end def form_account_batch_params diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index f0a935411..e376baab2 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -8,6 +8,7 @@ module Admin @pending_users_count = User.pending.count @pending_reports_count = Report.unresolved.count @pending_tags_count = Tag.pending_review.count + @pending_appeals_count = Appeal.pending.count end private diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb new file mode 100644 index 000000000..32e5e2f6f --- /dev/null +++ b/app/controllers/admin/disputes/appeals_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Admin::Disputes::AppealsController < Admin::BaseController + before_action :set_appeal, except: :index + + def index + authorize :appeal, :index? + + @appeals = filtered_appeals.page(params[:page]) + end + + def approve + authorize @appeal, :approve? + log_action :approve, @appeal + ApproveAppealService.new.call(@appeal, current_account) + redirect_to disputes_strike_path(@appeal.strike) + end + + def reject + authorize @appeal, :approve? + log_action :reject, @appeal + @appeal.reject!(current_account) + UserMailer.appeal_rejected(@appeal.account.user, @appeal) + redirect_to disputes_strike_path(@appeal.strike) + end + + private + + def filtered_appeals + Admin::AppealFilter.new(filter_params.with_defaults(status: 'pending')).results.includes(strike: :account) + end + + def filter_params + params.slice(:page, *Admin::AppealFilter::KEYS).permit(:page, *Admin::AppealFilter::KEYS) + end + + def set_appeal + @appeal = Appeal.find(params[:id]) + end +end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index f37e906fd..3b025838b 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -9,6 +9,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] + before_action :set_strikes, only: [:edit, :update] before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] @@ -111,8 +112,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def set_invite - invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil - @invite = invite&.valid_for_use? ? invite : nil + @invite = begin + invite = Invite.find_by(code: invite_code) if invite_code.present? + invite if invite&.valid_for_use? + end end def determine_layout @@ -123,6 +126,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController @sessions = current_user.session_activations end + def set_strikes + @strikes = current_account.strikes.active.latest + end + def require_not_suspended! forbidden if current_account.suspended? end diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb new file mode 100644 index 000000000..15367c879 --- /dev/null +++ b/app/controllers/disputes/appeals_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Disputes::AppealsController < Disputes::BaseController + before_action :set_strike + + def create + authorize @strike, :appeal? + + @appeal = AppealService.new.call(@strike, appeal_params[:text]) + + redirect_to disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg') + rescue ActiveRecord::RecordInvalid + render template: 'disputes/strikes/show' + end + + private + + def set_strike + @strike = current_account.strikes.find(params[:strike_id]) + end + + def appeal_params + params.require(:appeal).permit(:text) + end +end diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb new file mode 100644 index 000000000..865146b5c --- /dev/null +++ b/app/controllers/disputes/base_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Disputes::BaseController < ApplicationController + include Authorization + + layout 'admin' + + skip_before_action :require_functional! + + before_action :set_body_classes + before_action :authenticate_user! + + private + + def set_body_classes + @body_classes = 'admin' + end +end diff --git a/app/controllers/disputes/strikes_controller.rb b/app/controllers/disputes/strikes_controller.rb new file mode 100644 index 000000000..d41c5c727 --- /dev/null +++ b/app/controllers/disputes/strikes_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Disputes::StrikesController < Disputes::BaseController + before_action :set_strike + + def show + authorize @strike, :show? + + @appeal = @strike.appeal || @strike.build_appeal + end + + private + + def set_strike + @strike = AccountWarning.find(params[:id]) + end +end diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb index 40b2a5289..2f08538ca 100644 --- a/app/helpers/admin/account_moderation_notes_helper.rb +++ b/app/helpers/admin/account_moderation_notes_helper.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true module Admin::AccountModerationNotesHelper - def admin_account_link_to(account) + def admin_account_link_to(account, path: nil) return if account.nil? - link_to admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do + link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do safe_join([ image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username'), diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index f3aa4be4f..47eeeaac3 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -33,6 +33,8 @@ module Admin::ActionLogsHelper "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})" when 'Instance' record.domain + when 'Appeal' + link_to record.account.acct, disputes_strike_path(record.strike) end end diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb new file mode 100644 index 000000000..d16e3dd12 --- /dev/null +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Admin::Trends::StatusesHelper + def one_line_preview(status) + text = begin + if status.local? + status.text.split("\n").first + else + Nokogiri::HTML(status.text).css('html > body > *').first&.text + end + end + + return '' if text.blank? + + html = Formatter.instance.send(:encode, text) + html = Formatter.instance.send(:encode_custom_emojis, html, status.emojis, prefers_autoplay?) + + html.html_safe # rubocop:disable Rails/OutputSafety + end +end diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 546de1640..f5741bd50 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -578,12 +578,16 @@ body, } .log-entry { + display: block; line-height: 20px; padding: 15px; padding-left: 15px * 2 + 40px; background: $ui-base-color; border-bottom: 1px solid darken($ui-base-color, 8%); position: relative; + text-decoration: none; + color: $darker-text-color; + font-size: 14px; &:first-child { border-top-left-radius: 4px; @@ -596,15 +600,12 @@ body, border-bottom: 0; } - &:hover { + &:hover, + &:focus, + &:active { background: lighten($ui-base-color, 4%); } - &__header { - color: $darker-text-color; - font-size: 14px; - } - &__avatar { position: absolute; left: 15px; @@ -640,6 +641,18 @@ body, text-decoration: underline; } } + + &--inactive { + .log-entry__title { + text-decoration: line-through; + } + + a, + .username, + .target { + color: $darker-text-color; + } + } } a.name-tag, @@ -1175,6 +1188,17 @@ a.sparkline { font-weight: 600; padding: 4px 0; } + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } } &--horizontal { @@ -1451,3 +1475,56 @@ a.sparkline { } } } + +.strike-card { + padding: 15px; + border-radius: 4px; + background: $ui-base-color; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + color: $primary-text-color; + + p { + margin-bottom: 20px; + unicode-bidi: plaintext; + + &:last-child { + margin-bottom: 0; + } + } + + &__statuses-list { + border-radius: 4px; + border: 1px solid darken($ui-base-color, 8%); + font-size: 13px; + line-height: 18px; + overflow: hidden; + + &__item { + padding: 16px; + background: lighten($ui-base-color, 2%); + border-bottom: 1px solid darken($ui-base-color, 8%); + + &:last-child { + border-bottom: 0; + } + + &__meta { + color: $darker-text-color; + } + + a { + color: inherit; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + } + } +} diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index b23bd1296..a9d00c000 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -15,6 +15,16 @@ class AdminMailer < ApplicationMailer end end + def new_appeal(recipient, appeal) + @appeal = appeal + @me = recipient + @instance = Rails.configuration.x.local_domain + + locale_for_account(@me) do + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username) + end + end + def new_pending_account(recipient, user) @account = user.account @me = recipient diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 5221a4892..583c948b0 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -173,6 +173,26 @@ class UserMailer < Devise::Mailer end end + def appeal_approved(user, appeal) + @resource = user + @instance = Rails.configuration.x.local_domain + @appeal = appeal + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at)) + end + end + + def appeal_rejected(user, appeal) + @resource = user + @instance = Rails.configuration.x.local_domain + @appeal = appeal + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at)) + end + end + def sign_in_token(user, remote_ip, user_agent, timestamp) @resource = user @instance = Rails.configuration.x.local_domain diff --git a/app/models/account.rb b/app/models/account.rb index 771cc0b1b..2ad45feda 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -270,6 +270,10 @@ class Account < ApplicationRecord true end + def previous_strikes_count + strikes.where(overruled_at: nil).count + end + def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index 86b7f5f41..9da1522dd 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -24,6 +24,8 @@ class AccountFilter scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil) params.each do |key, value| + next if key.to_s == 'page' + scope.merge!(scope_for(key, value.to_s.strip)) if value.present? end diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb index fc0d988fd..05d01942d 100644 --- a/app/models/account_warning.rb +++ b/app/models/account_warning.rb @@ -12,6 +12,7 @@ # updated_at :datetime not null # report_id :bigint(8) # status_ids :string is an Array +# overruled_at :datetime # class AccountWarning < ApplicationRecord @@ -28,12 +29,17 @@ class AccountWarning < ApplicationRecord belongs_to :target_account, class_name: 'Account', inverse_of: :strikes belongs_to :report, optional: true - has_one :appeal, dependent: :destroy + has_one :appeal, dependent: :destroy, inverse_of: :strike scope :latest, -> { order(id: :desc) } scope :custom, -> { where.not(text: '') } + scope :active, -> { where(overruled_at: nil).or(where('account_warnings.overruled_at >= ?', 30.days.ago)) } def statuses Status.with_discarded.where(id: status_ids || []) end + + def overruled? + overruled_at.present? + end end diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb index 12136223b..0f2f712a2 100644 --- a/app/models/admin/action_log_filter.rb +++ b/app/models/admin/action_log_filter.rb @@ -8,6 +8,8 @@ class Admin::ActionLogFilter ).freeze ACTION_TYPE_MAP = { + approve_appeal: { target_type: 'Appeal', action: 'approve' }.freeze, + reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze, assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze, change_email_user: { target_type: 'User', action: 'change_email' }.freeze, confirm_user: { target_type: 'User', action: 'confirm' }.freeze, diff --git a/app/models/admin/appeal_filter.rb b/app/models/admin/appeal_filter.rb new file mode 100644 index 000000000..b163d2e56 --- /dev/null +++ b/app/models/admin/appeal_filter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Admin::AppealFilter + KEYS = %i( + status + ).freeze + + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = Appeal.order(id: :desc) + + params.each do |key, value| + next if %w(page).include?(key.to_s) + + scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when 'status' + status_scope(value) + else + raise "Unknown filter: #{key}" + end + end + + def status_scope(value) + case value + when 'approved' + Appeal.approved + when 'rejected' + Appeal.rejected + when 'pending' + Appeal.pending + else + raise "Unknown status: #{value}" + end + end +end diff --git a/app/models/appeal.rb b/app/models/appeal.rb new file mode 100644 index 000000000..46f35ae37 --- /dev/null +++ b/app/models/appeal.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: appeals +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# account_warning_id :bigint(8) not null +# text :text default(""), not null +# approved_at :datetime +# approved_by_account_id :bigint(8) +# rejected_at :datetime +# rejected_by_account_id :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# +class Appeal < ApplicationRecord + belongs_to :account + belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id' + belongs_to :approved_by_account, class_name: 'Account', optional: true + belongs_to :rejected_by_account, class_name: 'Account', optional: true + + validates :text, presence: true, length: { maximum: 2_000 } + validates :account_warning_id, uniqueness: true + + validate :validate_time_frame, on: :create + + scope :approved, -> { where.not(approved_at: nil) } + scope :rejected, -> { where.not(rejected_at: nil) } + scope :pending, -> { where(approved_at: nil, rejected_at: nil) } + + def pending? + !approved? && !rejected? + end + + def approved? + approved_at.present? + end + + def rejected? + rejected_at.present? + end + + def approve!(current_account) + update!(approved_at: Time.now.utc, approved_by_account: current_account) + end + + def reject!(current_account) + update!(rejected_at: Time.now.utc, rejected_by_account: current_account) + end + + private + + def validate_time_frame + errors.add(:base, I18n.t('strikes.errors.too_late')) if Time.now.utc > (strike.created_at + 20.days) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index fd1d7049a..517254a91 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -265,6 +265,10 @@ class User < ApplicationRecord settings.notification_emails['pending_account'] end + def allows_appeal_emails? + settings.notification_emails['appeal'] + end + def allows_trending_tag_emails? settings.notification_emails['trending_tag'] end diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb new file mode 100644 index 000000000..6b92da475 --- /dev/null +++ b/app/policies/account_warning_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AccountWarningPolicy < ApplicationPolicy + def show? + target? || staff? + end + + def appeal? + target? + end + + private + + def target? + record.target_account_id == current_account&.id + end +end diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb new file mode 100644 index 000000000..a25187172 --- /dev/null +++ b/app/policies/appeal_policy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AppealPolicy < ApplicationPolicy + def index? + staff? + end + + def approve? + record.pending? && staff? + end + + alias reject? approve? +end diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb new file mode 100644 index 000000000..1397c50f5 --- /dev/null +++ b/app/services/appeal_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AppealService < BaseService + def call(strike, text) + @strike = strike + @text = text + + create_appeal! + notify_staff! + + @appeal + end + + private + + def create_appeal! + @appeal = @strike.create_appeal!( + text: @text, + account: @strike.target_account + ) + end + + def notify_staff! + User.staff.includes(:account).each do |u| + AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails? + end + end +end diff --git a/app/services/approve_appeal_service.rb b/app/services/approve_appeal_service.rb new file mode 100644 index 000000000..f76bf8943 --- /dev/null +++ b/app/services/approve_appeal_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class ApproveAppealService < BaseService + def call(appeal, current_account) + @appeal = appeal + @strike = appeal.strike + @current_account = current_account + + ApplicationRecord.transaction do + undo_strike_action! + mark_strike_as_appealed! + end + + queue_workers! + notify_target_account! + end + + private + + def target_account + @strike.target_account + end + + def undo_strike_action! + case @strike.action + when 'disable' + undo_disable! + when 'delete_statuses' + undo_delete_statuses! + when 'sensitive' + undo_sensitive! + when 'silence' + undo_silence! + when 'suspend' + undo_suspend! + end + end + + def mark_strike_as_appealed! + @appeal.approve!(@current_account) + @strike.touch(:overruled_at) + end + + def undo_disable! + target_account.user.enable! + end + + def undo_delete_statuses! + # Cannot be undone + end + + def undo_sensitive! + target_account.unsensitize! + end + + def undo_silence! + target_account.unsilence! + end + + def undo_suspend! + target_account.unsuspend! + end + + def queue_workers! + case @strike.action + when 'suspend' + Admin::UnsuspensionWorker.perform_async(target_account.id) + end + end + + def notify_target_account! + UserMailer.appeal_approved(target_account.user, @appeal).deliver_later + end +end diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml deleted file mode 100644 index 432fb79a6..000000000 --- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.speech-bubble - .speech-bubble__bubble - = simple_format(h(account_moderation_note.content)) - .speech-bubble__owner - = admin_account_link_to account_moderation_note.account - %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at - = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note) diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml index 8c9c9679c..ef23c3b77 100644 --- a/app/views/admin/account_warnings/_account_warning.html.haml +++ b/app/views/admin/account_warnings/_account_warning.html.haml @@ -1,6 +1,24 @@ -.speech-bubble.warning - .speech-bubble__bubble - = Formatter.instance.linkify(account_warning.text) - .speech-bubble__owner - = admin_account_link_to account_warning.account - %time.formatted{ datetime: account_warning.created_at.iso8601 }= l account_warning.created_at += link_to disputes_strike_path(account_warning), class: ['log-entry', account_warning.overruled? && 'log-entry--inactive'] do + .log-entry__header + .log-entry__avatar + = image_tag account_warning.target_account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar' + .log-entry__content + .log-entry__title + = t(account_warning.action, scope: 'admin.strikes.actions', name: content_tag(:span, account_warning.account.username, class: 'username'), target: content_tag(:span, account_warning.target_account.acct, class: 'target')).html_safe + .log-entry__timestamp + %time.formatted{ datetime: account_warning.created_at.iso8601 } + = l(account_warning.created_at) + + - if account_warning.report_id.present? + · + = t('admin.reports.title', id: account_warning.report_id) + + - if account_warning.overruled? + · + %span.positive-hint= t('admin.strikes.appeal_approved') + - elsif account_warning.appeal&.pending? + · + %span.warning-hint= t('admin.strikes.appeal_pending') + - elsif account_warning.appeal&.rejected? + · + %span.negative-hint= t('admin.strikes.appeal_rejected') diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index f3853d629..9a1f07a06 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -246,18 +246,29 @@ %hr.spacer/ - unless @warnings.empty? - = render @warnings + + %h3= t 'admin.accounts.previous_strikes' + + %p= t('admin.accounts.previous_strikes_description_html', count: @account.previous_strikes_count) + + .account-strikes + = render @warnings %hr.spacer/ - = render @moderation_notes + %h3= t 'admin.reports.notes.title' + + %p= t 'admin.reports.notes_description_html' + + .report-notes + = render partial: 'admin/report_notes/report_note', collection: @moderation_notes = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f| - = render 'shared/error_messages', object: @account_moderation_note - - = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6 = f.hidden_field :target_account_id + .field-group + = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6 + .actions = f.button :button, t('admin.account_moderation_notes.create'), type: :submit diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 4b581f5ea..59b75e0e1 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -46,6 +46,9 @@ %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count) = fa_icon 'chevron-right fw' + = link_to admin_disputes_appeals_path(status: 'pending'), class: 'dashboard__quick-access' do + %span= t('admin.dashboard.pending_appeals_html', count: @pending_appeals_count) + = fa_icon 'chevron-right fw' .dashboard__item = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources') diff --git a/app/views/admin/disputes/appeals/_appeal.html.haml b/app/views/admin/disputes/appeals/_appeal.html.haml new file mode 100644 index 000000000..02b8777e1 --- /dev/null +++ b/app/views/admin/disputes/appeals/_appeal.html.haml @@ -0,0 +1,21 @@ += link_to disputes_strike_path(appeal.strike), class: ['log-entry', appeal.approved? && 'log-entry--inactive'] do + .log-entry__header + .log-entry__avatar + = image_tag appeal.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar' + .log-entry__content + .log-entry__title + = t(appeal.strike.action, scope: 'admin.strikes.actions', name: content_tag(:span, appeal.strike.account.username, class: 'username'), target: content_tag(:span, appeal.account.acct, class: 'target')).html_safe + .log-entry__timestamp + %time.formatted{ datetime: appeal.strike.created_at.iso8601 } + = l(appeal.strike.created_at) + + - if appeal.strike.report_id.present? + · + = t('admin.reports.title', id: appeal.strike.report_id) + · + - if appeal.approved? + %span.positive-hint= t('admin.strikes.appeal_approved') + - elsif appeal.rejected? + %span.negative-hint= t('admin.strikes.appeal_rejected') + - else + %span.warning-hint= t('admin.strikes.appeal_pending') diff --git a/app/views/admin/disputes/appeals/index.html.haml b/app/views/admin/disputes/appeals/index.html.haml new file mode 100644 index 000000000..dd6a6f403 --- /dev/null +++ b/app/views/admin/disputes/appeals/index.html.haml @@ -0,0 +1,22 @@ +- content_for :page_title do + = t('admin.disputes.appeals.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + +.filters + .filter-subset + %strong= t('admin.tags.review') + %ul + %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Appeal.pending.count})"], ' '), status: 'pending' + %li= filter_link_to t('admin.trends.approved'), status: 'approved' + %li= filter_link_to t('admin.trends.rejected'), status: 'rejected' + +- if @appeals.empty? + %div.muted-hint.center-text + = t 'admin.disputes.appeals.empty' +- else + .announcements-list + = render partial: 'appeal', collection: @appeals + += paginate @appeals diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml index 428b6cf59..f9d57c2ae 100644 --- a/app/views/admin/report_notes/_report_note.html.haml +++ b/app/views/admin/report_notes/_report_note.html.haml @@ -3,7 +3,7 @@ .report-notes__item__header %span.username - = link_to display_name(report_note.account), admin_account_path(report_note.account_id) + = link_to report_note.account.username, admin_account_path(report_note.account_id) %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) } - if report_note.created_at.today? = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time)) diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index 02c46e384..e53c180e5 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -57,7 +57,7 @@ .report-header__details__item__header %strong= t('admin.accounts.strikes') .report-header__details__item__content - = @report.target_account.strikes.count + = @report.target_account.previous_strikes_count .report-header__details .report-header__details__item diff --git a/app/views/admin_mailer/new_appeal.text.erb b/app/views/admin_mailer/new_appeal.text.erb new file mode 100644 index 000000000..db4529eb7 --- /dev/null +++ b/app/views/admin_mailer/new_appeal.text.erb @@ -0,0 +1,9 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_appeal.body', target: @appeal.account.username, action_taken_by: @appeal.strike.account.username, date: l(@appeal.strike.created_at), type: t(@appeal.strike.action, scope: 'admin_mailer.new_appeal.actions')) %> + +> <%= raw word_wrap(@appeal.text, break_sequence: "\n> ") %> + +<%= raw t('admin_mailer.new_appeal.next_steps') %> + +<%= raw t('application_mailer.view')%> <%= disputes_strike_url(@appeal.strike) %> diff --git a/app/views/auth/registrations/_account_warning.html.haml b/app/views/auth/registrations/_account_warning.html.haml new file mode 100644 index 000000000..40e7e1296 --- /dev/null +++ b/app/views/auth/registrations/_account_warning.html.haml @@ -0,0 +1,20 @@ += link_to disputes_strike_path(account_warning), class: 'log-entry' do + .log-entry__header + .log-entry__avatar + .indicator-icon{ class: account_warning.overruled? ? 'success' : 'failure' } + = fa_icon 'warning' + .log-entry__content + .log-entry__title + = t('disputes.strikes.title', action: t(account_warning.action, scope: 'disputes.strikes.title_actions'), date: l(account_warning.created_at.to_date)) + .log-entry__timestamp + %time.formatted{ datetime: account_warning.created_at.iso8601 }= l(account_warning.created_at) + + - if account_warning.overruled? + · + %span.positive-hint= t('disputes.strikes.your_appeal_approved') + - elsif account_warning.appeal&.pending? + · + %span.warning-hint= t('disputes.strikes.your_appeal_pending') + - elsif account_warning.appeal&.rejected? + · + %span.negative-hint= t('disputes.strikes.your_appeal_rejected') diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml index 47112dae0..3546510b2 100644 --- a/app/views/auth/registrations/_status.html.haml +++ b/app/views/auth/registrations/_status.html.haml @@ -1,22 +1,17 @@ +- if !@user.confirmed? + .flash-message.warning + = t('auth.status.confirming') + = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path +- elsif !@user.approved? + .flash-message.warning + = t('auth.status.pending') +- elsif @user.account.moved_to_account_id.present? + .flash-message.warning + = t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct) + = link_to t('migrations.cancel'), settings_migration_path + %h3= t('auth.status.account_status') -.simple_form - %p.hint - - if @user.account.suspended? - %span.negative-hint= t('user_mailer.warning.explanation.suspend') - - elsif @user.disabled? - %span.negative-hint= t('user_mailer.warning.explanation.disable') - - elsif @user.account.silenced? - %span.warning-hint= t('user_mailer.warning.explanation.silence') - - elsif !@user.confirmed? - %span.warning-hint= t('auth.status.confirming') - = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path - - elsif !@user.approved? - %span.warning-hint= t('auth.status.pending') - - elsif @user.account.moved_to_account_id.present? - %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct) - = link_to t('migrations.cancel'), settings_migration_path - - else - %span.positive-hint= t('auth.status.functional') += render partial: 'account_warning', collection: @strikes %hr.spacer/ diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml new file mode 100644 index 000000000..3dcb19016 --- /dev/null +++ b/app/views/disputes/strikes/show.html.haml @@ -0,0 +1,127 @@ +- content_for :page_title do + = t('disputes.strikes.title', action: t(@strike.action, scope: 'disputes.strikes.title_actions'), date: l(@strike.created_at.to_date)) + +- content_for :heading_actions do + - if @appeal.persisted? + = link_to t('admin.accounts.approve'), approve_admin_disputes_appeal_path(@appeal), method: :post, class: 'button' if can?(:approve, @appeal) + = link_to t('admin.accounts.reject'), reject_admin_disputes_appeal_path(@appeal), method: :post, class: 'button button--destructive' if can?(:reject, @appeal) + +- if @strike.overruled? + %p.hint + %span.positive-hint + = fa_icon 'check' + = ' ' + = t 'disputes.strikes.appeal_approved' +- elsif @appeal.persisted? && @appeal.rejected? + %p.hint + %span.negative-hint + = fa_icon 'times' + = ' ' + = t 'disputes.strikes.appeal_rejected' + +.report-header + .report-header__card + .strike-card + - unless @strike.none_action? + %p= t "user_mailer.warning.explanation.#{@strike.action}" + + - unless @strike.text.blank? + = Formatter.instance.linkify(@strike.text) + + - if @strike.report && !@strike.report.other? + %p + %strong= t('user_mailer.warning.reason') + = t("user_mailer.warning.categories.#{@strike.report.category}") + + - if @strike.report.violation? && @strike.report.rule_ids.present? + %ul.rules-list + - @strike.report.rules.each do |rule| + %li= rule.text + + - if @strike.status_ids.present? && !@strike.status_ids.empty? + %p + %strong= t('user_mailer.warning.statuses') + + .strike-card__statuses-list + - status_map = @strike.statuses.includes(:application, :media_attachments).index_by(&:id) + + - @strike.status_ids.each do |status_id| + .strike-card__statuses-list__item + - if (status = status_map[status_id.to_i]) + .one-liner + = link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do + = one_line_preview(status) + + - status.media_attachments.each do |media_attachment| + %abbr{ title: media_attachment.description } + = fa_icon 'link' + = media_attachment.file_file_name + .strike-card__statuses-list__item__meta + %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) + · + = status.application.name + - else + .one-liner= t('disputes.strikes.status', id: status_id) + .strike-card__statuses-list__item__meta + = t('disputes.strikes.status_removed') + + .report-header__details + .report-header__details__item + .report-header__details__item__header + %strong= t('disputes.strikes.created_at') + .report-header__details__item__content + %time.formatted{ datetime: @strike.created_at.iso8601, title: l(@strike.created_at) }= l(@strike.created_at) + .report-header__details__item + .report-header__details__item__header + %strong= t('disputes.strikes.recipient') + .report-header__details__item__content + = admin_account_link_to @strike.target_account, path: can?(:show, @strike.target_account) ? admin_account_path(@strike.target_account_id) : ActivityPub::TagManager.instance.url_for(@strike.target_account) + .report-header__details__item + .report-header__details__item__header + %strong= t('disputes.strikes.action_taken') + .report-header__details__item__content + - if @strike.overruled? + %del= t(@strike.action, scope: 'user_mailer.warning.title') + - else + = t(@strike.action, scope: 'user_mailer.warning.title') + - if @strike.report && can?(:show, @strike.report) + .report-header__details__item + .report-header__details__item__header + %strong= t('disputes.strikes.associated_report') + .report-header__details__item__content + = link_to t('admin.reports.report', id: @strike.report.id), admin_report_path(@strike.report) + - if @appeal.persisted? + .report-header__details__item + .report-header__details__item__header + %strong= t('disputes.strikes.appeal_submitted_at') + .report-header__details__item__content + %time.formatted{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }= l(@appeal.created_at) +%hr.spacer/ + +- if @appeal.persisted? + %h3= t('disputes.strikes.appeal') + + .report-notes + .report-notes__item + = image_tag @appeal.account.avatar.url, class: 'report-notes__item__avatar' + + .report-notes__item__header + %span.username + = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account) + %time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) } + - if @appeal.created_at.today? + = t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time)) + - else + = l @appeal.created_at.to_date + + .report-notes__item__content + = simple_format(h(@appeal.text)) +- elsif can?(:appeal, @strike) + %h3= t('disputes.strikes.appeals.submit') + + = simple_form_for(@appeal, url: disputes_strike_appeal_path(@strike)) do |f| + .fields-group + = f.input :text, wrapper: :with_label, input_html: { maxlength: 500 } + + .actions + = f.button :button, t('disputes.strikes.appeals.submit'), type: :submit diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index d7cc1ed5d..223e5d740 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -21,6 +21,7 @@ - if current_user.staff? = ff.input :report, as: :boolean, wrapper: :with_label + = ff.input :appeal, as: :boolean, wrapper: :with_label = ff.input :pending_account, as: :boolean, wrapper: :with_label = ff.input :trending_tag, as: :boolean, wrapper: :with_label diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index cd5ed52af..1922f53ce 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -49,7 +49,7 @@ %span.detailed-status__visibility-icon = visibility_icon status · - - if status.application && @account.user&.setting_show_application + - if status.application && status.account.user&.setting_show_application - if status.application.website.blank? %strong.detailed-status__application= status.application.name - else diff --git a/app/views/user_mailer/appeal_approved.html.haml b/app/views/user_mailer/appeal_approved.html.haml new file mode 100644 index 000000000..962cab2e2 --- /dev/null +++ b/app/views/user_mailer/appeal_approved.html.haml @@ -0,0 +1,59 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_done.png'), alt: '' + + %h1= t 'user_mailer.appeal_approved.title' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to root_url do + %span= t 'user_mailer.appeal_approved.action' diff --git a/app/views/user_mailer/appeal_approved.text.erb b/app/views/user_mailer/appeal_approved.text.erb new file mode 100644 index 000000000..290fa24c3 --- /dev/null +++ b/app/views/user_mailer/appeal_approved.text.erb @@ -0,0 +1,7 @@ +<%= t 'user_mailer.appeal_approved.title' %> + +=== + +<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %> + +=> <%= root_url %> diff --git a/app/views/user_mailer/appeal_rejected.html.haml b/app/views/user_mailer/appeal_rejected.html.haml new file mode 100644 index 000000000..75cd9d023 --- /dev/null +++ b/app/views/user_mailer/appeal_rejected.html.haml @@ -0,0 +1,59 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_warning.png'), alt: '' + + %h1= t 'user_mailer.appeal_rejected.title' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to root_url do + %span= t 'user_mailer.appeal_approved.action' diff --git a/app/views/user_mailer/appeal_rejected.text.erb b/app/views/user_mailer/appeal_rejected.text.erb new file mode 100644 index 000000000..f47a76818 --- /dev/null +++ b/app/views/user_mailer/appeal_rejected.text.erb @@ -0,0 +1,7 @@ +<%= t 'user_mailer.appeal_rejected.title' %> + +=== + +<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %> + +=> <%= root_url %> diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index bda1fef6c..b308e18f7 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -77,8 +77,8 @@ %tbody %tr %td.button-primary - = link_to about_more_url do - %span= t 'user_mailer.warning.review_server_policies' + = link_to disputes_strike_url(@warning) do + %span= t 'user_mailer.warning.appeal' %table.email-table{ cellspacing: 0, cellpadding: 0 } %tbody @@ -95,4 +95,4 @@ %tbody %tr %td.column-cell.text-center - %p= t 'user_mailer.warning.get_in_touch', instance: @instance + %p= t 'user_mailer.warning.appeal_description', instance: @instance diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 4245b7192..6ffe12ae0 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,25 +1,5 @@ { "ignored_warnings": [ - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "04dbbc249b989db2e0119bbb0f59c9818e12889d2b97c529cdc0b1526002ba4b", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/report.rb", - "line": 113, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")", - "render_path": null, - "location": { - "type": "method", - "class": "Report", - "method": "history" - }, - "user_input": "Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)", - "confidence": "High", - "note": "" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -27,7 +7,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/status.rb", - "line": 100, + "line": 104, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")", "render_path": null, @@ -107,7 +87,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/admin/reports_controller.rb", - "line": 78, + "line": 90, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit(:resolved, :account_id, :target_account_id)", "render_path": null, @@ -140,6 +120,36 @@ "confidence": "Medium", "note": "" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "afad51718ae373b2f19d2513029fd2afccf58b9148e475934bc6a162ee33c352", + "check_name": "CrossSiteScripting", + "message": "Unescaped model attribute", + "file": "app/views/admin/disputes/appeals/_appeal.html.haml", + "line": 7, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "t((Unresolved Model).new.strike.action, :scope => \"admin.strikes.actions\", :name => content_tag(:span, (Unresolved Model).new.strike.account.username, :class => \"username\"), :target => content_tag(:span, (Unresolved Model).new.account.acct, :class => \"target\"))", + "render_path": [ + { + "type": "template", + "name": "admin/disputes/appeals/index", + "line": 16, + "file": "app/views/admin/disputes/appeals/index.html.haml", + "rendered": { + "name": "admin/disputes/appeals/_appeal", + "file": "app/views/admin/disputes/appeals/_appeal.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "admin/disputes/appeals/_appeal" + }, + "user_input": "(Unresolved Model).new.strike", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Redirect", "warning_code": 18, @@ -194,7 +204,7 @@ { "type": "template", "name": "admin/trends/links/index", - "line": 37, + "line": 39, "file": "app/views/admin/trends/links/index.html.haml", "rendered": { "name": "admin/trends/links/_preview_card", @@ -213,13 +223,13 @@ { "warning_type": "Mass Assignment", "warning_code": 105, - "fingerprint": "e867661b2c9812bc8b75a5df12b28e2a53ab97015de0638b4e732fe442561b28", + "fingerprint": "f9de0ca4b04ae4b51b74d98db14dcbb6dae6809e627b58e711019cf9b4a47866", "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/reports_controller.rb", "line": 36, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.permit(:account_id, :comment, :forward, :status_ids => ([]))", + "code": "params.permit(:account_id, :comment, :category, :forward, :status_ids => ([]), :rule_ids => ([]))", "render_path": null, "location": { "type": "method", @@ -231,6 +241,6 @@ "note": "" } ], - "updated": "2021-11-14 05:26:09 +0100", - "brakeman_version": "5.1.2" + "updated": "2022-02-13 02:24:12 +0100", + "brakeman_version": "5.2.1" } diff --git a/config/locales/en.yml b/config/locales/en.yml index 1809f123e..05c64e18a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -94,7 +94,6 @@ en: account_moderation_notes: create: Leave note created_msg: Moderation note successfully created! - delete: Delete destroyed_msg: Moderation note successfully destroyed! accounts: add_email_domain_block: Block e-mail domain @@ -163,6 +162,11 @@ en: not_subscribed: Not subscribed pending: Pending review perform_full_suspension: Suspend + previous_strikes: Previous strikes + previous_strikes_description_html: + one: This account has one strike. + other: This account has %{count} strikes. + zero: This account is in good standing. promote: Promote protocol: Protocol public: Public @@ -227,6 +231,7 @@ en: whitelisted: Allowed for federation action_logs: action_types: + approve_appeal: Approve Appeal approve_user: Approve User assigned_to_self_report: Assign Report change_email_user: Change E-mail for User @@ -258,6 +263,7 @@ en: enable_user: Enable User memorialize_account: Memorialize Account promote_user: Promote User + reject_appeal: Reject Appeal reject_user: Reject User remove_avatar_user: Remove Avatar reopen_report: Reopen Report @@ -276,6 +282,7 @@ en: update_domain_block: Update Domain Block update_status: Update Post actions: + approve_appeal_html: "%{name} approved moderation decision appeal from %{target}" approve_user_html: "%{name} approved sign-up from %{target}" assigned_to_self_report_html: "%{name} assigned report %{target} to themselves" change_email_user_html: "%{name} changed the e-mail address of user %{target}" @@ -307,6 +314,7 @@ en: enable_user_html: "%{name} enabled login for user %{target}" memorialize_account_html: "%{name} turned %{target}'s account into a memoriam page" promote_user_html: "%{name} promoted user %{target}" + reject_appeal_html: "%{name} rejected moderation decision appeal from %{target}" reject_user_html: "%{name} rejected sign-up from %{target}" remove_avatar_user_html: "%{name} removed %{target}'s avatar" reopen_report_html: "%{name} reopened report %{target}" @@ -385,6 +393,9 @@ en: media_storage: Media storage new_users: new users opened_reports: reports opened + pending_appeals_html: + one: "1 pending appeal" + other: "%{count} pending appeals" pending_reports_html: one: "1 pending report" other: "%{count} pending reports" @@ -402,6 +413,10 @@ en: top_languages: Top active languages top_servers: Top active servers website: Website + disputes: + appeals: + empty: No appeals found. + title: Appeals domain_allows: add_new: Allow federation with domain created_msg: Domain has been successfully allowed for federation @@ -720,6 +735,16 @@ en: no_status_selected: No posts were changed as none were selected title: Account posts with_media: With media + strikes: + actions: + delete_statuses: "%{name} deleted %{target}'s posts" + disable: "%{name} froze %{target}'s account" + none: "%{name} sent a warning to %{target}" + sensitive: "%{name} marked %{target}'s account as sensitive" + silence: "%{name} limited %{target}'s account" + suspend: "%{name} suspended %{target}'s account" + appeal_approved: Appealed + appeal_pending: Appeal pending system_checks: database_schema_check: message_html: There are pending database migrations. Please run them to ensure the application behaves as expected @@ -781,6 +806,17 @@ en: empty: You haven't defined any warning presets yet. title: Manage warning presets admin_mailer: + new_appeal: + actions: + delete_statuses: to delete their posts + disable: to freeze their account + none: a warning + sensitive: to mark their account as sensitive + silence: to limit their account + suspend: to suspend their account + body: "%{target} is appealing a moderation decision by %{action_taken_by} from %{date}, which was %{type}. They wrote:" + next_steps: You can approve the appeal to undo the moderation decision, or ignore it. + subject: "%{username} is appealing a moderation decision on %{instance}" new_pending_account: body: The details of the new account are below. You can approve or reject this application. subject: New account up for review on %{instance} (%{username}) @@ -871,7 +907,6 @@ en: status: account_status: Account status confirming: Waiting for e-mail confirmation to be completed. - functional: Your account is fully operational. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. redirecting_to: Your account is inactive because it is currently redirecting to %{acct}. too_fast: Form submitted too fast, try again. @@ -937,6 +972,32 @@ en: directory: Profile directory explanation: Discover users based on their interests explore_mastodon: Explore %{title} + disputes: + strikes: + action_taken: Action taken + appeal: Appeal + appeal_approved: This strike has been successfully appealed and is no longer valid + appeal_rejected: The appeal has been rejected + appeal_submitted_at: Appeal submitted + appealed_msg: Your appeal has been submitted. If it is approved, you will be notified. + appeals: + submit: Submit appeal + associated_report: Associated report + created_at: Dated + recipient: Addressed to + status: 'Post #%{id}' + status_removed: Post already removed from system + title: "%{action} from %{date}" + title_actions: + delete_statuses: Post removal + disable: Freezing of account + none: Warning + sensitive: Marking as sensitive of account + silence: Limitation of account + suspend: Suspension of account + your_appeal_approved: Your appeal has been approved + your_appeal_pending: You have submitted an appeal + your_appeal_rejected: Your appeal has been rejected domain_validator: invalid_domain: is not a valid domain name errors: @@ -1501,6 +1562,15 @@ en: recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. For example, you may print them and store them with other important documents. webauthn: Security keys user_mailer: + appeal_approved: + action: Go to your account + explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been approved. Your account is once again in good standing. + subject: Your appeal from %{date} has been approved + title: Appeal approved + appeal_rejected: + explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been rejected. + subject: Your appeal from %{date} has been rejected + title: Appeal rejected backup_ready: explanation: You requested a full backup of your Mastodon account. It's now ready for download! subject: Your archive is ready for download @@ -1512,6 +1582,8 @@ en: subject: Please confirm attempted sign in title: Sign in attempt warning: + appeal: Submit an appeal + appeal_description: If you believe this is an error, you can submit an appeal to the staff of %{instance}. categories: spam: Spam violation: Content violates the following community guidelines @@ -1523,7 +1595,6 @@ en: suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed in about 30 days, but we will retain some basic data to prevent you from evading the suspension. get_in_touch: If you believe this is an error, you can reply to this e-mail to get in touch with the staff of %{instance}. reason: 'Reason:' - review_server_policies: Review server policies statuses: 'Posts that have been found in violation:' subject: delete_statuses: Your posts on %{acct} have been removed diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index d6376782d..03eefd0d5 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -27,6 +27,8 @@ en: scheduled_at: Leave blank to publish the announcement immediately starts_at: Optional. In case your announcement is bound to a specific time range text: You can use post syntax. Please be mindful of the space the announcement will take up on the user's screen + appeal: + text: You can only appeal a strike once defaults: autofollow: People who sign up through the invite will automatically follow you avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px @@ -119,6 +121,8 @@ en: scheduled_at: Schedule publication starts_at: Start of event text: Announcement + appeal: + text: Explain why this decision should be reversed defaults: autofollow: Invite to follow your account avatar: Avatar @@ -197,6 +201,7 @@ en: sign_up_requires_approval: Limit sign-ups severity: Rule notification_emails: + appeal: Someone appeals a moderator decision digest: Send digest e-mails favourite: Someone favourited your post follow: Someone followed you @@ -204,8 +209,8 @@ en: mention: Someone mentioned you pending_account: New account needs review reblog: Someone boosted your post - report: A new report is submitted - trending_tag: A new trend requires approval + report: New report is submitted + trending_tag: New trend requires review rule: text: Rule tag: diff --git a/config/navigation.rb b/config/navigation.rb index fc03a2a77..3fc3747d5 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -20,7 +20,7 @@ SimpleNavigation::Configuration.run do |navigation| n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_url, if: -> { current_user.functional? } n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s| - s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities} + s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes} s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys} s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url end @@ -41,7 +41,7 @@ SimpleNavigation::Configuration.run do |navigation| n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s| s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} - s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts} + s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes} s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations} s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } diff --git a/config/routes.rb b/config/routes.rb index 5edb36519..f59f2a5bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -164,6 +164,12 @@ Rails.application.routes.draw do resources :login_activities, only: [:index] end + namespace :disputes do + resources :strikes, only: [:show] do + resource :appeal, only: [:create] + end + end + resources :media, only: [:show] do get :player end @@ -324,6 +330,15 @@ Rails.application.routes.draw do end end end + + namespace :disputes do + resources :appeals, only: [:index] do + member do + post :approve + post :reject + end + end + end end get '/admin', to: redirect('/admin/dashboard', status: 302) diff --git a/config/settings.yml b/config/settings.yml index 06cee2532..e63788ba2 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -48,6 +48,7 @@ defaults: &defaults report: true pending_account: true trending_tag: true + appeal: true interactions: must_be_follower: false must_be_following: false diff --git a/db/migrate/20220124141035_create_appeals.rb b/db/migrate/20220124141035_create_appeals.rb new file mode 100644 index 000000000..afb3efbd5 --- /dev/null +++ b/db/migrate/20220124141035_create_appeals.rb @@ -0,0 +1,14 @@ +class CreateAppeals < ActiveRecord::Migration[6.1] + def change + create_table :appeals do |t| + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :account_warning, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true } + t.text :text, null: false, default: '' + t.datetime :approved_at + t.belongs_to :approved_by_account, foreign_key: { to_table: :accounts, on_delete: :nullify } + t.datetime :rejected_at + t.belongs_to :rejected_by_account, foreign_key: { to_table: :accounts, on_delete: :nullify } + t.timestamps + end + end +end diff --git a/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb b/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb new file mode 100644 index 000000000..a082da774 --- /dev/null +++ b/db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb @@ -0,0 +1,5 @@ +class AddOverruledAtToAccountWarnings < ActiveRecord::Migration[6.1] + def change + add_column :account_warnings, :overruled_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index fd4633d69..8842dcd8c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_01_18_183123) do +ActiveRecord::Schema.define(version: 2022_02_10_153119) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -135,6 +135,7 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do t.datetime "updated_at", null: false t.bigint "report_id" t.string "status_ids", array: true + t.datetime "overruled_at" t.index ["account_id"], name: "index_account_warnings_on_account_id" t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id" end @@ -243,6 +244,22 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do t.bigint "status_ids", array: true end + create_table "appeals", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "account_warning_id", null: false + t.text "text", default: "", null: false + t.datetime "approved_at" + t.bigint "approved_by_account_id" + t.datetime "rejected_at" + t.bigint "rejected_by_account_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_appeals_on_account_id" + t.index ["account_warning_id"], name: "index_appeals_on_account_warning_id", unique: true + t.index ["approved_by_account_id"], name: "index_appeals_on_approved_by_account_id" + t.index ["rejected_by_account_id"], name: "index_appeals_on_rejected_by_account_id" + end + create_table "backups", force: :cascade do |t| t.bigint "user_id" t.string "dump_file_name" @@ -1031,6 +1048,10 @@ ActiveRecord::Schema.define(version: 2022_01_18_183123) do add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade + add_foreign_key "appeals", "account_warnings", on_delete: :cascade + add_foreign_key "appeals", "accounts", column: "approved_by_account_id", on_delete: :nullify + add_foreign_key "appeals", "accounts", column: "rejected_by_account_id", on_delete: :nullify + add_foreign_key "appeals", "accounts", on_delete: :cascade add_foreign_key "backups", "users", on_delete: :nullify add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb new file mode 100644 index 000000000..6a06f9406 --- /dev/null +++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe Admin::Disputes::AppealsController, type: :controller do + render_views + + before { sign_in current_user, scope: :user } + + let(:target_account) { Fabricate(:account) } + let(:strike) { Fabricate(:account_warning, target_account: target_account, action: :suspend) } + let(:appeal) { Fabricate(:appeal, strike: strike, account: target_account) } + + before do + target_account.suspend! + end + + describe 'POST #approve' do + let(:current_user) { Fabricate(:user, admin: true) } + + before do + allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil)) + post :approve, params: { id: appeal.id } + end + + it 'unsuspends a suspended account' do + expect(target_account.reload.suspended?).to be false + end + + it 'redirects back to the strike page' do + expect(response).to redirect_to(disputes_strike_path(appeal.strike)) + end + + it 'notifies target account about approved appeal' do + expect(UserMailer).to have_received(:appeal_approved).with(target_account.user, appeal) + end + end + + describe 'POST #reject' do + let(:current_user) { Fabricate(:user, admin: true) } + + before do + allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil)) + post :reject, params: { id: appeal.id } + end + + it 'redirects back to the strike page' do + expect(response).to redirect_to(disputes_strike_path(appeal.strike)) + end + + it 'notifies target account about rejected appeal' do + expect(UserMailer).to have_received(:appeal_rejected).with(target_account.user, appeal) + end + end +end diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb new file mode 100644 index 000000000..faa571fc9 --- /dev/null +++ b/spec/controllers/disputes/appeals_controller_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Disputes::AppealsController, type: :controller do + render_views + + before { sign_in current_user, scope: :user } + + let!(:admin) { Fabricate(:user, admin: true) } + + describe '#create' do + let(:current_user) { Fabricate(:user) } + let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } + + before do + allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil)) + post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } } + end + + it 'notifies staff about new appeal' do + expect(AdminMailer).to have_received(:new_appeal).with(admin.account, Appeal.last) + end + + it 'redirects back to the strike page' do + expect(response).to redirect_to(disputes_strike_path(strike.id)) + end + end +end diff --git a/spec/controllers/disputes/strikes_controller_spec.rb b/spec/controllers/disputes/strikes_controller_spec.rb new file mode 100644 index 000000000..157f9ec3c --- /dev/null +++ b/spec/controllers/disputes/strikes_controller_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Disputes::StrikesController, type: :controller do + render_views + + before { sign_in current_user, scope: :user } + + describe '#show' do + let(:current_user) { Fabricate(:user) } + let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } + + before do + get :show, params: { id: strike.id } + end + + context 'when meant for the user' do + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'when meant for a different user' do + let(:strike) { Fabricate(:account_warning) } + + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/fabricators/account_warning_fabricator.rb b/spec/fabricators/account_warning_fabricator.rb index db161d446..72fe835d9 100644 --- a/spec/fabricators/account_warning_fabricator.rb +++ b/spec/fabricators/account_warning_fabricator.rb @@ -1,5 +1,6 @@ Fabricator(:account_warning) do - account nil - target_account nil - text "MyText" + account + target_account(fabricator: :account) + text { Faker::Lorem.paragraph } + action 'suspend' end diff --git a/spec/fabricators/appeal_fabricator.rb b/spec/fabricators/appeal_fabricator.rb new file mode 100644 index 000000000..339363822 --- /dev/null +++ b/spec/fabricators/appeal_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:appeal) do + strike(fabricator: :account_warning) + account { |attrs| attrs[:strike].target_account } + text { Faker::Lorem.paragraph } +end diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 75ffbbf40..9c0372b47 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -15,4 +15,9 @@ class AdminMailerPreview < ActionMailer::Preview def new_trending_links AdminMailer.new_trending_links(Account.first, PreviewCard.limit(3)) end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal + def new_appeal + AdminMailer.new_appeal(Account.first, Appeal.first) + end end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 69b9b971e..8de7d8669 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -82,6 +82,11 @@ class UserMailerPreview < ActionMailer::Preview UserMailer.warning(User.first, AccountWarning.last) end + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/appeal_approved + def appeal_approved + UserMailer.appeal_approved(User.first, Appeal.last) + end + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token def sign_in_token UserMailer.sign_in_token(User.first.tap { |user| user.generate_sign_in_token }, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc) diff --git a/spec/models/appeal_spec.rb b/spec/models/appeal_spec.rb new file mode 100644 index 000000000..14062dc4f --- /dev/null +++ b/spec/models/appeal_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Appeal, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end