Add watched words of type "replace" (#12020)

This commit includes other various improvements to watched words.

auto_silence_first_post_regex site setting was removed because it overlapped
with 'require approval' watched words.
This commit is contained in:
Bianca Nenciu 2021-02-25 14:00:58 +02:00 committed by GitHub
parent a9a93b15ec
commit 533800a87b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 384 additions and 154 deletions

View File

@ -1,17 +1,9 @@
import Component from "@ember/component";
import I18n from "I18n";
import bootbox from "bootbox";
import { iconHTML } from "discourse-common/lib/icon-library";
export default Component.extend({
classNames: ["watched-word"],
watchedWord: null,
xIcon: iconHTML("times").htmlSafe(),
init() {
this._super(...arguments);
this.set("watchedWord", this.get("word.word"));
},
click() {
this.word

View File

@ -15,6 +15,11 @@ export default Component.extend({
actionKey: null,
showMessage: false,
@discourseComputed("actionKey")
canReplace(actionKey) {
return actionKey === "replace";
},
@discourseComputed("regularExpressions")
placeholderKey(regularExpressions) {
return (
@ -56,6 +61,7 @@ export default Component.extend({
const watchedWord = WatchedWord.create({
word: this.word,
replacement: this.canReplace ? this.replacement : null,
action: this.actionKey,
});
@ -64,6 +70,7 @@ export default Component.extend({
.then((result) => {
this.setProperties({
word: "",
replacement: "",
formSubmitted: false,
showMessage: true,
message: I18n.t("admin.watched_words.form.success"),

View File

@ -8,7 +8,11 @@ const WatchedWord = EmberObject.extend({
"/admin/logs/watched_words" + (this.id ? "/" + this.id : "") + ".json",
{
type: this.id ? "PUT" : "POST",
data: { word: this.word, action_key: this.action },
data: {
word: this.word,
replacement: this.replacement,
action_key: this.action,
},
dataType: "json",
}
);

View File

@ -1 +1 @@
{{xIcon}}{{watchedWord}}
{{d-icon "times"}} {{word.word}} {{#if word.replacement}}→ {{word.replacement}}{{/if}}

View File

@ -1,5 +1,15 @@
<b>{{i18n "admin.watched_words.form.label"}}</b>
{{text-field value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey=placeholderKey title=(i18n placeholderKey)}}
<div class="watched-word-input">
<label for="watched-word">{{i18n "admin.watched_words.form.label"}}</label>
{{text-field id="watched-word" value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey=placeholderKey title=(i18n placeholderKey)}}
</div>
{{#if canReplace}}
<div class="watched-word-input">
<label for="watched-replacement">{{i18n "admin.watched_words.form.replacement_label"}}</label>
{{text-field id="watched-replacement" value=replacement disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.replacement_placeholder"}}
</div>
{{/if}}
{{d-button class="btn-default" action=(action "submit") disabled=formSubmitted label="admin.watched_words.form.add"}}
{{#if showMessage}}

View File

@ -3,4 +3,3 @@
{{i18n "admin.watched_words.form.upload"}}
<input class="hidden-upload-field" disabled={{addDisabled}} type="file" accept="text/plain">
</label>
<span class="instructions">{{i18n "admin.watched_words.one_word_per_line"}}</span>

View File

@ -1,50 +1,47 @@
<h2>{{model.name}}</h2>
<div class="watched-word-container">
<h2>{{model.name}}</h2>
<p class="about">{{actionDescription}}</p>
<div class="watched-word-controls">
{{d-button
class="btn-default download-link"
href=downloadLink
icon="download"
label="admin.watched_words.download"}}
<div class="watched-word-controls">
{{watched-word-form
actionKey=actionNameKey
action=(action "recordAdded")
filteredContent=filteredContent
regularExpressions=adminWatchedWords.regularExpressions}}
<div class="download-upload-controls">
<div class="download">
{{d-button
class="btn-default download-link"
href=downloadLink
icon="download"
label="admin.watched_words.download"}}
</div>
{{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}}
{{d-button
label="admin.watched_words.test.button_label"
icon="far-eye"
action=(action "test")}}
{{d-button
class="btn-danger clear-all"
label="admin.watched_words.clear_all"
icon="trash-alt"
action=(action "clearAll")}}
</div>
</div>
<div>
<p class="about">{{actionDescription}}</p>
{{watched-word-form
actionKey=actionNameKey
action=(action "recordAdded")
filteredContent=filteredContent
regularExpressions=adminWatchedWords.regularExpressions}}
{{#if wordCount}}
<label class="show-words-checkbox">
{{input type="checkbox" checked=adminWatchedWords.showWords disabled=adminWatchedWords.disableShowWords}}
{{i18n "admin.watched_words.show_words"}}
{{i18n "admin.watched_words.show_words" count=wordCount}}
</label>
</div>
<div class="watched-words-list">
{{#if showWordsList}}
{{/if}}
{{#if showWordsList}}
<div class="watched-words-list">
{{#each filteredContent as |word| }}
<div class="watched-word-box">{{admin-watched-word word=word action=(action "recordRemoved")}}</div>
{{/each}}
{{else}}
{{i18n "admin.watched_words.word_count" count=wordCount}}
{{/if}}
</div>
<div class="clear-all-row">
{{d-button
label="admin.watched_words.test.button_label"
icon="far-eye"
action=(action "test")}}
{{d-button
class="btn-danger clear-all"
label="admin.watched_words.clear_all"
icon="trash-alt"
action=(action "clearAll")}}
</div>
</div>
{{/if}}

View File

@ -21,6 +21,7 @@ function getOpts(opts) {
customEmojiTranslation: context.site.custom_emoji_translation,
siteSettings: context.siteSettings,
formatUsername,
watchedWordsReplacements: context.site.watched_words_replace,
},
opts
);

View File

@ -12,7 +12,11 @@ acceptance("Admin - Watched Words", function (needs) {
test("list words in groups", async function (assert) {
await visit("/admin/logs/watched_words/action/block");
assert.ok(exists(".watched-words-list"));
assert.ok(
!exists(".watched-words-list"),
"Don't show bad words by default."
);
assert.ok(
!exists(".watched-words-list .watched-word"),
"Don't show bad words by default."

View File

@ -33,6 +33,7 @@ export function buildOptions(state) {
censoredRegexp,
disableEmojis,
customEmojiTranslation,
watchedWordsReplacements,
} = state;
let features = {
@ -82,6 +83,7 @@ export function buildOptions(state) {
siteSettings.enable_advanced_editor_preview_sync,
previewing,
disableEmojis,
watchedWordsReplacements,
};
// note, this will mutate options due to the way the API is designed

View File

@ -0,0 +1,179 @@
function isLinkOpen(str) {
return /^<a[>\s]/i.test(str);
}
function isLinkClose(str) {
return /^<\/a\s*>/i.test(str);
}
function findAllMatches(text, matchers, useRegExp) {
const matches = [];
if (useRegExp) {
matchers.forEach((matcher) => {
let match;
while ((match = matcher.pattern.exec(text)) !== null) {
matches.push({
index: match.index,
text: match[0],
replacement: matcher.replacement,
});
}
});
} else {
const lowerText = text.toLowerCase();
matchers.forEach((matcher) => {
const lowerPattern = matcher.pattern.toLowerCase();
let index = -1;
while ((index = lowerText.indexOf(lowerPattern, index + 1)) !== -1) {
matches.push({
index,
text: text.substr(index, lowerPattern.length),
replacement: matcher.replacement,
});
}
});
}
return matches.sort((a, b) => a.index - b.index);
}
export function setup(helper) {
helper.registerOptions((opts, siteSettings) => {
opts.watchedWordsRegularExpressions =
siteSettings.watched_words_regular_expressions;
});
helper.registerPlugin((md) => {
const replacements = md.options.discourse.watchedWordsReplacements;
if (!replacements) {
return;
}
const matchers = Object.keys(replacements).map((word) => ({
pattern: md.options.discourse.watchedWordsRegularExpressions
? new RegExp(word, "gi")
: word,
replacement: replacements[word],
}));
const cache = {};
md.core.ruler.push("watched-words-replace", (state) => {
for (let j = 0, l = state.tokens.length; j < l; j++) {
if (state.tokens[j].type !== "inline") {
continue;
}
let tokens = state.tokens[j].children;
let htmlLinkLevel = 0;
// We scan from the end, to keep position when new tags added.
// Use reversed logic in links start/end match
for (let i = tokens.length - 1; i >= 0; i--) {
const currentToken = tokens[i];
// Skip content of markdown links
if (currentToken.type === "link_close") {
i--;
while (
tokens[i].level !== currentToken.level &&
tokens[i].type !== "link_open"
) {
i--;
}
continue;
}
// Skip content of html tag links
if (currentToken.type === "html_inline") {
if (isLinkOpen(currentToken.content) && htmlLinkLevel > 0) {
htmlLinkLevel--;
}
if (isLinkClose(currentToken.content)) {
htmlLinkLevel++;
}
}
if (htmlLinkLevel > 0) {
continue;
}
if (currentToken.type === "text") {
const text = currentToken.content;
const matches = (cache[text] =
cache[text] ||
findAllMatches(
text,
matchers,
md.options.discourse.watchedWordsRegularExpressions
));
// Now split string to nodes
const nodes = [];
let level = currentToken.level;
let lastPos = 0;
let token;
for (let ln = 0; ln < matches.length; ln++) {
if (matches[ln].index < lastPos) {
continue;
}
if (matches[ln].index > lastPos) {
token = new state.Token("text", "", 0);
token.content = text.slice(lastPos, matches[ln].index);
token.level = level;
nodes.push(token);
}
let url = state.md.normalizeLink(matches[ln].replacement);
if (state.md.validateLink(url) && /^https?/.test(url)) {
token = new state.Token("link_open", "a", 1);
token.attrs = [["href", url]];
token.level = level++;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
token = new state.Token("text", "", 0);
token.content = matches[ln].text;
token.level = level;
nodes.push(token);
token = new state.Token("link_close", "a", -1);
token.level = --level;
token.markup = "linkify";
token.info = "auto";
nodes.push(token);
} else {
token = new state.Token("text", "", 0);
token.content = matches[ln].replacement;
token.level = level;
nodes.push(token);
}
lastPos = matches[ln].index + matches[ln].text.length;
}
if (lastPos < text.length) {
token = new state.Token("text", "", 0);
token.content = text.slice(lastPos);
token.level = level;
nodes.push(token);
}
// replace current node
state.tokens[j].children = tokens = md.utils.arrayReplaceAt(
tokens,
i,
nodes
);
}
}
}
});
});
}

View File

@ -329,28 +329,13 @@ table.screened-ip-addresses {
vertical-align: top;
}
.admin-watched-words {
.clear-all-row {
display: flex;
margin-top: 10px;
justify-content: flex-end;
.clear-all {
margin-left: 5px;
}
}
.watched-word-container {
display: flex;
justify-content: space-between;
}
.watched-word-controls {
display: flex;
flex-wrap: wrap;
margin-bottom: 1em;
justify-content: space-between;
.download-upload-controls {
display: flex;
}
.download {
justify-content: flex-end;
}
.watched-words-uploader {
display: inline-block;
}
.watched-words-list {
@ -361,47 +346,39 @@ table.screened-ip-addresses {
.watched-word {
display: inline-block;
cursor: pointer;
.d-icon {
margin-right: 0.25em;
color: var(--primary-medium);
}
&:hover .d-icon {
color: var(--danger);
}
}
.watched-word-form {
display: inline-block;
.success-message {
margin-left: 1em;
}
}
.watched-words-uploader {
margin-left: 5px;
display: flex;
flex-direction: column;
align-items: flex-end;
@media screen and (max-width: 500px) {
flex: 1 1 100%;
margin-top: 0.5em;
}
.instructions {
font-size: $font-down-1;
margin-top: 5px;
}
}
.watched-words-detail {
.about {
margin: 0.5em 0 1em 0;
}
.watched-words-detail .about,
.watched-word-form {
margin: 0.5em 0 1em 0;
}
.watched-words-test-modal p {
margin-top: 0;
}
.watched-word-input {
label {
display: inline-block;
min-width: 120px;
}
}
// Search logs
table.search-logs-list {

View File

@ -69,7 +69,7 @@ class Admin::WatchedWordsController < Admin::AdminController
private
def watched_words_params
params.permit(:id, :word, :action_key)
params.permit(:id, :word, :replacement, :action_key)
end
end

View File

@ -7,7 +7,8 @@ class WatchedWord < ActiveRecord::Base
block: 1,
censor: 2,
require_approval: 3,
flag: 4
flag: 4,
replace: 5
)
end
@ -37,6 +38,7 @@ class WatchedWord < ActiveRecord::Base
def self.create_or_update_word(params)
new_word = normalize_word(params[:word])
w = WatchedWord.where("word ILIKE ?", new_word).first || WatchedWord.new(word: new_word)
w.replacement = params[:replacement] if params[:replacement]
w.action_key = params[:action_key] if params[:action_key]
w.action = params[:action] if params[:action]
w.save
@ -57,11 +59,12 @@ end
#
# Table name: watched_words
#
# id :integer not null, primary key
# word :string not null
# action :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# word :string not null
# action :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# replacement :string
#
# Indexes
#

View File

@ -28,7 +28,8 @@ class SiteSerializer < ApplicationSerializer
:default_dark_color_scheme,
:censored_regexp,
:shared_drafts_category_id,
:custom_emoji_translation
:custom_emoji_translation,
:watched_words_replace
)
has_many :categories, serializer: SiteCategorySerializer, embed: :objects
@ -175,6 +176,10 @@ class SiteSerializer < ApplicationSerializer
scope.can_see_shared_draft?
end
def watched_words_replace
WordWatcher.get_cached_words(:replace)
end
private
def ordered_flags(flags)

View File

@ -1,9 +1,13 @@
# frozen_string_literal: true
class WatchedWordSerializer < ApplicationSerializer
attributes :id, :word, :action
attributes :id, :word, :replacement, :action
def action
WatchedWord.actions[object.action]
end
def include_replacement?
action == :replace
end
end

View File

@ -7,7 +7,12 @@ class WordWatcher
end
def self.words_for_action(action)
WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000).pluck(:word)
words = WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000)
if action.to_sym == :replace
words.pluck(:word, :replacement).to_h
else
words.pluck(:word)
end
end
def self.words_for_action_exists?(action)
@ -26,6 +31,9 @@ class WordWatcher
def self.word_matcher_regexp(action, raise_errors: false)
words = get_cached_words(action)
if words
if action.to_sym == :replace
words = words.keys
end
words = words.map do |w|
word = word_to_regexp(w)
word = "(#{word})" if SiteSetting.watched_words_regular_expressions?

View File

@ -4603,31 +4603,33 @@ en:
title: "Watched Words"
search: "search"
clear_filter: "Clear"
show_words: "show words"
one_word_per_line: "One word per line"
show_words:
one: "show %{count} word"
other: "show %{count} words"
download: Download
clear_all: Clear All
clear_all_confirm_block: "Are you sure you want to clear all watched words for the Block action?"
clear_all_confirm_censor: "Are you sure you want to clear all watched words for the Censor action?"
clear_all_confirm_flag: "Are you sure you want to clear all watched words for the Flag action?"
clear_all_confirm_require_approval: "Are you sure you want to clear all watched words for the Require Approval action?"
word_count:
one: "%{count} word"
other: "%{count} words"
actions:
block: "Block"
censor: "Censor"
require_approval: "Require Approval"
flag: "Flag"
replace: "Replace"
action_descriptions:
block: "Prevent posts containing these words from being posted. The user will see an error message when they try to submit their post."
censor: "Allow posts containing these words, but replace them with characters that hide the censored words."
require_approval: "Posts containing these words will require approval by staff before they can be seen."
flag: "Allow posts containing these words, but flag them as inappropriate so moderators can review them."
replace: "Replace words in posts with other words or links"
form:
label: "New Word:"
label: "New Word"
placeholder: "full word or * as wildcard"
placeholder_regexp: "regular expression"
replacement_label: "Replacement"
replacement_placeholder: "example or https://example.com"
add: "Add"
success: "Success"
exists: "Already exists"

View File

@ -1943,7 +1943,6 @@ en:
min_first_post_typing_time: "Minimum amount of time in milliseconds a user must type during first post, if threshold is not met post will automatically enter the needs approval queue. Set to 0 to disable (not recommended)"
auto_silence_fast_typers_on_first_post: "Automatically silence users that do not meet min_first_post_typing_time"
auto_silence_fast_typers_max_trust_level: "Maximum trust level to auto silence fast typers"
auto_silence_first_post_regex: "Case insensitive regex that if passed will cause first post by user to be silenced and sent to approval queue. Example: raging|a[bc]a , will cause all posts containing raging or aba or aca to be silenced on first. Only applies to first post."
reviewable_claiming: "Does reviewable content need to be claimed before it can be acted upon?"
reviewable_default_topics: "Show reviewable content grouped by topic by default"
reviewable_default_visibility: "Don't show reviewable items unless they meet this priority"
@ -4905,7 +4904,6 @@ en:
trust_level: "Users at low trust levels must have replies approved by staff. See `approve_unless_trust_level`."
new_topics_unless_trust_level: "Users at low trust levels must have topics approved by staff. See `approve_new_topics_unless_trust_level`."
fast_typer: "New user typed their first post suspiciously fast, suspected bot or spammer behavior. See `min_first_post_typing_time`."
auto_silence_regexp: "New user whose first post matches the `auto_silence_first_post_regex` setting."
watched_word: "This post included a Watched Word. See your <a href='%{base_url}/admin/logs/watched_words'>list of watched words</a>."
staged: "New topics and posts for staged users must be approved by staff. See `approve_unless_staged`."
category: "Posts in this category require manual approval by staff. See the category settings."

View File

@ -1660,7 +1660,6 @@ spam:
min_first_post_typing_time: 3000
auto_silence_fast_typers_on_first_post: true
auto_silence_fast_typers_max_trust_level: 0
auto_silence_first_post_regex: ""
high_trust_flaggers_auto_hide_posts: true
cooldown_hours_until_reflag:
default: 24

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class MoveAutoSilenceFirstPostRegexToWatchedWords < ActiveRecord::Migration[6.0]
def up
execute <<~SQL
INSERT INTO watched_words (word, action, created_at, updated_at)
SELECT value, 3, created_at, updated_at
FROM site_settings
WHERE name = 'auto_silence_first_post_regex'
ON CONFLICT DO NOTHING
SQL
execute <<~SQL
INSERT INTO watched_words (word, action, created_at, updated_at)
SELECT unnest(string_to_array(value, '|')), 3, created_at, updated_at
FROM site_settings
WHERE name = 'auto_silence_first_post_regex'
ON CONFLICT DO NOTHING
SQL
end
def down
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddReplacementToWatchedWords < ActiveRecord::Migration[6.0]
def change
add_column :watched_words, :replacement, :string, null: true
end
end

View File

@ -54,25 +54,6 @@ class NewPostManager
manager.user.trust_level <= SiteSetting.auto_silence_fast_typers_max_trust_level
end
def self.matches_auto_silence_regex?(manager)
args = manager.args
pattern = SiteSetting.auto_silence_first_post_regex
return false unless pattern.present?
return false unless is_first_post?(manager)
begin
regex = Regexp.new(pattern, Regexp::IGNORECASE)
rescue => e
Rails.logger.warn "Invalid regex in auto_silence_first_post_regex #{e}"
return false
end
"#{args[:title]} #{args[:raw]}" =~ regex
end
def self.exempt_user?(user)
user.staff?
end
@ -102,8 +83,6 @@ class NewPostManager
return :fast_typer if is_fast_typer?(manager)
return :auto_silence_regex if matches_auto_silence_regex?(manager)
return :staged if SiteSetting.approve_unless_staged? && user.staged?
return :category if post_needs_approval_in_its_category?(manager)
@ -168,8 +147,6 @@ class NewPostManager
I18n.with_locale(SiteSetting.default_locale) do
if is_fast_typer?(manager)
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.new_user_typed_too_fast"))
elsif matches_auto_silence_regex?(manager)
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.content_matches_auto_silence_regex"))
elsif reason == :email_spam && is_first_post?(manager)
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.email_in_spam_header"))
end

View File

@ -172,6 +172,7 @@ module PrettyText
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
__optInput.lookupUploadUrls = __lookupUploadUrls;
__optInput.censoredRegexp = #{WordWatcher.word_matcher_regexp(:censor)&.source.to_json};
__optInput.watchedWordsReplacements = #{WordWatcher.get_cached_words(:replace).to_json};
JS
if opts[:topicId]

View File

@ -1351,6 +1351,56 @@ HTML
end
end
describe "watched words - replace" do
after(:all) { Discourse.redis.flushdb }
it "replaces words with other words" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "dolor sit", replacement: "something else")
expect(PrettyText.cook("Lorem ipsum dolor sit amet")).to match_html(<<~HTML)
<p>Lorem ipsum something else amet</p>
HTML
end
it "replaces words with links" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "meta", replacement: "https://meta.discourse.org")
expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML)
<p>
<a href=\"https://meta.discourse.org\" rel=\"noopener nofollow ugc\">Meta</a>
is a Discourse forum
</p>
HTML
end
it "works with regex" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "f.o", replacement: "test")
expect(PrettyText.cook("foo")).to match_html("<p>foo</p>")
expect(PrettyText.cook("f.o")).to match_html("<p>test</p>")
SiteSetting.watched_words_regular_expressions = true
expect(PrettyText.cook("foo")).to match_html("<p>test</p>")
expect(PrettyText.cook("f.o")).to match_html("<p>test</p>")
end
it "supports overlapping words" do
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "discourse", replacement: "https://discourse.org")
Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "is", replacement: "https://example.com")
expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML)
<p>
Meta
<a href="https://example.com" rel="noopener nofollow ugc">is</a>
a
<a href="https://discourse.org" rel="noopener nofollow ugc">Discourse</a>
forum
</p>
HTML
end
end
it 'supports typographer' do
SiteSetting.enable_markdown_typographer = true
expect(PrettyText.cook('(tm)')).to eq('<p>™</p>')

View File

@ -857,26 +857,6 @@ describe PostsController do
end
end
it 'silences correctly based on auto_silence_first_post_regex' do
SiteSetting.auto_silence_first_post_regex = "I love candy|i eat s[1-5]"
post "/posts.json", params: {
raw: 'this is the test content',
title: 'when I eat s3 sometimes when not looking'
}
expect(response.status).to eq(200)
parsed = response.parsed_body
expect(parsed["action"]).to eq("enqueued")
reviewable = ReviewableQueuedPost.find_by(created_by: user)
score = reviewable.reviewable_scores.first
expect(score.reason).to eq('auto_silence_regex')
user.reload
expect(user).to be_silenced
end
it "can send a message to a group" do
group = Group.create(name: 'test_group', messageable_level: Group::ALIAS_LEVELS[:nobody])
user1 = user