diff --git a/app/assets/javascripts/admin/addon/components/admin-watched-word.js b/app/assets/javascripts/admin/addon/components/admin-watched-word.js index 1dcee049eb7..8c5fd514966 100644 --- a/app/assets/javascripts/admin/addon/components/admin-watched-word.js +++ b/app/assets/javascripts/admin/addon/components/admin-watched-word.js @@ -9,6 +9,7 @@ export default Component.extend({ isReplace: equal("actionKey", "replace"), isTag: equal("actionKey", "tag"), + isLink: equal("actionKey", "link"), @discourseComputed("word.replacement") tags(replacement) { diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index 7730b2ada8b..89eb6bfe950 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -15,9 +15,16 @@ export default Component.extend({ formSubmitted: false, actionKey: null, showMessage: false, + selectedTags: null, canReplace: equal("actionKey", "replace"), canTag: equal("actionKey", "tag"), + canLink: equal("actionKey", "link"), + + didInsertElement() { + this._super(...arguments); + this.set("selectedTags", []); + }, @discourseComputed("siteSettings.watched_words_regular_expressions") placeholderKey(watchedWordsRegularExpressions) { @@ -47,6 +54,13 @@ export default Component.extend({ }, actions: { + changeSelectedTags(tags) { + this.setProperties({ + selectedTags: tags, + replacement: tags.join(","), + }); + }, + submit() { if (!this.isUniqueWord) { this.setProperties({ @@ -61,7 +75,10 @@ export default Component.extend({ const watchedWord = WatchedWord.create({ word: this.word, - replacement: this.canReplace || this.canTag ? this.replacement : null, + replacement: + this.canReplace || this.canTag || this.canLink + ? this.replacement + : null, action: this.actionKey, }); diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js index 3dfbd278b56..3ce120419c0 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js @@ -6,15 +6,17 @@ import { equal } from "@ember/object/computed"; export default Controller.extend(ModalFunctionality, { isReplace: equal("model.nameKey", "replace"), isTag: equal("model.nameKey", "tag"), + isLink: equal("model.nameKey", "link"), @discourseComputed( "value", "model.compiledRegularExpression", "model.words", "isReplace", - "isTag" + "isTag", + "isLink" ) - matches(value, regexpString, words, isReplace, isTag) { + matches(value, regexpString, words, isReplace, isTag, isLink) { if (!value || !regexpString) { return; } @@ -22,7 +24,7 @@ export default Controller.extend(ModalFunctionality, { const regexp = new RegExp(regexpString, "ig"); const matches = value.match(regexp) || []; - if (isReplace) { + if (isReplace || isLink) { return matches.map((match) => ({ match, replacement: words.find((word) => diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs index 26f8a9ebbda..53d61a9b97a 100644 --- a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs @@ -1,5 +1,5 @@ {{d-icon "times"}} {{word.word}} -{{#if isReplace}} +{{#if (or isReplace isLink)}} → {{word.replacement}} {{else if isTag}} → diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index 8d10aeb14da..be6727ca687 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -5,15 +5,29 @@ {{#if canReplace}}
{{i18n "admin.watched_words.test.found_matches"}}
test times
"); }); + test("watched words link", function (assert) { + const opts = { + watchedWordsLink: { fun: "https://discourse.org" }, + }; + + assert.cookedOptions( + "test fun", + opts, + 'test fun
' + ); + }); + test("watched words replace with bad regex", function (assert) { const maxMatches = 100; // same limit as MD watched-words-replace plugin const opts = { siteSettings: { watched_words_regular_expressions: true }, - watchedWordsReplacements: { "\\bu?\\b": "you" }, + watchedWordsReplace: { "\\bu?\\b": "you" }, }; assert.cookedOptions( diff --git a/app/assets/javascripts/pretty-text/addon/pretty-text.js b/app/assets/javascripts/pretty-text/addon/pretty-text.js index 2143505b514..fb2ced1b0c9 100644 --- a/app/assets/javascripts/pretty-text/addon/pretty-text.js +++ b/app/assets/javascripts/pretty-text/addon/pretty-text.js @@ -33,7 +33,8 @@ export function buildOptions(state) { censoredRegexp, disableEmojis, customEmojiTranslation, - watchedWordsReplacements, + watchedWordsReplace, + watchedWordsLink, } = state; let features = { @@ -83,7 +84,8 @@ export function buildOptions(state) { siteSettings.enable_advanced_editor_preview_sync, previewing, disableEmojis, - watchedWordsReplacements, + watchedWordsReplace, + watchedWordsLink, }; // note, this will mutate options due to the way the API is designed diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words-replace.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js similarity index 66% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words-replace.js rename to app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js index 0e3a42298c4..c8ad08a0c01 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words-replace.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js @@ -23,6 +23,7 @@ function findAllMatches(text, matchers) { index: match.index, text: match[0], replacement: matcher.replacement, + link: matcher.link, }); } }); @@ -32,19 +33,39 @@ function findAllMatches(text, matchers) { export function setup(helper) { helper.registerPlugin((md) => { - const replacements = md.options.discourse.watchedWordsReplacements; - if (!replacements) { + const matchers = []; + + if (md.options.discourse.watchedWordsReplace) { + Object.entries(md.options.discourse.watchedWordsReplace).map( + ([word, replacement]) => { + matchers.push({ + pattern: new RegExp(word, "gi"), + replacement, + link: false, + }); + } + ); + } + + if (md.options.discourse.watchedWordsLink) { + Object.entries(md.options.discourse.watchedWordsLink).map( + ([word, replacement]) => { + matchers.push({ + pattern: new RegExp(word, "gi"), + replacement, + link: true, + }); + } + ); + } + + if (matchers.length === 0) { return; } - const matchers = Object.keys(replacements).map((word) => ({ - pattern: new RegExp(word, "gi"), - replacement: replacements[word], - })); - const cache = {}; - md.core.ruler.push("watched-words-replace", (state) => { + md.core.ruler.push("watched-words", (state) => { for (let j = 0, l = state.tokens.length; j < l; j++) { if (state.tokens[j].type !== "inline") { continue; @@ -82,10 +103,6 @@ export function setup(helper) { } } - if (htmlLinkLevel > 0) { - continue; - } - if (currentToken.type === "text") { const text = currentToken.content; const matches = (cache[text] = @@ -109,25 +126,27 @@ export function setup(helper) { 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); + if (matches[ln].link) { + const url = state.md.normalizeLink(matches[ln].replacement); + if (htmlLinkLevel === 0 && state.md.validateLink(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("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); + 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; diff --git a/app/assets/stylesheets/common/admin/staff_logs.scss b/app/assets/stylesheets/common/admin/staff_logs.scss index 8b5c1e37cc6..ee59e164830 100644 --- a/app/assets/stylesheets/common/admin/staff_logs.scss +++ b/app/assets/stylesheets/common/admin/staff_logs.scss @@ -332,6 +332,19 @@ table.screened-ip-addresses { vertical-align: top; } +.watched-words-link { + .watched-word-box { + min-width: 100%; + } +} + +.watched-words-replace, +.watched-words-tag { + .watched-word-box { + min-width: calc(50% - 5px); + } +} + .watched-word-box, .watched-words-test-modal { .replacement { @@ -354,6 +367,7 @@ table.screened-ip-addresses { .watched-words-list { margin-top: 20px; display: inline-block; + width: 100%; } .watched-word { @@ -396,6 +410,9 @@ table.screened-ip-addresses { input.watched-word-input-field { min-width: 300px; } + .select-kit.multi-select.watched-word-input-field { + width: 300px; + } } // Search logs diff --git a/app/models/watched_word.rb b/app/models/watched_word.rb index 1a84b7fd5dc..ecc239ae675 100644 --- a/app/models/watched_word.rb +++ b/app/models/watched_word.rb @@ -8,9 +8,10 @@ class WatchedWord < ActiveRecord::Base censor: 2, require_approval: 3, flag: 4, + link: 8, replace: 5, tag: 6, - silence: 7 + silence: 7, ) end @@ -18,10 +19,16 @@ class WatchedWord < ActiveRecord::Base before_validation do self.word = self.class.normalize_word(self.word) + if self.action == WatchedWord.actions[:link] && !(self.replacement =~ /^https?:\/\//) + self.replacement = "#{Discourse.base_url}#{self.replacement.starts_with?("/") ? "" : "/"}#{self.replacement}" + end end validates :word, presence: true, uniqueness: true, length: { maximum: 100 } validates :action, presence: true + + validate :replacement_is_url, if: -> { action == WatchedWord.actions[:link] } + validates_each :word do |record, attr, val| if WatchedWord.where(action: record.action).count >= MAX_WORDS_PER_ACTION record.errors.add(:word, :too_many) @@ -37,6 +44,12 @@ class WatchedWord < ActiveRecord::Base w.strip.squeeze('*') end + def replacement_is_url + if !(replacement =~ URI::regexp) + errors.add(:base, :invalid_url) + end + end + 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) @@ -48,7 +61,7 @@ class WatchedWord < ActiveRecord::Base end def self.has_replacement?(action) - action == :replace || action == :tag + action == :replace || action == :tag || action == :link end def action_key=(arg) diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index acdf3e58670..40da58e355e 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -29,7 +29,8 @@ class SiteSerializer < ApplicationSerializer :censored_regexp, :shared_drafts_category_id, :custom_emoji_translation, - :watched_words_replace + :watched_words_replace, + :watched_words_link ) has_many :categories, serializer: SiteCategorySerializer, embed: :objects @@ -185,6 +186,10 @@ class SiteSerializer < ApplicationSerializer WordWatcher.word_matcher_regexps(:replace) end + def watched_words_link + WordWatcher.word_matcher_regexps(:link) + end + private def ordered_flags(flags) diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb index f1e4ddbf5b9..2ccc7541ced 100644 --- a/app/services/word_watcher.rb +++ b/app/services/word_watcher.rb @@ -8,7 +8,7 @@ class WordWatcher def self.words_for_action(action) words = WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000) - if action.to_sym == :replace || action.to_sym == :tag + if WatchedWord.has_replacement?(action.to_sym) words.pluck(:word, :replacement).to_h else words.pluck(:word) @@ -31,7 +31,7 @@ class WordWatcher def self.word_matcher_regexp(action, raise_errors: false) words = get_cached_words(action) if words - if action.to_sym == :replace || action.to_sym == :tag + if WatchedWord.has_replacement?(action.to_sym) words = words.keys end words = words.map do |w| diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 502f893e2d7..983f955b02a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4741,22 +4741,25 @@ en: replace: "Replace" tag: "Tag" silence: "Silence" + link: "Link" 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" + replace: "Replace words in posts with other words" tag: "Automatically tag topics based on first post" silence: "First posts of users containing these words will require approval by staff before they can be seen and the user will be automatically silenced." + link: "Replace words in posts with links" form: label: "Has word or phrase" placeholder: "Enter word or phrase (* is a wildcard)" placeholder_regexp: "regular expression" - replacement_label: "Replacement" - replacement_placeholder: "example or https://example.com" + replace_label: "Replacement" + replace_placeholder: "example" tag_label: "Tag" - tag_placeholder: "tag1,tag2,tag3" + link_label: "Link" + link_placeholder: "https://example.com" add: "Add" success: "Success" exists: "Already exists" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 9d50e661525..07c405f7abb 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -637,6 +637,8 @@ en: attributes: word: too_many: "Too many words for that action" + base: + invalid_url: "Replacement URL is invalid" uncategorized_category_name: "Uncategorized" diff --git a/db/migrate/20210528144647_migrate_watched_words_from_replace_to_link.rb b/db/migrate/20210528144647_migrate_watched_words_from_replace_to_link.rb new file mode 100644 index 00000000000..eb26ead0581 --- /dev/null +++ b/db/migrate/20210528144647_migrate_watched_words_from_replace_to_link.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class MigrateWatchedWordsFromReplaceToLink < ActiveRecord::Migration[6.1] + def up + execute <<~SQL + UPDATE watched_words + SET action = 8 + WHERE action = 5 AND replacement ILIKE 'http%' + SQL + end + + def down + execute("UPDATE watched_words SET action = 5 WHERE action = 8") + end +end diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 71f4308fd6b..cb897760c3c 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -173,7 +173,8 @@ module PrettyText __optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer; __optInput.lookupUploadUrls = __lookupUploadUrls; __optInput.censoredRegexp = #{WordWatcher.word_matcher_regexp(:censor)&.source.to_json}; - __optInput.watchedWordsReplacements = #{WordWatcher.word_matcher_regexps(:replace).to_json}; + __optInput.watchedWordsReplace = #{WordWatcher.word_matcher_regexps(:replace).to_json}; + __optInput.watchedWordsLink = #{WordWatcher.word_matcher_regexps(:link).to_json}; JS if opts[:topicId] diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 0436849fe1a..ae4ee4a9117 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -1403,7 +1403,7 @@ HTML end end - describe "watched words - replace" do + describe "watched words - replace & link" do after(:all) { Discourse.redis.flushdb } it "replaces words with other words" do @@ -1423,7 +1423,7 @@ HTML end it "replaces words with links" do - Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "meta", replacement: "https://meta.discourse.org") + Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org") expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML)@@ -1446,14 +1446,14 @@ HTML 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") + Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org") + Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "iz", replacement: "is") + Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "discourse", replacement: "https://discourse.org") - expect(PrettyText.cook("Meta is a Discourse forum")).to match_html(<<~HTML) + expect(PrettyText.cook("Meta iz a Discourse forum")).to match_html(<<~HTML)