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.
This commit is contained in:
Krzysztof Kotlarek 2025-01-17 08:57:44 +11:00 committed by GitHub
parent d3a7b99699
commit 029bd6feda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 231 additions and 22 deletions

View File

@ -42,6 +42,7 @@
<input <input
id="radio_{{this.flag.name_key}}" id="radio_{{this.flag.name_key}}"
{{on "click" (fn this.changePostActionType this.flag)}} {{on "click" (fn this.changePostActionType this.flag)}}
checked={{this.selected}}
type="radio" type="radio"
name="post_action_type_index" name="post_action_type_index"
/> />

View File

@ -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;
}
<template>
<DModal
@title={{i18n "anonymous_flagging.title"}}
@closeModal={{@closeModal}}
@bodyClass="anonymous-flag-modal__body"
class="anonymous-flag-modal"
>
<:body>
{{htmlSafe this.description}}
</:body>
</DModal>
</template>
}

View File

@ -29,6 +29,10 @@ export default class Flag extends Component {
this.adminTools this.adminTools
?.checkSpammer(this.args.model.flagModel.user_id) ?.checkSpammer(this.args.model.flagModel.user_id)
.then((result) => (this.spammerDetails = result)); .then((result) => (this.spammerDetails = result));
if (this.flagsAvailable.length === 1) {
this.selected = this.flagsAvailable[0];
}
} }
get flagActions() { get flagActions() {

View File

@ -6,9 +6,21 @@ import concatClass from "discourse/helpers/concat-class";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
export default class PostMenuFlagButton extends Component { export default class PostMenuFlagButton extends Component {
static shouldRender(args) { static shouldRender(args, helper) {
const { reviewable_id, canFlag, hidden } = args.post; 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 @action
@ -36,7 +48,7 @@ export default class PostMenuFlagButton extends Component {
@action={{@buttonActions.showFlags}} @action={{@buttonActions.showFlags}}
@icon="flag" @icon="flag"
@label={{if @showLabel "post.controls.flag_action"}} @label={{if @showLabel "post.controls.flag_action"}}
@title="post.controls.flag" @title={{this.title}}
/> />
</div> </div>
</template> </template>

View File

@ -3,6 +3,7 @@ import { cancel, schedule } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import AddPmParticipants from "discourse/components/modal/add-pm-participants"; 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 ChangeOwnerModal from "discourse/components/modal/change-owner";
import ChangeTimestampModal from "discourse/components/modal/change-timestamp"; import ChangeTimestampModal from "discourse/components/modal/change-timestamp";
import EditSlowModeModal from "discourse/components/modal/edit-slow-mode"; import EditSlowModeModal from "discourse/components/modal/edit-slow-mode";
@ -27,6 +28,7 @@ const SCROLL_DELAY = 500;
export default class TopicRoute extends DiscourseRoute { export default class TopicRoute extends DiscourseRoute {
@service composer; @service composer;
@service screenTrack; @service screenTrack;
@service currentUser;
@service modal; @service modal;
@service router; @service router;
@ -104,7 +106,7 @@ export default class TopicRoute extends DiscourseRoute {
@action @action
showFlags(model) { showFlags(model) {
this.modal.show(FlagModal, { this.modal.show(this.currentUser ? FlagModal : AnonymousFlagModal, {
model: { model: {
flagTarget: new PostFlag(), flagTarget: new PostFlag(),
flagModel: model, flagModel: model,

View File

@ -211,20 +211,30 @@ registerButton("flag-count", (attrs) => {
}; };
}); });
registerButton("flag", (attrs) => { registerButton(
if (attrs.reviewableId || (attrs.canFlag && !attrs.hidden)) { "flag",
let button = { (attrs, _state, siteSettings, _postMenuSettings, currentUser) => {
action: "showFlags", if (
title: "post.controls.flag", attrs.reviewableId ||
icon: "flag", (attrs.canFlag && !attrs.hidden) ||
className: "create-flag", (siteSettings.allow_tl0_and_anonymous_users_to_flag_illegal_content &&
}; !currentUser)
if (attrs.reviewableId) { ) {
button.before = "flag-count"; 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) => { registerButton("edit", (attrs) => {
if (attrs.canEdit) { if (attrs.canEdit) {

View File

@ -3894,6 +3894,7 @@ en:
edit_anonymous: "Sorry, but you need to be logged in to edit this post." edit_anonymous: "Sorry, but you need to be logged in to edit this post."
flag_action: "Flag" flag_action: "Flag"
flag: "privately flag this post for attention or send a personal message about it" 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_action: "Delete post"
delete: "delete this post" delete: "delete this post"
undelete_action: "Undelete post" undelete_action: "Undelete post"
@ -4211,6 +4212,10 @@ en:
none: "no subcategories" none: "no subcategories"
colors_disabled: "You cant select colors because you have a category style of none." colors_disabled: "You cant select colors because you have a category style of none."
anonymous_flagging:
title: "Report illegal content"
description: "To report illegal content, please contact <a href='mailto:%{email}?subject=Illegal content: %{topic_title}&body=This post %{url} contains illegal content.'>%{email}</a>"
flagging: flagging:
title: "Thanks for keeping our community civil!" title: "Thanks for keeping our community civil!"
action: "Flag Post" action: "Flag Post"

View File

@ -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}." 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." 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." 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 <br><a href="https://meta.discourse.org/t/76575">https://meta.discourse.org/t/76575</a>' 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 <br><a href="https://meta.discourse.org/t/76575">https://meta.discourse.org/t/76575</a>'
onebox: 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:" 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_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." 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" 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" 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?" slow_mode_prevents_editing: "Does 'Slow Mode' prevent editing, after editing_grace_period?"

View File

@ -54,6 +54,7 @@ required:
default: "" default: ""
type: email type: email
area: "about|notifications|legal" area: "about|notifications|legal"
client: true
contact_url: contact_url:
default: "" default: ""
area: "about|legal" area: "about|legal"
@ -1938,6 +1939,14 @@ trust:
allow_any: false allow_any: false
refresh: true refresh: true
area: "group_permissions" 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: min_trust_to_post_links:
default: 0 default: 0
enum: "TrustLevelSetting" enum: "TrustLevelSetting"

View File

@ -94,6 +94,10 @@ module PostGuardian
post.topic.private_message? 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 a flagging action, and haven't done it already
not(is_flag || already_taken_this_action) && not(is_flag || already_taken_this_action) &&
# nothing except flagging on archived topics # nothing except flagging on archived topics

View File

@ -267,6 +267,16 @@ module SiteSettings::Validations
validate_error :twitter_summary_large_image_no_svg validate_error :twitter_summary_large_image_no_svg
end 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 private
def validate_bucket_setting(setting_name, upload_bucket, backup_bucket) def validate_bucket_setting(setting_name, upload_bucket, backup_bucket)

View File

@ -163,6 +163,16 @@ RSpec.describe Guardian do
Flag.reset_flag_settings! Flag.reset_flag_settings!
end 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 it "works as expected for silenced users" do
UserSilencer.silence(user, admin) UserSilencer.silence(user, admin)

View File

@ -474,4 +474,19 @@ RSpec.describe SiteSettings::Validations do
expect { validations.validate_twitter_summary_large_image(nil) }.not_to raise_error expect { validations.validate_twitter_summary_large_image(nil) }.not_to raise_error
end end
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 end

View File

@ -71,6 +71,8 @@ RSpec.describe Admin::SiteSettingsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["site_settings"].map { |s| s["setting"] }).to match_array( expect(response.parsed_body["site_settings"].map { |s| s["setting"] }).to match_array(
%w[ %w[
allow_tl0_and_anonymous_users_to_flag_illegal_content
email_address_to_report_illegal_content
silence_new_user_sensitivity silence_new_user_sensitivity
num_users_to_silence_new_user num_users_to_silence_new_user
flag_sockpuppets flag_sockpuppets

View File

@ -164,6 +164,8 @@ describe "Admin Flags Page", type: :system do
admin_flags_page.click_tab("settings") admin_flags_page.click_tab("settings")
expect(page.all(".setting-label h3").map(&:text).map(&:downcase)).to eq( 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", "silence new user sensitivity",
"num users to silence new user", "num users to silence new user",
"flag sockpuppets", "flag sockpuppets",

View File

@ -2,21 +2,23 @@
describe "Flagging post", type: :system do describe "Flagging post", type: :system do
fab!(:current_user) { Fabricate(:admin) } fab!(:current_user) { Fabricate(:admin) }
fab!(:first_post) { Fabricate(:post) } fab!(:category)
fab!(:post_to_flag) { Fabricate(:post, topic: first_post.topic) } 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(:topic_page) { PageObjects::Pages::Topic.new }
let(:flag_modal) { PageObjects::Modals::Flag.new } let(:flag_modal) { PageObjects::Modals::Flag.new }
before { sign_in(current_user) }
describe "Using Take Action" do 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 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 = Fabricate(:flag_post_action, post: post_to_flag, user: Fabricate(:moderator))
other_flag_reviewable = other_flag_reviewable =
Fabricate(:reviewable_flagged_post, target: post_to_flag, created_by: other_flag.user) Fabricate(:reviewable_flagged_post, target: post_to_flag, created_by: other_flag.user)
expect(other_flag.reload.agreed_at).to be_nil 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.expand_post_actions(post_to_flag)
topic_page.click_post_action_button(post_to_flag, :flag) topic_page.click_post_action_button(post_to_flag, :flag)
flag_modal.choose_type(:off_topic) flag_modal.choose_type(:off_topic)
@ -34,8 +36,10 @@ describe "Flagging post", type: :system do
end end
describe "As Illegal" do describe "As Illegal" do
before { sign_in(current_user) }
it do 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.expand_post_actions(post_to_flag)
topic_page.click_post_action_button(post_to_flag, :flag) topic_page.click_post_action_button(post_to_flag, :flag)
flag_modal.choose_type(:illegal) 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")) expect(page).to have_content(I18n.t("js.post.actions.by_you.illegal"))
end end
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 end

View File

@ -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

View File

@ -78,6 +78,10 @@ module PageObjects
".topic-post:not(.staged) #post_#{post_number}" ".topic-post:not(.staged) #post_#{post_number}"
end end
def has_no_post_more_actions?(post)
within_post(post) { has_no_css?(".show-more-actions") }
end
def has_post_more_actions?(post) def has_post_more_actions?(post)
within_post(post) { has_css?(".show-more-actions") } within_post(post) { has_css?(".show-more-actions") }
end end
@ -300,6 +304,10 @@ module PageObjects
find(".modal.convert-to-public-topic") find(".modal.convert-to-public-topic")
end end
def has_no_flag_button?
has_no_css?(".post-action-menu__flag.create-flag")
end
def open_flag_topic_modal def open_flag_topic_modal
expect(page).to have_css(".flag-topic", wait: Capybara.default_max_wait_time * 3) expect(page).to have_css(".flag-topic", wait: Capybara.default_max_wait_time * 3)
find(".flag-topic").click find(".flag-topic").click