Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/assets/stylesheets/admin/staff_logs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,13 @@ table.screened-ip-addresses {
}

// Watched words
.watched-word-regex-errors {
max-height: 200px;
overflow-y: auto;
list-style-type: disc;
padding-left: 20px;
}

.watched-word-box {
display: inline-block;
width: 250px;
Expand Down
16 changes: 14 additions & 2 deletions app/services/word_watcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
r = word_to_regexp(word, match_word: SiteSetting.watched_words_regular_expressions?)
begin
r if Regexp.new(r)
rescue RegexpError
rescue RegexpError => e
raise if raise_errors
Rails.logger.warn(
"Watched word '#{word}' has invalid regex '#{r}' for #{action}: #{e.message}",
)
nil
end
end
.select { |r| r.present? }
Expand All @@ -96,7 +100,15 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
) if !SiteSetting.watched_words_regular_expressions?

# Add case insensitive flag if needed
Regexp.new(regexp, group_key == :case_sensitive ? nil : Regexp::IGNORECASE)
begin
Regexp.new(regexp, group_key == :case_sensitive ? nil : Regexp::IGNORECASE)
rescue RegexpError => e
raise if raise_errors
Rails.logger.warn(
"Watched word compilation failed for #{action} (#{group_key}): #{e.message}. Regexp: #{regexp}",
)
nil
end
end
.compact
end
Expand Down
1 change: 1 addition & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7750,6 +7750,7 @@ en:
clear_all: Clear all
clear_all_confirm: "Are you sure you want to clear all watched words for the %{action} action?"
invalid_regex: 'The watched word "%{word}" is an invalid regular expression.'
invalid_regex_multiple: "Invalid Regular Expressions:"
regex_warning: '<a href="%{basePath}/admin/site_settings/category/all_results?filter=watched%20words%20regular%20expressions%20">Watched words are regular expressions</a> and they do not automatically include word boundaries. If you want the regular expression to match whole words, include <code>\b</code> at the start and end of your regular expression.'
actions:
block: "Block"
Expand Down
140 changes: 98 additions & 42 deletions frontend/discourse/admin/components/modal/watched-word-testing.gjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { cached, tracked } from "@glimmer/tracking";
import { Textarea } from "@ember/component";
import DModal from "discourse/components/d-modal";
import { or } from "discourse/truth-helpers";
Expand All @@ -20,70 +20,126 @@ export default class WatchedWordTesting extends Component {
return this.args.model.watchedWord.nameKey === "link";
}

get matches() {
if (
!this.value ||
this.args.model.watchedWord.compiledRegularExpression.length === 0
) {
return [];
cleanErrorMessage(message) {
const parts = message.split(": ");
return parts[parts.length - 1];
}

@cached
get matchesAndErrors() {
const errors = {};

const addError = (word, message) => {
errors[word] ??= this.cleanErrorMessage(message);
};

const errorsToArray = () =>
Object.entries(errors).map(([word, error]) => ({ word, error }));

if (!this.value) {
return { matches: [], errors: [] };
}

if (this.isReplace || this.isLink) {
const matches = [];
this.args.model.watchedWord.words.forEach((word) => {
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;

while ((match = regexp.exec(this.value)) !== null) {
matches.push({
match: match[1],
replacement: word.replacement,
});
try {
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;

while ((match = regexp.exec(this.value)) !== null) {
matches.push({
match: match[1],
replacement: word.replacement,
});
}
} catch (e) {
addError(word.word, e.message);
}
});
return matches;
return { matches, errors: errorsToArray() };
}

if (this.isTag) {
const matches = new Map();
this.args.model.watchedWord.words.forEach((word) => {
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;

while ((match = regexp.exec(this.value)) !== null) {
if (!matches.has(match[1])) {
matches.set(match[1], new Set());
try {
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;

while ((match = regexp.exec(this.value)) !== null) {
if (!matches.has(match[1])) {
matches.set(match[1], new Set());
}

const tags = matches.get(match[1]);
word.replacement.split(",").forEach((tag) => tags.add(tag));
}

const tags = matches.get(match[1]);
word.replacement.split(",").forEach((tag) => tags.add(tag));
} catch (e) {
addError(word.word, e.message);
}
});

return Array.from(matches, ([match, tagsSet]) => ({
match,
tags: Array.from(tagsSet),
}));
return {
matches: Array.from(matches, ([match, tagsSet]) => ({
match,
tags: Array.from(tagsSet),
})),
errors: errorsToArray(),
};
}

let matches = [];
let hasCompiledExpressionError = false;

this.args.model.watchedWord.compiledRegularExpression.forEach((entry) => {
const [regexp, options] = Object.entries(entry)[0];
const wordRegexp = new RegExp(
regexp,
options.case_sensitive ? "gu" : "gui"
);
try {
const [regexp, options] = Object.entries(entry)[0];
const wordRegexp = new RegExp(
regexp,
options.case_sensitive ? "gu" : "gui"
);

matches.push(...(this.value.match(wordRegexp) || []));
matches.push(...(this.value.match(wordRegexp) || []));
} catch {
hasCompiledExpressionError = true;
}
});

return matches;
if (hasCompiledExpressionError) {
matches = [];
this.args.model.watchedWord.words.forEach((word) => {
try {
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;

while ((match = regexp.exec(this.value)) !== null) {
matches.push(match[1] || match[0]);
}
} catch (e) {
addError(word.word, e.message);
}
});
}

return { matches, errors: errorsToArray() };
}

get matches() {
return this.matchesAndErrors.matches;
}

get regexErrors() {
return this.matchesAndErrors.errors;
}

<template>
Expand Down
33 changes: 27 additions & 6 deletions frontend/discourse/admin/controllers/admin-watched-words/action.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { tracked } from "@glimmer/tracking";
import { cached, tracked } from "@glimmer/tracking";
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { or } from "@ember/object/computed";
Expand Down Expand Up @@ -34,14 +34,35 @@ export default class AdminWatchedWordsActionController extends Controller {
);
}

get regexpError() {
for (const { regexp, word } of this.currentAction.words) {
@cached
get regexpErrors() {
const errors = [];
const seen = new Set();

if (!this.currentAction?.words) {
return errors;
}

for (const { regexp, word, id } of this.currentAction.words) {
if (!regexp) {
continue;
}

try {
RegExp(regexp);
} catch {
return i18n("admin.watched_words.invalid_regex", { word });
// eslint-disable-next-line no-new
new RegExp(regexp, "u");
} catch (e) {
const key = `${id}:${word}:${e.message}`;
if (!seen.has(key)) {
seen.add(key);
const parts = e.message.split(": ");
const cleanError = parts[parts.length - 1];
errors.push({ word, error: cleanError });
}
}
}

return errors;
}

get actionDescription() {
Expand Down
14 changes: 12 additions & 2 deletions frontend/discourse/admin/templates/admin-watched-words/action.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ import basePath from "discourse/helpers/base-path";
import { i18n } from "discourse-i18n";

export default <template>
{{#if @controller.regexpError}}
<div class="alert alert-error">{{@controller.regexpError}}</div>
{{#if @controller.regexpErrors.length}}
<div class="alert alert-error">
<strong>{{i18n "admin.watched_words.invalid_regex_multiple"}}</strong>
<ul class="watched-word-regex-errors">
{{#each @controller.regexpErrors as |error|}}
<li>
<strong>{{error.word}}</strong>:
{{error.error}}
</li>
{{/each}}
</ul>
</div>
{{/if}}

<div class="watched-word-controls">
Expand Down
Loading