Skip to content

Commit f07137e

Browse files
authored
FIX: silent failure when watched words contains invalid regex (#36132)
If a watched words list contains any invalid regex, the test modal won't work because the "compiled" regex (that contains all the watched words) will be invalid. This PR introduces a fallback to matching individual watched words when the compiled regex is invalid as well as displaying the error for each invalid regex. <img width="1549" height="1359" alt="Screenshot 2025-11-20 at 10 34 00" src="https://github.com/user-attachments/assets/ed781d12-bc78-4d8c-98ac-bd49448a9718" /> https://github.com/user-attachments/assets/f9200ca9-eda7-4592-8a8e-437ab4a13953 Internal ref - t/168206
1 parent 239f505 commit f07137e

File tree

8 files changed

+399
-52
lines changed

8 files changed

+399
-52
lines changed

app/assets/stylesheets/admin/staff_logs.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ table.screened-ip-addresses {
307307
}
308308

309309
// Watched words
310+
.watched-word-regex-errors {
311+
max-height: 200px;
312+
overflow-y: auto;
313+
list-style-type: disc;
314+
padding-left: 20px;
315+
}
316+
310317
.watched-word-box {
311318
display: inline-block;
312319
width: 250px;

app/services/word_watcher.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
7979
r = word_to_regexp(word, match_word: SiteSetting.watched_words_regular_expressions?)
8080
begin
8181
r if Regexp.new(r)
82-
rescue RegexpError
82+
rescue RegexpError => e
8383
raise if raise_errors
84+
Rails.logger.warn(
85+
"Watched word '#{word}' has invalid regex '#{r}' for #{action}: #{e.message}",
86+
)
87+
nil
8488
end
8589
end
8690
.select { |r| r.present? }
@@ -96,7 +100,15 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
96100
) if !SiteSetting.watched_words_regular_expressions?
97101

98102
# Add case insensitive flag if needed
99-
Regexp.new(regexp, group_key == :case_sensitive ? nil : Regexp::IGNORECASE)
103+
begin
104+
Regexp.new(regexp, group_key == :case_sensitive ? nil : Regexp::IGNORECASE)
105+
rescue RegexpError => e
106+
raise if raise_errors
107+
Rails.logger.warn(
108+
"Watched word compilation failed for #{action} (#{group_key}): #{e.message}. Regexp: #{regexp}",
109+
)
110+
nil
111+
end
100112
end
101113
.compact
102114
end

config/locales/client.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7750,6 +7750,7 @@ en:
77507750
clear_all: Clear all
77517751
clear_all_confirm: "Are you sure you want to clear all watched words for the %{action} action?"
77527752
invalid_regex: 'The watched word "%{word}" is an invalid regular expression.'
7753+
invalid_regex_multiple: "Invalid Regular Expressions:"
77537754
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.'
77547755
actions:
77557756
block: "Block"

frontend/discourse/admin/components/modal/watched-word-testing.gjs

Lines changed: 98 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Component from "@glimmer/component";
2-
import { tracked } from "@glimmer/tracking";
2+
import { cached, tracked } from "@glimmer/tracking";
33
import { Textarea } from "@ember/component";
44
import DModal from "discourse/components/d-modal";
55
import { or } from "discourse/truth-helpers";
@@ -20,70 +20,126 @@ export default class WatchedWordTesting extends Component {
2020
return this.args.model.watchedWord.nameKey === "link";
2121
}
2222

23-
get matches() {
24-
if (
25-
!this.value ||
26-
this.args.model.watchedWord.compiledRegularExpression.length === 0
27-
) {
28-
return [];
23+
cleanErrorMessage(message) {
24+
const parts = message.split(": ");
25+
return parts[parts.length - 1];
26+
}
27+
28+
@cached
29+
get matchesAndErrors() {
30+
const errors = {};
31+
32+
const addError = (word, message) => {
33+
errors[word] ??= this.cleanErrorMessage(message);
34+
};
35+
36+
const errorsToArray = () =>
37+
Object.entries(errors).map(([word, error]) => ({ word, error }));
38+
39+
if (!this.value) {
40+
return { matches: [], errors: [] };
2941
}
3042

3143
if (this.isReplace || this.isLink) {
3244
const matches = [];
3345
this.args.model.watchedWord.words.forEach((word) => {
34-
const regexp = new RegExp(
35-
word.regexp,
36-
word.case_sensitive ? "gu" : "gui"
37-
);
38-
let match;
39-
40-
while ((match = regexp.exec(this.value)) !== null) {
41-
matches.push({
42-
match: match[1],
43-
replacement: word.replacement,
44-
});
46+
try {
47+
const regexp = new RegExp(
48+
word.regexp,
49+
word.case_sensitive ? "gu" : "gui"
50+
);
51+
let match;
52+
53+
while ((match = regexp.exec(this.value)) !== null) {
54+
matches.push({
55+
match: match[1],
56+
replacement: word.replacement,
57+
});
58+
}
59+
} catch (e) {
60+
addError(word.word, e.message);
4561
}
4662
});
47-
return matches;
63+
return { matches, errors: errorsToArray() };
4864
}
4965

5066
if (this.isTag) {
5167
const matches = new Map();
5268
this.args.model.watchedWord.words.forEach((word) => {
53-
const regexp = new RegExp(
54-
word.regexp,
55-
word.case_sensitive ? "gu" : "gui"
56-
);
57-
let match;
58-
59-
while ((match = regexp.exec(this.value)) !== null) {
60-
if (!matches.has(match[1])) {
61-
matches.set(match[1], new Set());
69+
try {
70+
const regexp = new RegExp(
71+
word.regexp,
72+
word.case_sensitive ? "gu" : "gui"
73+
);
74+
let match;
75+
76+
while ((match = regexp.exec(this.value)) !== null) {
77+
if (!matches.has(match[1])) {
78+
matches.set(match[1], new Set());
79+
}
80+
81+
const tags = matches.get(match[1]);
82+
word.replacement.split(",").forEach((tag) => tags.add(tag));
6283
}
63-
64-
const tags = matches.get(match[1]);
65-
word.replacement.split(",").forEach((tag) => tags.add(tag));
84+
} catch (e) {
85+
addError(word.word, e.message);
6686
}
6787
});
6888

69-
return Array.from(matches, ([match, tagsSet]) => ({
70-
match,
71-
tags: Array.from(tagsSet),
72-
}));
89+
return {
90+
matches: Array.from(matches, ([match, tagsSet]) => ({
91+
match,
92+
tags: Array.from(tagsSet),
93+
})),
94+
errors: errorsToArray(),
95+
};
7396
}
7497

7598
let matches = [];
99+
let hasCompiledExpressionError = false;
100+
76101
this.args.model.watchedWord.compiledRegularExpression.forEach((entry) => {
77-
const [regexp, options] = Object.entries(entry)[0];
78-
const wordRegexp = new RegExp(
79-
regexp,
80-
options.case_sensitive ? "gu" : "gui"
81-
);
102+
try {
103+
const [regexp, options] = Object.entries(entry)[0];
104+
const wordRegexp = new RegExp(
105+
regexp,
106+
options.case_sensitive ? "gu" : "gui"
107+
);
82108

83-
matches.push(...(this.value.match(wordRegexp) || []));
109+
matches.push(...(this.value.match(wordRegexp) || []));
110+
} catch {
111+
hasCompiledExpressionError = true;
112+
}
84113
});
85114

86-
return matches;
115+
if (hasCompiledExpressionError) {
116+
matches = [];
117+
this.args.model.watchedWord.words.forEach((word) => {
118+
try {
119+
const regexp = new RegExp(
120+
word.regexp,
121+
word.case_sensitive ? "gu" : "gui"
122+
);
123+
let match;
124+
125+
while ((match = regexp.exec(this.value)) !== null) {
126+
matches.push(match[1] || match[0]);
127+
}
128+
} catch (e) {
129+
addError(word.word, e.message);
130+
}
131+
});
132+
}
133+
134+
return { matches, errors: errorsToArray() };
135+
}
136+
137+
get matches() {
138+
return this.matchesAndErrors.matches;
139+
}
140+
141+
get regexErrors() {
142+
return this.matchesAndErrors.errors;
87143
}
88144

89145
<template>

frontend/discourse/admin/controllers/admin-watched-words/action.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { tracked } from "@glimmer/tracking";
1+
import { cached, tracked } from "@glimmer/tracking";
22
import Controller, { inject as controller } from "@ember/controller";
33
import { action } from "@ember/object";
44
import { or } from "@ember/object/computed";
@@ -34,14 +34,35 @@ export default class AdminWatchedWordsActionController extends Controller {
3434
);
3535
}
3636

37-
get regexpError() {
38-
for (const { regexp, word } of this.currentAction.words) {
37+
@cached
38+
get regexpErrors() {
39+
const errors = [];
40+
const seen = new Set();
41+
42+
if (!this.currentAction?.words) {
43+
return errors;
44+
}
45+
46+
for (const { regexp, word, id } of this.currentAction.words) {
47+
if (!regexp) {
48+
continue;
49+
}
50+
3951
try {
40-
RegExp(regexp);
41-
} catch {
42-
return i18n("admin.watched_words.invalid_regex", { word });
52+
// eslint-disable-next-line no-new
53+
new RegExp(regexp, "u");
54+
} catch (e) {
55+
const key = `${id}:${word}:${e.message}`;
56+
if (!seen.has(key)) {
57+
seen.add(key);
58+
const parts = e.message.split(": ");
59+
const cleanError = parts[parts.length - 1];
60+
errors.push({ word, error: cleanError });
61+
}
4362
}
4463
}
64+
65+
return errors;
4566
}
4667

4768
get actionDescription() {

frontend/discourse/admin/templates/admin-watched-words/action.gjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,18 @@ import basePath from "discourse/helpers/base-path";
88
import { i18n } from "discourse-i18n";
99

1010
export default <template>
11-
{{#if @controller.regexpError}}
12-
<div class="alert alert-error">{{@controller.regexpError}}</div>
11+
{{#if @controller.regexpErrors.length}}
12+
<div class="alert alert-error">
13+
<strong>{{i18n "admin.watched_words.invalid_regex_multiple"}}</strong>
14+
<ul class="watched-word-regex-errors">
15+
{{#each @controller.regexpErrors as |error|}}
16+
<li>
17+
<strong>{{error.word}}</strong>:
18+
{{error.error}}
19+
</li>
20+
{{/each}}
21+
</ul>
22+
</div>
1323
{{/if}}
1424

1525
<div class="watched-word-controls">

0 commit comments

Comments
 (0)