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:
parent
a9a93b15ec
commit
533800a87b
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1 +1 @@
|
|||
{{xIcon}}{{watchedWord}}
|
||||
{{d-icon "times"}} {{word.word}} {{#if word.replacement}}→ {{word.replacement}}{{/if}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
{{watched-word-form
|
||||
actionKey=actionNameKey
|
||||
action=(action "recordAdded")
|
||||
filteredContent=filteredContent
|
||||
regularExpressions=adminWatchedWords.regularExpressions}}
|
||||
|
||||
<div class="download-upload-controls">
|
||||
<div class="download">
|
||||
<div class="watched-word-controls">
|
||||
{{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")}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="show-words-checkbox">
|
||||
{{input type="checkbox" checked=adminWatchedWords.showWords disabled=adminWatchedWords.disableShowWords}}
|
||||
{{i18n "admin.watched_words.show_words"}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="watched-words-list">
|
||||
{{#if showWordsList}}
|
||||
{{#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>
|
||||
|
||||
<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" count=wordCount}}
|
||||
</label>
|
||||
{{/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}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -21,6 +21,7 @@ function getOpts(opts) {
|
|||
customEmojiTranslation: context.site.custom_emoji_translation,
|
||||
siteSettings: context.siteSettings,
|
||||
formatUsername,
|
||||
watchedWordsReplacements: context.site.watched_words_replace,
|
||||
},
|
||||
opts
|
||||
);
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -329,28 +329,13 @@ table.screened-ip-addresses {
|
|||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-watched-words {
|
||||
.clear-all-row {
|
||||
.watched-word-container {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
justify-content: flex-end;
|
||||
.clear-all {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
.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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
@ -62,6 +64,7 @@ end
|
|||
# action :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# replacement :string
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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>')
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue