From 029bd6fedae4a3f34c96324ee4a9a95d28b68d7a Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Fri, 17 Jan 2025 08:57:44 +1100 Subject: [PATCH] FEATURE: setting allowing tl0/anonymous flag illegal content (#30785) The new site setting `allow_anonymous_and_tl0_to_flag_illegal` allows tl0 users to flag illegal content. In addition, anonymous users are instructed on how to flag illegal content by sending emails. Also `email_address_to_report_illegal_content` setting is added. If not provided, then the site contact email is used. --- .../app/components/flag-action-type.hbs | 1 + .../app/components/modal/anonymous-flag.gjs | 39 ++++++++++ .../discourse/app/components/modal/flag.js | 4 + .../app/components/post/menu/buttons/flag.gjs | 18 ++++- .../javascripts/discourse/app/routes/topic.js | 4 +- .../discourse/app/widgets/post-menu.js | 34 ++++++--- config/locales/client.en.yml | 5 ++ config/locales/server.en.yml | 4 + config/site_settings.yml | 9 +++ lib/guardian/post_guardian.rb | 4 + lib/site_settings/validations.rb | 10 +++ spec/lib/guardian_spec.rb | 10 +++ spec/lib/site_settings/validations_spec.rb | 15 ++++ .../config/site_settings_controller_spec.rb | 2 + spec/system/admin_flags_spec.rb | 2 + spec/system/flagging_post_spec.rb | 75 +++++++++++++++++-- .../page_objects/modals/anonoymous_flag.rb | 9 +++ spec/system/page_objects/pages/topic.rb | 8 ++ 18 files changed, 231 insertions(+), 22 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/modal/anonymous-flag.gjs create mode 100644 spec/system/page_objects/modals/anonoymous_flag.rb diff --git a/app/assets/javascripts/discourse/app/components/flag-action-type.hbs b/app/assets/javascripts/discourse/app/components/flag-action-type.hbs index c7452421f68..89d7f15914a 100644 --- a/app/assets/javascripts/discourse/app/components/flag-action-type.hbs +++ b/app/assets/javascripts/discourse/app/components/flag-action-type.hbs @@ -42,6 +42,7 @@ diff --git a/app/assets/javascripts/discourse/app/components/modal/anonymous-flag.gjs b/app/assets/javascripts/discourse/app/components/modal/anonymous-flag.gjs new file mode 100644 index 00000000000..333c95bf0e9 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/anonymous-flag.gjs @@ -0,0 +1,39 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { isEmpty } from "@ember/utils"; +import DModal from "discourse/components/d-modal"; +import { getAbsoluteURL } from "discourse/lib/get-url"; +import { i18n } from "discourse-i18n"; + +export default class AnonymousFlagModal extends Component { + @service siteSettings; + + get description() { + return i18n("anonymous_flagging.description", { + email: this.#email, + topic_title: this.args.model.flagModel.topic.title, + url: getAbsoluteURL(this.args.model.flagModel.url), + }); + } + + get #email() { + if (isEmpty(this.siteSettings.email_address_to_report_illegal_content)) { + return this.siteSettings.contact_email; + } + return this.siteSettings.email_address_to_report_illegal_content; + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/modal/flag.js b/app/assets/javascripts/discourse/app/components/modal/flag.js index 323defc188d..140de6a5824 100644 --- a/app/assets/javascripts/discourse/app/components/modal/flag.js +++ b/app/assets/javascripts/discourse/app/components/modal/flag.js @@ -29,6 +29,10 @@ export default class Flag extends Component { this.adminTools ?.checkSpammer(this.args.model.flagModel.user_id) .then((result) => (this.spammerDetails = result)); + + if (this.flagsAvailable.length === 1) { + this.selected = this.flagsAvailable[0]; + } } get flagActions() { diff --git a/app/assets/javascripts/discourse/app/components/post/menu/buttons/flag.gjs b/app/assets/javascripts/discourse/app/components/post/menu/buttons/flag.gjs index f565cab2b9c..51856afc009 100644 --- a/app/assets/javascripts/discourse/app/components/post/menu/buttons/flag.gjs +++ b/app/assets/javascripts/discourse/app/components/post/menu/buttons/flag.gjs @@ -6,9 +6,21 @@ import concatClass from "discourse/helpers/concat-class"; import DiscourseURL from "discourse/lib/url"; export default class PostMenuFlagButton extends Component { - static shouldRender(args) { + static shouldRender(args, helper) { const { reviewable_id, canFlag, hidden } = args.post; - return reviewable_id || (canFlag && !hidden); + return ( + reviewable_id || + (canFlag && !hidden) || + (helper.siteSettings + .allow_tl0_and_anonymous_users_to_flag_illegal_content && + !helper.currentUser) + ); + } + + get title() { + return this.args.post.currentUser + ? "post.controls.flag" + : "post.controls.anonymous_flag"; } @action @@ -36,7 +48,7 @@ export default class PostMenuFlagButton extends Component { @action={{@buttonActions.showFlags}} @icon="flag" @label={{if @showLabel "post.controls.flag_action"}} - @title="post.controls.flag" + @title={{this.title}} /> diff --git a/app/assets/javascripts/discourse/app/routes/topic.js b/app/assets/javascripts/discourse/app/routes/topic.js index 7b7c4458c82..4ac337ccbdc 100644 --- a/app/assets/javascripts/discourse/app/routes/topic.js +++ b/app/assets/javascripts/discourse/app/routes/topic.js @@ -3,6 +3,7 @@ import { cancel, schedule } from "@ember/runloop"; import { service } from "@ember/service"; import { isEmpty } from "@ember/utils"; import AddPmParticipants from "discourse/components/modal/add-pm-participants"; +import AnonymousFlagModal from "discourse/components/modal/anonymous-flag"; import ChangeOwnerModal from "discourse/components/modal/change-owner"; import ChangeTimestampModal from "discourse/components/modal/change-timestamp"; import EditSlowModeModal from "discourse/components/modal/edit-slow-mode"; @@ -27,6 +28,7 @@ const SCROLL_DELAY = 500; export default class TopicRoute extends DiscourseRoute { @service composer; @service screenTrack; + @service currentUser; @service modal; @service router; @@ -104,7 +106,7 @@ export default class TopicRoute extends DiscourseRoute { @action showFlags(model) { - this.modal.show(FlagModal, { + this.modal.show(this.currentUser ? FlagModal : AnonymousFlagModal, { model: { flagTarget: new PostFlag(), flagModel: model, diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js index fa53f922246..52eeb8d03e7 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js @@ -211,20 +211,30 @@ registerButton("flag-count", (attrs) => { }; }); -registerButton("flag", (attrs) => { - if (attrs.reviewableId || (attrs.canFlag && !attrs.hidden)) { - let button = { - action: "showFlags", - title: "post.controls.flag", - icon: "flag", - className: "create-flag", - }; - if (attrs.reviewableId) { - button.before = "flag-count"; +registerButton( + "flag", + (attrs, _state, siteSettings, _postMenuSettings, currentUser) => { + if ( + attrs.reviewableId || + (attrs.canFlag && !attrs.hidden) || + (siteSettings.allow_tl0_and_anonymous_users_to_flag_illegal_content && + !currentUser) + ) { + const button = { + action: "showFlags", + title: currentUser + ? "post.controls.flag" + : "post.controls.anonymous_flag", + icon: "flag", + className: "create-flag", + }; + if (attrs.reviewableId) { + button.before = "flag-count"; + } + return button; } - return button; } -}); +); registerButton("edit", (attrs) => { if (attrs.canEdit) { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e689431f115..b166d9e2626 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3894,6 +3894,7 @@ en: edit_anonymous: "Sorry, but you need to be logged in to edit this post." flag_action: "Flag" flag: "privately flag this post for attention or send a personal message about it" + anonymous_flag: "send an email to staff to flag this post" delete_action: "Delete post" delete: "delete this post" undelete_action: "Undelete post" @@ -4211,6 +4212,10 @@ en: none: "no subcategories" colors_disabled: "You can’t select colors because you have a category style of none." + anonymous_flagging: + title: "Report illegal content" + description: "To report illegal content, please contact %{email}" + flagging: title: "Thanks for keeping our community civil!" action: "Flag Post" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 73002142f0d..8da5ac715f3 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -364,6 +364,8 @@ en: slow_down_crawler_user_agent_cannot_be_popular_browsers: "You cannot add any of the following values to the setting: %{values}." strip_image_metadata_cannot_be_disabled_if_composer_media_optimization_image_enabled: "You cannot disable strip image metadata if 'composer media optimization image enabled' is enabled. Disable 'composer media optimization image enabled' before disabling strip image metadata." twitter_summary_large_image_no_svg: "Twitter summary images used for twitter:image metadata cannot be an .svg image." + tl0_and_anonymous_flag: "Either 'site contact email' or 'email address to report illegal content' must be provided for anonymous users." + conflicting_google_user_id: 'The Google Account ID for this account has changed; staff intervention is required for security reasons. Please contact staff and point them to
https://meta.discourse.org/t/76575' onebox: invalid_address: "Sorry, we were unable to generate a preview for this web page, because the server '%{hostname}' could not be found. Instead of a preview, only a link will appear in your post. :cry:" @@ -2307,6 +2309,8 @@ en: reviewable_default_visibility: "Don't show reviewable items unless they meet this priority" reviewable_low_priority_threshold: "The priority filter hides reviewable items that don't meet this score unless the '(any)' filter is used." high_trust_flaggers_auto_hide_posts: "New user posts are automatically hidden after being flagged as spam by a TL3+ user" + allow_tl0_and_anonymous_users_to_flag_illegal_content: "Anonymous users will see information that they have to e-mail administrators to report illegal content." + email_address_to_report_illegal_content: "If left blank the default site admin email will be used." cooldown_hours_until_reflag: "How much time users will have to wait until they are able to reflag a post" slow_mode_prevents_editing: "Does 'Slow Mode' prevent editing, after editing_grace_period?" diff --git a/config/site_settings.yml b/config/site_settings.yml index fa051214f40..4937b6aee41 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -54,6 +54,7 @@ required: default: "" type: email area: "about|notifications|legal" + client: true contact_url: default: "" area: "about|legal" @@ -1938,6 +1939,14 @@ trust: allow_any: false refresh: true area: "group_permissions" + allow_tl0_and_anonymous_users_to_flag_illegal_content: + default: false + area: "flags" + client: true + email_address_to_report_illegal_content: + default: "" + area: "flags" + client: true min_trust_to_post_links: default: 0 enum: "TrustLevelSetting" diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index c46a7452c16..89dade652ff 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -94,6 +94,10 @@ module PostGuardian post.topic.private_message? ) ) || + ( + action_key == :illegal && + SiteSetting.allow_tl0_and_anonymous_users_to_flag_illegal_content + ) || # not a flagging action, and haven't done it already not(is_flag || already_taken_this_action) && # nothing except flagging on archived topics diff --git a/lib/site_settings/validations.rb b/lib/site_settings/validations.rb index dbaac48c006..6639c431be0 100644 --- a/lib/site_settings/validations.rb +++ b/lib/site_settings/validations.rb @@ -267,6 +267,16 @@ module SiteSettings::Validations validate_error :twitter_summary_large_image_no_svg end + def validate_allow_tl0_and_anonymous_users_to_flag_illegal_content(new_val) + return if new_val == "f" + if SiteSetting.contact_email.present? || + SiteSetting.email_address_to_report_illegal_content.present? + return + end + + validate_error :tl0_and_anonymous_flag + end + private def validate_bucket_setting(setting_name, upload_bucket, backup_bucket) diff --git a/spec/lib/guardian_spec.rb b/spec/lib/guardian_spec.rb index 838dc5c8c1f..5e8a6c102fd 100644 --- a/spec/lib/guardian_spec.rb +++ b/spec/lib/guardian_spec.rb @@ -163,6 +163,16 @@ RSpec.describe Guardian do Flag.reset_flag_settings! end + it "return true for illegal if tl0 and allow_tl0_and_anonymous_users_to_flag_illegal_content" do + SiteSetting.flag_post_allowed_groups = "" + user.trust_level = TrustLevel[0] + expect(Guardian.new(user).post_can_act?(post, :illegal)).to be false + + SiteSetting.email_address_to_report_illegal_content = "illegal@example.com" + SiteSetting.allow_tl0_and_anonymous_users_to_flag_illegal_content = true + expect(Guardian.new(user).post_can_act?(post, :illegal)).to be true + end + it "works as expected for silenced users" do UserSilencer.silence(user, admin) diff --git a/spec/lib/site_settings/validations_spec.rb b/spec/lib/site_settings/validations_spec.rb index 43a901175d8..109738327b6 100644 --- a/spec/lib/site_settings/validations_spec.rb +++ b/spec/lib/site_settings/validations_spec.rb @@ -474,4 +474,19 @@ RSpec.describe SiteSettings::Validations do expect { validations.validate_twitter_summary_large_image(nil) }.not_to raise_error end end + + describe "#validate_allow_tl0_and_anonymous_users_to_flag_illegal_content" do + it "does not allow to enable when no contact email is provided" do + expect { + validations.validate_allow_tl0_and_anonymous_users_to_flag_illegal_content("t") + }.to raise_error( + Discourse::InvalidParameters, + I18n.t("errors.site_settings.tl0_and_anonymous_flag"), + ) + SiteSetting.contact_email = "illegal@example.com" + expect { + validations.validate_allow_tl0_and_anonymous_users_to_flag_illegal_content("t") + }.not_to raise_error + end + end end diff --git a/spec/requests/admin/config/site_settings_controller_spec.rb b/spec/requests/admin/config/site_settings_controller_spec.rb index 602f082ee2a..67f9a2515a7 100644 --- a/spec/requests/admin/config/site_settings_controller_spec.rb +++ b/spec/requests/admin/config/site_settings_controller_spec.rb @@ -71,6 +71,8 @@ RSpec.describe Admin::SiteSettingsController do expect(response.status).to eq(200) expect(response.parsed_body["site_settings"].map { |s| s["setting"] }).to match_array( %w[ + allow_tl0_and_anonymous_users_to_flag_illegal_content + email_address_to_report_illegal_content silence_new_user_sensitivity num_users_to_silence_new_user flag_sockpuppets diff --git a/spec/system/admin_flags_spec.rb b/spec/system/admin_flags_spec.rb index de3d4bbfa9d..b592dcd1050 100644 --- a/spec/system/admin_flags_spec.rb +++ b/spec/system/admin_flags_spec.rb @@ -164,6 +164,8 @@ describe "Admin Flags Page", type: :system do admin_flags_page.click_tab("settings") expect(page.all(".setting-label h3").map(&:text).map(&:downcase)).to eq( [ + "allow tl0 and anonymous users to flag illegal content", + "email address to report illegal content", "silence new user sensitivity", "num users to silence new user", "flag sockpuppets", diff --git a/spec/system/flagging_post_spec.rb b/spec/system/flagging_post_spec.rb index ea6d2c9f29f..0ee8748919b 100644 --- a/spec/system/flagging_post_spec.rb +++ b/spec/system/flagging_post_spec.rb @@ -2,21 +2,23 @@ describe "Flagging post", type: :system do fab!(:current_user) { Fabricate(:admin) } - fab!(:first_post) { Fabricate(:post) } - fab!(:post_to_flag) { Fabricate(:post, topic: first_post.topic) } + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:first_post) { Fabricate(:post, topic: topic) } + fab!(:post_to_flag) { Fabricate(:post, topic: topic) } let(:topic_page) { PageObjects::Pages::Topic.new } let(:flag_modal) { PageObjects::Modals::Flag.new } - before { sign_in(current_user) } - describe "Using Take Action" do + before { sign_in(current_user) } + it "can select the default action to hide the post, agree with other flags, and reach the flag threshold" do other_flag = Fabricate(:flag_post_action, post: post_to_flag, user: Fabricate(:moderator)) other_flag_reviewable = Fabricate(:reviewable_flagged_post, target: post_to_flag, created_by: other_flag.user) expect(other_flag.reload.agreed_at).to be_nil - topic_page.visit_topic(post_to_flag.topic) + topic_page.visit_topic(topic) topic_page.expand_post_actions(post_to_flag) topic_page.click_post_action_button(post_to_flag, :flag) flag_modal.choose_type(:off_topic) @@ -34,8 +36,10 @@ describe "Flagging post", type: :system do end describe "As Illegal" do + before { sign_in(current_user) } + it do - topic_page.visit_topic(post_to_flag.topic) + topic_page.visit_topic(topic) topic_page.expand_post_actions(post_to_flag) topic_page.click_post_action_button(post_to_flag, :flag) flag_modal.choose_type(:illegal) @@ -50,4 +54,63 @@ describe "Flagging post", type: :system do expect(page).to have_content(I18n.t("js.post.actions.by_you.illegal")) end end + + context "when tl0" do + fab!(:tl0_user) { Fabricate(:user, trust_level: TrustLevel[0]) } + before { sign_in(tl0_user) } + + it "does not allow to mark posts as illegal" do + topic_page.visit_topic(topic) + topic_page.expand_post_actions(post_to_flag) + expect(topic_page).to have_no_flag_button + end + + it "allows to mark posts as illegal when allow_tl0_and_anonymous_users_to_flag_illegal_content setting is enabled" do + SiteSetting.email_address_to_report_illegal_content = "illegal@example.com" + SiteSetting.allow_tl0_and_anonymous_users_to_flag_illegal_content = true + topic_page.visit_topic(topic).open_flag_topic_modal + expect(flag_modal).to have_choices(I18n.t("js.flagging.formatted_name.illegal")) + end + end + + context "when anonymous" do + let(:anonymous_flag_modal) { PageObjects::Modals::AnonymousFlag.new } + + it "does not allow to mark posts as illegal" do + topic_page.visit_topic(topic) + expect(topic_page).to have_no_post_more_actions(post_to_flag) + end + + it "allows to mark posts as illegal when allow_tl0_and_anonymous_users_to_flag_illegal_content setting is enabled" do + SiteSetting.contact_email = "contact@example.com" + SiteSetting.allow_tl0_and_anonymous_users_to_flag_illegal_content = true + + topic_page.visit_topic(topic, post_number: post_to_flag.post_number) + topic_page.expand_post_actions(post_to_flag) + topic_page.find_post_action_button(post_to_flag, :flag).click + + expect(anonymous_flag_modal.body).to have_content( + ActionView::Base.full_sanitizer.sanitize( + I18n.t( + "js.anonymous_flagging.description", + { email: "contact@example.com", topic_title: topic.title, url: current_url }, + ), + ), + ) + + SiteSetting.email_address_to_report_illegal_content = "illegal@example.com" + topic_page.visit_topic(topic) + topic_page.expand_post_actions(post_to_flag) + topic_page.find_post_action_button(post_to_flag, :flag).click + + expect(anonymous_flag_modal.body).to have_content( + ActionView::Base.full_sanitizer.sanitize( + I18n.t( + "js.anonymous_flagging.description", + { email: "illegal@example.com", topic_title: topic.title, url: current_url }, + ), + ), + ) + end + end end diff --git a/spec/system/page_objects/modals/anonoymous_flag.rb b/spec/system/page_objects/modals/anonoymous_flag.rb new file mode 100644 index 00000000000..dfc96b80e7c --- /dev/null +++ b/spec/system/page_objects/modals/anonoymous_flag.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PageObjects + module Modals + class AnonymousFlag < PageObjects::Modals::Base + BODY_SELECTOR = ".anonymous-flag-modal__body" + end + end +end diff --git a/spec/system/page_objects/pages/topic.rb b/spec/system/page_objects/pages/topic.rb index 39461b13053..b8062d2a984 100644 --- a/spec/system/page_objects/pages/topic.rb +++ b/spec/system/page_objects/pages/topic.rb @@ -78,6 +78,10 @@ module PageObjects ".topic-post:not(.staged) #post_#{post_number}" end + def has_no_post_more_actions?(post) + within_post(post) { has_no_css?(".show-more-actions") } + end + def has_post_more_actions?(post) within_post(post) { has_css?(".show-more-actions") } end @@ -300,6 +304,10 @@ module PageObjects find(".modal.convert-to-public-topic") end + def has_no_flag_button? + has_no_css?(".post-action-menu__flag.create-flag") + end + def open_flag_topic_modal expect(page).to have_css(".flag-topic", wait: Capybara.default_max_wait_time * 3) find(".flag-topic").click