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 Component from "@ember/component";
|
||||||
import I18n from "I18n";
|
import I18n from "I18n";
|
||||||
import bootbox from "bootbox";
|
import bootbox from "bootbox";
|
||||||
import { iconHTML } from "discourse-common/lib/icon-library";
|
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
classNames: ["watched-word"],
|
classNames: ["watched-word"],
|
||||||
watchedWord: null,
|
|
||||||
xIcon: iconHTML("times").htmlSafe(),
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this._super(...arguments);
|
|
||||||
this.set("watchedWord", this.get("word.word"));
|
|
||||||
},
|
|
||||||
|
|
||||||
click() {
|
click() {
|
||||||
this.word
|
this.word
|
||||||
|
|
|
@ -15,6 +15,11 @@ export default Component.extend({
|
||||||
actionKey: null,
|
actionKey: null,
|
||||||
showMessage: false,
|
showMessage: false,
|
||||||
|
|
||||||
|
@discourseComputed("actionKey")
|
||||||
|
canReplace(actionKey) {
|
||||||
|
return actionKey === "replace";
|
||||||
|
},
|
||||||
|
|
||||||
@discourseComputed("regularExpressions")
|
@discourseComputed("regularExpressions")
|
||||||
placeholderKey(regularExpressions) {
|
placeholderKey(regularExpressions) {
|
||||||
return (
|
return (
|
||||||
|
@ -56,6 +61,7 @@ export default Component.extend({
|
||||||
|
|
||||||
const watchedWord = WatchedWord.create({
|
const watchedWord = WatchedWord.create({
|
||||||
word: this.word,
|
word: this.word,
|
||||||
|
replacement: this.canReplace ? this.replacement : null,
|
||||||
action: this.actionKey,
|
action: this.actionKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,6 +70,7 @@ export default Component.extend({
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
word: "",
|
word: "",
|
||||||
|
replacement: "",
|
||||||
formSubmitted: false,
|
formSubmitted: false,
|
||||||
showMessage: true,
|
showMessage: true,
|
||||||
message: I18n.t("admin.watched_words.form.success"),
|
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",
|
"/admin/logs/watched_words" + (this.id ? "/" + this.id : "") + ".json",
|
||||||
{
|
{
|
||||||
type: this.id ? "PUT" : "POST",
|
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",
|
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>
|
<div class="watched-word-input">
|
||||||
{{text-field value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey=placeholderKey title=(i18n placeholderKey)}}
|
<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"}}
|
{{d-button class="btn-default" action=(action "submit") disabled=formSubmitted label="admin.watched_words.form.add"}}
|
||||||
|
|
||||||
{{#if showMessage}}
|
{{#if showMessage}}
|
||||||
|
|
|
@ -3,4 +3,3 @@
|
||||||
{{i18n "admin.watched_words.form.upload"}}
|
{{i18n "admin.watched_words.form.upload"}}
|
||||||
<input class="hidden-upload-field" disabled={{addDisabled}} type="file" accept="text/plain">
|
<input class="hidden-upload-field" disabled={{addDisabled}} type="file" accept="text/plain">
|
||||||
</label>
|
</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">
|
||||||
|
|
||||||
<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
|
{{d-button
|
||||||
class="btn-default download-link"
|
class="btn-default download-link"
|
||||||
href=downloadLink
|
href=downloadLink
|
||||||
icon="download"
|
icon="download"
|
||||||
label="admin.watched_words.download"}}
|
label="admin.watched_words.download"}}
|
||||||
</div>
|
|
||||||
{{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}}
|
{{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
|
{{d-button
|
||||||
label="admin.watched_words.test.button_label"
|
label="admin.watched_words.test.button_label"
|
||||||
icon="far-eye"
|
icon="far-eye"
|
||||||
action=(action "test")}}
|
action=(action "test")}}
|
||||||
|
|
||||||
{{d-button
|
{{d-button
|
||||||
class="btn-danger clear-all"
|
class="btn-danger clear-all"
|
||||||
label="admin.watched_words.clear_all"
|
label="admin.watched_words.clear_all"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
action=(action "clearAll")}}
|
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" 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,
|
customEmojiTranslation: context.site.custom_emoji_translation,
|
||||||
siteSettings: context.siteSettings,
|
siteSettings: context.siteSettings,
|
||||||
formatUsername,
|
formatUsername,
|
||||||
|
watchedWordsReplacements: context.site.watched_words_replace,
|
||||||
},
|
},
|
||||||
opts
|
opts
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,7 +12,11 @@ acceptance("Admin - Watched Words", function (needs) {
|
||||||
test("list words in groups", async function (assert) {
|
test("list words in groups", async function (assert) {
|
||||||
await visit("/admin/logs/watched_words/action/block");
|
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(
|
assert.ok(
|
||||||
!exists(".watched-words-list .watched-word"),
|
!exists(".watched-words-list .watched-word"),
|
||||||
"Don't show bad words by default."
|
"Don't show bad words by default."
|
||||||
|
|
|
@ -33,6 +33,7 @@ export function buildOptions(state) {
|
||||||
censoredRegexp,
|
censoredRegexp,
|
||||||
disableEmojis,
|
disableEmojis,
|
||||||
customEmojiTranslation,
|
customEmojiTranslation,
|
||||||
|
watchedWordsReplacements,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
let features = {
|
let features = {
|
||||||
|
@ -82,6 +83,7 @@ export function buildOptions(state) {
|
||||||
siteSettings.enable_advanced_editor_preview_sync,
|
siteSettings.enable_advanced_editor_preview_sync,
|
||||||
previewing,
|
previewing,
|
||||||
disableEmojis,
|
disableEmojis,
|
||||||
|
watchedWordsReplacements,
|
||||||
};
|
};
|
||||||
|
|
||||||
// note, this will mutate options due to the way the API is designed
|
// 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;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-watched-words {
|
.watched-word-container {
|
||||||
.clear-all-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 10px;
|
justify-content: space-between;
|
||||||
justify-content: flex-end;
|
|
||||||
.clear-all {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.watched-word-controls {
|
.watched-words-uploader {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
justify-content: space-between;
|
|
||||||
.download-upload-controls {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.download {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.watched-words-list {
|
.watched-words-list {
|
||||||
|
@ -361,47 +346,39 @@ table.screened-ip-addresses {
|
||||||
.watched-word {
|
.watched-word {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.d-icon {
|
.d-icon {
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25em;
|
||||||
color: var(--primary-medium);
|
color: var(--primary-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .d-icon {
|
&:hover .d-icon {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.watched-word-form {
|
.watched-word-form {
|
||||||
display: inline-block;
|
|
||||||
.success-message {
|
.success-message {
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.watched-words-uploader {
|
.watched-words-detail .about,
|
||||||
margin-left: 5px;
|
.watched-word-form {
|
||||||
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;
|
margin: 0.5em 0 1em 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.watched-words-test-modal p {
|
.watched-words-test-modal p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watched-word-input {
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search logs
|
// Search logs
|
||||||
|
|
||||||
table.search-logs-list {
|
table.search-logs-list {
|
||||||
|
|
|
@ -69,7 +69,7 @@ class Admin::WatchedWordsController < Admin::AdminController
|
||||||
private
|
private
|
||||||
|
|
||||||
def watched_words_params
|
def watched_words_params
|
||||||
params.permit(:id, :word, :action_key)
|
params.permit(:id, :word, :replacement, :action_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,8 @@ class WatchedWord < ActiveRecord::Base
|
||||||
block: 1,
|
block: 1,
|
||||||
censor: 2,
|
censor: 2,
|
||||||
require_approval: 3,
|
require_approval: 3,
|
||||||
flag: 4
|
flag: 4,
|
||||||
|
replace: 5
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -37,6 +38,7 @@ class WatchedWord < ActiveRecord::Base
|
||||||
def self.create_or_update_word(params)
|
def self.create_or_update_word(params)
|
||||||
new_word = normalize_word(params[:word])
|
new_word = normalize_word(params[:word])
|
||||||
w = WatchedWord.where("word ILIKE ?", new_word).first || WatchedWord.new(word: new_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_key = params[:action_key] if params[:action_key]
|
||||||
w.action = params[:action] if params[:action]
|
w.action = params[:action] if params[:action]
|
||||||
w.save
|
w.save
|
||||||
|
@ -62,6 +64,7 @@ end
|
||||||
# action :integer not null
|
# action :integer not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
# replacement :string
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -28,7 +28,8 @@ class SiteSerializer < ApplicationSerializer
|
||||||
:default_dark_color_scheme,
|
:default_dark_color_scheme,
|
||||||
:censored_regexp,
|
:censored_regexp,
|
||||||
:shared_drafts_category_id,
|
:shared_drafts_category_id,
|
||||||
:custom_emoji_translation
|
:custom_emoji_translation,
|
||||||
|
:watched_words_replace
|
||||||
)
|
)
|
||||||
|
|
||||||
has_many :categories, serializer: SiteCategorySerializer, embed: :objects
|
has_many :categories, serializer: SiteCategorySerializer, embed: :objects
|
||||||
|
@ -175,6 +176,10 @@ class SiteSerializer < ApplicationSerializer
|
||||||
scope.can_see_shared_draft?
|
scope.can_see_shared_draft?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def watched_words_replace
|
||||||
|
WordWatcher.get_cached_words(:replace)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ordered_flags(flags)
|
def ordered_flags(flags)
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class WatchedWordSerializer < ApplicationSerializer
|
class WatchedWordSerializer < ApplicationSerializer
|
||||||
attributes :id, :word, :action
|
attributes :id, :word, :replacement, :action
|
||||||
|
|
||||||
def action
|
def action
|
||||||
WatchedWord.actions[object.action]
|
WatchedWord.actions[object.action]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def include_replacement?
|
||||||
|
action == :replace
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,12 @@ class WordWatcher
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.words_for_action(action)
|
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
|
end
|
||||||
|
|
||||||
def self.words_for_action_exists?(action)
|
def self.words_for_action_exists?(action)
|
||||||
|
@ -26,6 +31,9 @@ class WordWatcher
|
||||||
def self.word_matcher_regexp(action, raise_errors: false)
|
def self.word_matcher_regexp(action, raise_errors: false)
|
||||||
words = get_cached_words(action)
|
words = get_cached_words(action)
|
||||||
if words
|
if words
|
||||||
|
if action.to_sym == :replace
|
||||||
|
words = words.keys
|
||||||
|
end
|
||||||
words = words.map do |w|
|
words = words.map do |w|
|
||||||
word = word_to_regexp(w)
|
word = word_to_regexp(w)
|
||||||
word = "(#{word})" if SiteSetting.watched_words_regular_expressions?
|
word = "(#{word})" if SiteSetting.watched_words_regular_expressions?
|
||||||
|
|
|
@ -4603,31 +4603,33 @@ en:
|
||||||
title: "Watched Words"
|
title: "Watched Words"
|
||||||
search: "search"
|
search: "search"
|
||||||
clear_filter: "Clear"
|
clear_filter: "Clear"
|
||||||
show_words: "show words"
|
show_words:
|
||||||
one_word_per_line: "One word per line"
|
one: "show %{count} word"
|
||||||
|
other: "show %{count} words"
|
||||||
download: Download
|
download: Download
|
||||||
clear_all: Clear All
|
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_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_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_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?"
|
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:
|
actions:
|
||||||
block: "Block"
|
block: "Block"
|
||||||
censor: "Censor"
|
censor: "Censor"
|
||||||
require_approval: "Require Approval"
|
require_approval: "Require Approval"
|
||||||
flag: "Flag"
|
flag: "Flag"
|
||||||
|
replace: "Replace"
|
||||||
action_descriptions:
|
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."
|
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."
|
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."
|
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."
|
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:
|
form:
|
||||||
label: "New Word:"
|
label: "New Word"
|
||||||
placeholder: "full word or * as wildcard"
|
placeholder: "full word or * as wildcard"
|
||||||
placeholder_regexp: "regular expression"
|
placeholder_regexp: "regular expression"
|
||||||
|
replacement_label: "Replacement"
|
||||||
|
replacement_placeholder: "example or https://example.com"
|
||||||
add: "Add"
|
add: "Add"
|
||||||
success: "Success"
|
success: "Success"
|
||||||
exists: "Already exists"
|
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)"
|
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_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_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_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_topics: "Show reviewable content grouped by topic by default"
|
||||||
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"
|
||||||
|
@ -4905,7 +4904,6 @@ en:
|
||||||
trust_level: "Users at low trust levels must have replies approved by staff. See `approve_unless_trust_level`."
|
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`."
|
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`."
|
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>."
|
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`."
|
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."
|
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
|
min_first_post_typing_time: 3000
|
||||||
auto_silence_fast_typers_on_first_post: true
|
auto_silence_fast_typers_on_first_post: true
|
||||||
auto_silence_fast_typers_max_trust_level: 0
|
auto_silence_fast_typers_max_trust_level: 0
|
||||||
auto_silence_first_post_regex: ""
|
|
||||||
high_trust_flaggers_auto_hide_posts: true
|
high_trust_flaggers_auto_hide_posts: true
|
||||||
cooldown_hours_until_reflag:
|
cooldown_hours_until_reflag:
|
||||||
default: 24
|
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
|
manager.user.trust_level <= SiteSetting.auto_silence_fast_typers_max_trust_level
|
||||||
end
|
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)
|
def self.exempt_user?(user)
|
||||||
user.staff?
|
user.staff?
|
||||||
end
|
end
|
||||||
|
@ -102,8 +83,6 @@ class NewPostManager
|
||||||
|
|
||||||
return :fast_typer if is_fast_typer?(manager)
|
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 :staged if SiteSetting.approve_unless_staged? && user.staged?
|
||||||
|
|
||||||
return :category if post_needs_approval_in_its_category?(manager)
|
return :category if post_needs_approval_in_its_category?(manager)
|
||||||
|
@ -168,8 +147,6 @@ class NewPostManager
|
||||||
I18n.with_locale(SiteSetting.default_locale) do
|
I18n.with_locale(SiteSetting.default_locale) do
|
||||||
if is_fast_typer?(manager)
|
if is_fast_typer?(manager)
|
||||||
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.new_user_typed_too_fast"))
|
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)
|
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"))
|
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.email_in_spam_header"))
|
||||||
end
|
end
|
||||||
|
|
|
@ -172,6 +172,7 @@ module PrettyText
|
||||||
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
|
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
|
||||||
__optInput.lookupUploadUrls = __lookupUploadUrls;
|
__optInput.lookupUploadUrls = __lookupUploadUrls;
|
||||||
__optInput.censoredRegexp = #{WordWatcher.word_matcher_regexp(:censor)&.source.to_json};
|
__optInput.censoredRegexp = #{WordWatcher.word_matcher_regexp(:censor)&.source.to_json};
|
||||||
|
__optInput.watchedWordsReplacements = #{WordWatcher.get_cached_words(:replace).to_json};
|
||||||
JS
|
JS
|
||||||
|
|
||||||
if opts[:topicId]
|
if opts[:topicId]
|
||||||
|
|
|
@ -1351,6 +1351,56 @@ HTML
|
||||||
end
|
end
|
||||||
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
|
it 'supports typographer' do
|
||||||
SiteSetting.enable_markdown_typographer = true
|
SiteSetting.enable_markdown_typographer = true
|
||||||
expect(PrettyText.cook('(tm)')).to eq('<p>™</p>')
|
expect(PrettyText.cook('(tm)')).to eq('<p>™</p>')
|
||||||
|
|
|
@ -857,26 +857,6 @@ describe PostsController do
|
||||||
end
|
end
|
||||||
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
|
it "can send a message to a group" do
|
||||||
group = Group.create(name: 'test_group', messageable_level: Group::ALIAS_LEVELS[:nobody])
|
group = Group.create(name: 'test_group', messageable_level: Group::ALIAS_LEVELS[:nobody])
|
||||||
user1 = user
|
user1 = user
|
||||||
|
|
Loading…
Reference in New Issue