diff --git a/app/controllers/ajax/anonymous_block_controller.rb b/app/controllers/ajax/anonymous_block_controller.rb index 4e7a86f4..20503553 100644 --- a/app/controllers/ajax/anonymous_block_controller.rb +++ b/app/controllers/ajax/anonymous_block_controller.rb @@ -6,13 +6,15 @@ class Ajax::AnonymousBlockController < AjaxController question = Question.find(params[:question]) + raise Errors::Forbidden if params[:global] && !current_user.mod? + AnonymousBlock.create!( - user: current_user, + user: params[:global] ? nil : current_user, identifier: question.author_identifier, - question: question + question: ) - question.inboxes.first.destroy + question.inboxes.first&.destroy @response[:status] = :okay @response[:message] = t(".success") diff --git a/app/controllers/moderation/anonymous_block_controller.rb b/app/controllers/moderation/anonymous_block_controller.rb new file mode 100644 index 00000000..2e0ef899 --- /dev/null +++ b/app/controllers/moderation/anonymous_block_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Moderation::AnonymousBlockController < ApplicationController + def index + @anonymous_blocks = AnonymousBlock.where(user: nil) + end +end diff --git a/app/javascript/retrospring/features/moderation/blockAnon.ts b/app/javascript/retrospring/features/moderation/blockAnon.ts new file mode 100644 index 00000000..5c587300 --- /dev/null +++ b/app/javascript/retrospring/features/moderation/blockAnon.ts @@ -0,0 +1,46 @@ +import Rails from '@rails/ujs'; +import swal from 'sweetalert'; + +import { showErrorNotification, showNotification } from "utilities/notifications"; +import I18n from "retrospring/i18n"; + +export function blockAnonEventHandler(event: Event): void { + event.preventDefault(); + + swal({ + title: I18n.translate('frontend.mod_mute.confirm.title'), + text: I18n.translate('frontend.mod_mute.confirm.text'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: "#DD6B55", + confirmButtonText: I18n.translate('voc.y'), + cancelButtonText: I18n.translate('voc.n'), + closeOnConfirm: true, + }, (dialogResult) => { + if (!dialogResult) { + return; + } + + const sender: HTMLAnchorElement = event.target as HTMLAnchorElement; + + const data = { + question: sender.getAttribute('data-q-id'), + global: 'true' + }; + + Rails.ajax({ + url: '/ajax/block_anon', + type: 'POST', + data: new URLSearchParams(data).toString(), + success: (data) => { + if (!data.success) return false; + + showNotification(data.message); + }, + error: (data, status, xhr) => { + console.log(data, status, xhr); + showErrorNotification(I18n.translate('frontend.error.message')); + } + }); + }); +} \ No newline at end of file diff --git a/app/javascript/retrospring/features/moderation/index.ts b/app/javascript/retrospring/features/moderation/index.ts index 6c93249b..7d0e639a 100644 --- a/app/javascript/retrospring/features/moderation/index.ts +++ b/app/javascript/retrospring/features/moderation/index.ts @@ -2,11 +2,13 @@ import registerEvents from 'utilities/registerEvents'; import { banCheckboxHandler, banFormHandler, permanentBanCheckboxHandler } from './ban'; import { destroyReportHandler } from './destroy'; import { privilegeCheckHandler } from './privilege'; +import { blockAnonEventHandler } from './blockAnon'; export default (): void => { registerEvents([ - { type: 'click', target: '[type=checkbox][name=check-your-privileges]', handler: privilegeCheckHandler, global: true }, - { type: 'click', target: '[name=mod-delete-report]', handler: destroyReportHandler, global: true }, + { type: 'click', target: '[type="checkbox"][name="check-your-privileges"]', handler: privilegeCheckHandler, global: true }, + { type: 'click', target: '[name="mod-delete-report"]', handler: destroyReportHandler, global: true }, + { type: 'click', target: '[name="mod-block-anon"]', handler: blockAnonEventHandler, global: true }, { type: 'change', target: '[name="ban"][type="checkbox"]', handler: banCheckboxHandler, global: true }, { type: 'change', target: '[name="permaban"][type="checkbox"]', handler: permanentBanCheckboxHandler, global: true }, { type: 'submit', target: '#modal-ban form', handler: banFormHandler, global: true } diff --git a/app/models/anonymous_block.rb b/app/models/anonymous_block.rb index f5213979..accaa7e7 100644 --- a/app/models/anonymous_block.rb +++ b/app/models/anonymous_block.rb @@ -3,7 +3,7 @@ require "digest" class AnonymousBlock < ApplicationRecord - belongs_to :user + belongs_to :user, optional: true belongs_to :question, optional: true def self.get_identifier(ip) diff --git a/app/models/question.rb b/app/models/question.rb index f9b8e4c3..88b5565c 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -26,4 +26,8 @@ class Question < ApplicationRecord return false if Inbox.where(question: self).count > 1 true end + + def generated? = %w[justask retrospring_exporter].include?(author_identifier) + + def anonymous? = author_is_anonymous && author_identifier.present? end diff --git a/app/views/answerbox/_header.haml b/app/views/answerbox/_header.haml index 8b9277a1..1d49f545 100644 --- a/app/views/answerbox/_header.haml +++ b/app/views/answerbox/_header.haml @@ -18,6 +18,10 @@ %a.dropdown-item{ href: "#", tabindex: -1, data: { action: "ab-question-report", q_id: a.question.id } } %i.fa.fa-exclamation-triangle = t("voc.report") + - if current_user.mod? && a.question.anonymous? && !a.question.generated? + %a.dropdown-item{ href: "#", name: "mod-block-anon", data: { q_id: a.question.id } } + %i.fa.fa-volume-off + = t("voc.block_site_wide") - if current_user.has_role? :administrator %a.dropdown-item{ href: rails_admin_path_for_resource(a.question), target: "_blank" } %i.fa.fa-gears diff --git a/app/views/inbox/_entry.haml b/app/views/inbox/_entry.haml index 0bc31cde..dc1931e5 100644 --- a/app/views/inbox/_entry.haml +++ b/app/views/inbox/_entry.haml @@ -22,15 +22,18 @@ .dropdown-menu.dropdown-menu-right{ role: :menu } - if i.question.user_id != current_user.id %a.dropdown-item{ name: "ib-report", data: { q_id: i.question.id } } - %i.fa.fa-warning + %i.fa.fa-fw.fa-warning = t("voc.report") - - if i.question.author_is_anonymous && i.question.author_identifier.present? + - if current_user.mod? && i.question.anonymous? && !i.question.generated? + %a.dropdown-item{ name: "mod-block-anon", data: { q_id: i.question.id } } + %i.fa.fa-fw.fa-volume-off + = t("voc.block_site_wide") %a.dropdown-item{ name: "ib-block-anon", data: { q_id: i.question.id } } - %i.fa.fa-minus-circle + %i.fa.fa-fw.fa-minus-circle = t("voc.block") - if current_user.has_role? :administrator %a.dropdown-item{ href: rails_admin_path_for_resource(i) } - %i.fa.fa-gears + %i.fa.fa-fw.fa-gears = t("voc.view_in_rails_admin") - if current_user == i.user .card-body diff --git a/app/views/moderation/anonymous_block/index.haml b/app/views/moderation/anonymous_block/index.haml new file mode 100644 index 00000000..689669f6 --- /dev/null +++ b/app/views/moderation/anonymous_block/index.haml @@ -0,0 +1,10 @@ +- provide(:title, generate_title(t(".title"))) + +.container-lg.container--main + .card + .card-body + #entries + - if @anonymous_blocks.empty? + .text-center= t(".empty") + - @anonymous_blocks.each do |block| + = render "shared/anonymous_block", block: diff --git a/app/views/navigation/dropdown/_profile.haml b/app/views/navigation/dropdown/_profile.haml index 20c52295..95dfcf88 100644 --- a/app/views/navigation/dropdown/_profile.haml +++ b/app/views/navigation/dropdown/_profile.haml @@ -30,6 +30,9 @@ = link_to moderation_toggle_unmask_path, method: :post, class: "dropdown-item" do %i.fa.fa-toggle-off = t(".unmask.enable") + %a.dropdown-item{ href: mod_anon_block_index_path } + %i.fa.fa-fw.fa-volume-off + = t(".site_wide_blocks") %a.dropdown-item{ href: moderation_path } %i.fa.fa-fw.fa-gavel = t(".moderation") diff --git a/app/views/settings/blocks/index.haml b/app/views/settings/blocks/index.haml index ab3947dc..38608e0d 100644 --- a/app/views/settings/blocks/index.haml +++ b/app/views/settings/blocks/index.haml @@ -22,17 +22,7 @@ %p= t(".section.anon_blocks.body") %ul.list-group - @anonymous_blocks.each do |block| - %li.list-group-item - .d-flex - %div - - if block.question.present? - %p.mb-0= block.question.content - - else - %p.mb-0.text-muted.font-italic= t(".deleted_question") - %p.text-muted.mb-0= t(".blocked", time: time_ago_in_words(block.created_at)) - .ml-auto.d-inline-flex - %button.btn.btn-default.align-self-center{ data: { action: "anon-unblock", target: block.id } } - %span.pe-none= t("voc.unblock") + = render "shared/anonymous_block", block: - if @anonymous_blocks.empty? %p.text-muted.text-center= t(".none") diff --git a/app/views/shared/_anonymous_block.haml b/app/views/shared/_anonymous_block.haml new file mode 100644 index 00000000..39cb20a9 --- /dev/null +++ b/app/views/shared/_anonymous_block.haml @@ -0,0 +1,11 @@ +%li.list-group-item + .d-flex + %div + - if block.question.present? + %p.mb-0= block.question.content + - else + %p.mb-0.text-muted.font-italic= t(".deleted_question") + %p.text-muted.mb-0= t(".blocked", time: time_ago_in_words(block.created_at)) + .ml-auto.d-inline-flex + %button.btn.btn-default.align-self-center{ data: { action: "anon-unblock", target: block.id } } + %span.pe-none= t("voc.unblock") \ No newline at end of file diff --git a/config/locales/frontend.en.yml b/config/locales/frontend.en.yml index fd1e59ee..0e734fd6 100644 --- a/config/locales/frontend.en.yml +++ b/config/locales/frontend.en.yml @@ -45,6 +45,10 @@ en: confirm: title: "Are you sure?" text: "This question will be gone forever." + mod_mute: + confirm: + title: "Are you sure?" + text: "This will mute this user for everyone." report: confirm: title: "Are you sure you want to report this %{type}?" diff --git a/config/locales/views.en.yml b/config/locales/views.en.yml index eba04ea0..66ad3f1e 100644 --- a/config/locales/views.en.yml +++ b/config/locales/views.en.yml @@ -305,6 +305,7 @@ en: heading: "Feedback" bugs: "Bugs" features: "Feature Requests" + site_wide_blocks: "Site-wide anonymous blocks" desktop: ask_question: "Ask a question" list: :user.actions.list @@ -348,7 +349,6 @@ en: blocks: index: title: "Blocks" - blocked: "blocked %{time} ago" none: "You are not blocking anyone." section: blocks: @@ -357,7 +357,6 @@ en: anon_blocks: heading: "Anonymous Blocks" body: "Each anonymous user you've blocked is listed here, along with a way to unblock them. We also display the question they asked you to add some more context. You can block anonymous users directly from the question in your inbox." - deleted_question: "Deleted question" data: index: title: "Your Data" @@ -511,6 +510,9 @@ en: source: "Source code" terms: "Terms of Service" privacy: "Privacy Policy" + anonymous_block: + deleted_question: "Deleted question" + blocked: "blocked %{time} ago" sidebar: list: title: "Members" @@ -572,6 +574,10 @@ en: heading: "Reason:" none: "No reason provided" view: "View reported %{content}" + anonymous_block: + index: + title: "Site-wide anonymous blocks" + empty: "There are currently no users blocked site-wide." user: actions: view_inbox: "View inbox" diff --git a/config/locales/voc.en.yml b/config/locales/voc.en.yml index 3533a892..fc15602f 100644 --- a/config/locales/voc.en.yml +++ b/config/locales/voc.en.yml @@ -2,6 +2,7 @@ en: voc: answer: "Answer" block: "Block" + block_site_wide: "Block user site-wide" cancel: "Cancel" close: "Close" confirm: "Are you sure?" diff --git a/config/routes.rb b/config/routes.rb index e484097a..ab4eca51 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,7 @@ Rails.application.routes.draw do # Routes only accessible by moderators (moderation panel) authenticate :user, ->(user) { user.mod? } do post "/moderation/unmask", to: "moderation#toggle_unmask", as: :moderation_toggle_unmask + get "/moderation/blocks", to: "moderation/anonymous_block#index", as: :mod_anon_block_index get "/moderation(/:type)", to: "moderation#index", as: :moderation, defaults: { type: "all" } get "/moderation/inbox/:user", to: "moderation/inbox#index", as: :mod_inbox_index namespace :ajax do diff --git a/db/migrate/20220820163035_make_user_id_on_anonymous_blocks_nullable.rb b/db/migrate/20220820163035_make_user_id_on_anonymous_blocks_nullable.rb new file mode 100644 index 00000000..046fb385 --- /dev/null +++ b/db/migrate/20220820163035_make_user_id_on_anonymous_blocks_nullable.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MakeUserIdOnAnonymousBlocksNullable < ActiveRecord::Migration[6.1] + def change + change_column_null :anonymous_blocks, :user_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 140139e5..3f035379 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_07_20_190421) do +ActiveRecord::Schema.define(version: 2022_08_20_163035) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -46,6 +46,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_190421) do t.datetime "created_at" t.datetime "updated_at" t.integer "smile_count", default: 0, null: false + t.datetime "deleted_at" + t.index ["deleted_at"], name: "index_answers_on_deleted_at" t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id", "created_at"], name: "index_answers_on_user_id_and_created_at" end @@ -58,6 +60,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_190421) do t.text "content" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.datetime "deleted_at" + t.index ["deleted_at"], name: "index_appendables_on_deleted_at" t.index ["parent_id", "parent_type"], name: "index_appendables_on_parent_id_and_parent_type" t.index ["user_id", "created_at"], name: "index_appendables_on_user_id_and_created_at" end @@ -69,7 +73,9 @@ ActiveRecord::Schema.define(version: 2022_07_20_190421) do t.datetime "created_at" t.datetime "updated_at" t.integer "smile_count", default: 0, null: false + t.datetime "deleted_at" t.index ["answer_id"], name: "index_comments_on_answer_id" + t.index ["deleted_at"], name: "index_comments_on_deleted_at" t.index ["user_id", "created_at"], name: "index_comments_on_user_id_and_created_at" end @@ -144,6 +150,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_190421) do t.datetime "updated_at" t.integer "answer_count", default: 0, null: false t.boolean "direct", default: false, null: false + t.datetime "deleted_at" + t.index ["deleted_at"], name: "index_questions_on_deleted_at" t.index ["user_id", "created_at"], name: "index_questions_on_user_id_and_created_at" end @@ -292,7 +300,9 @@ ActiveRecord::Schema.define(version: 2022_07_20_190421) do t.datetime "export_created_at" t.string "otp_secret_key" t.integer "otp_module", default: 0, null: false + t.datetime "deleted_at" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true + t.index ["deleted_at"], name: "index_users_on_deleted_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["screen_name"], name: "index_users_on_screen_name", unique: true diff --git a/lib/use_case/question/create.rb b/lib/use_case/question/create.rb index 7a5e0f56..7ec5b7fc 100644 --- a/lib/use_case/question/create.rb +++ b/lib/use_case/question/create.rb @@ -72,7 +72,7 @@ module UseCase def filtered?(question) target_user.mute_rules.any? { |rule| rule.applies_to? question } || - (anonymous && target_user.anonymous_blocks.where(identifier: question.author_identifier).any?) + (anonymous && AnonymousBlock.where(identifier: question.author_identifier, user_id: [target_user.id, nil]).any?) end def source_user diff --git a/spec/controllers/ajax/anonymous_block_controller_spec.rb b/spec/controllers/ajax/anonymous_block_controller_spec.rb index 24730037..689916d8 100644 --- a/spec/controllers/ajax/anonymous_block_controller_spec.rb +++ b/spec/controllers/ajax/anonymous_block_controller_spec.rb @@ -13,9 +13,9 @@ describe Ajax::AnonymousBlockController, :ajax_controller, type: :controller do sign_in(user) end - context "when all parameters are given" do + context "when all required parameters are given" do let(:question) { FactoryBot.create(:question, author_identifier: "someidentifier") } - let!(:inbox) { FactoryBot.create(:inbox, user: user, question: question) } + let!(:inbox) { FactoryBot.create(:inbox, user:, question:) } let(:params) do { question: question.id } end @@ -35,6 +35,51 @@ describe Ajax::AnonymousBlockController, :ajax_controller, type: :controller do include_examples "returns the expected response" end + context "when blocking a user globally" do + let(:question) { FactoryBot.create(:question, author_identifier: "someidentifier") } + let!(:inbox) { FactoryBot.create(:inbox, user:, question:) } + let(:params) do + { question: question.id, global: "true" } + end + + context "as a moderator" do + let(:expected_response) do + { + "success" => true, + "status" => "okay", + "message" => anything + } + end + + before do + user.add_role(:moderator) + end + + it "creates an site-wide anonymous block" do + expect { subject }.to(change { AnonymousBlock.count }.by(1)) + expect(AnonymousBlock.last.user_id).to be_nil + end + + include_examples "returns the expected response" + end + + context "as a regular user" do + let(:expected_response) do + { + "success" => false, + "status" => "forbidden", + "message" => anything + } + end + + it "does not create an anonymous block" do + expect { subject }.not_to(change { AnonymousBlock.count }) + end + + include_examples "returns the expected response" + end + end + context "when parameters are missing" do let(:params) { {} } let(:expected_response) do diff --git a/spec/controllers/moderation/anonymous_block_controller_spec.rb b/spec/controllers/moderation/anonymous_block_controller_spec.rb new file mode 100644 index 00000000..f2147756 --- /dev/null +++ b/spec/controllers/moderation/anonymous_block_controller_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Moderation::AnonymousBlockController, :ajax_controller, type: :controller do + describe "#index" do + subject { get :index } + + shared_examples_for "should render the page successfully" do + it "should render the page successfully" do + subject + expect(response).to have_rendered(:index) + expect(response).to have_http_status(200) + end + end + + shared_examples_for "empty" do + it "should assign an empty result set" do + subject + expect(assigns(:anonymous_blocks)).to be_empty + end + end + + context "when there are no anonymous blocks" do + include_examples "empty" + include_examples "should render the page successfully" + end + + context "when there are some anonymous blocks set" do + before do + %w[a b c].each do |identifier| + AnonymousBlock.create!(identifier:) + end + end + + it "should assign an result set" do + subject + expect(assigns(:anonymous_blocks).length).to eq(3) + end + + include_examples "should render the page successfully" + end + + context "when there are only anonymous blocks assigned for users" do + before do + user = FactoryBot.create(:user) + %w[a b c].each do |identifier| + AnonymousBlock.create!(user:, identifier:) + end + end + + include_examples "empty" + include_examples "should render the page successfully" + end + end +end diff --git a/spec/lib/use_case/question/create_spec.rb b/spec/lib/use_case/question/create_spec.rb index ba7d7f23..79ae1aca 100644 --- a/spec/lib/use_case/question/create_spec.rb +++ b/spec/lib/use_case/question/create_spec.rb @@ -79,6 +79,18 @@ describe UseCase::Question::Create do it_behaves_like "creates the question", false end + + context "question is from an anon who is blocked globally" do + before do + AnonymousBlock.create!( + identifier: author_identifier, + question_id: FactoryBot.create(:question).id, + user_id: nil + ) + end + + it_behaves_like "creates the question", false + end end context "user signed in" do