FEATURE: Watched Words: when posts contain words, do one of flag, require approval, censor, or block

This commit is contained in:
Neil Lalonde 2017-06-28 16:56:44 -04:00
parent 9d774a951a
commit 24cb950432
49 changed files with 1096 additions and 37 deletions

View File

@ -0,0 +1,19 @@
import { iconHTML } from 'discourse-common/helpers/fa-icon';
import { bufferedRender } from 'discourse-common/lib/buffered-render';
export default Ember.Component.extend(bufferedRender({
classNames: ['watched-word'],
buildBuffer(buffer) {
buffer.push(iconHTML('times'));
buffer.push(' ' + this.get('word.word'));
},
click() {
this.get('word').destroy().then(() => {
this.sendAction('action', this.get('word'));
}).catch(e => {
bootbox.alert(I18n.t("generic_error_with_reason", {error: "http: " + e.status + " - " + e.body}));
});;
}
}));

View File

@ -0,0 +1,49 @@
import WatchedWord from 'admin/models/watched-word';
import { on, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ['watched-word-form'],
formSubmitted: false,
actionKey: null,
showSuccessMessage: false,
@observes('word')
removeSuccessMessage() {
if (this.get('showSuccessMessage') && !Ember.isEmpty(this.get('word'))) {
this.set('showSuccessMessage', false);
}
},
actions: {
submit() {
if (!this.get('formSubmitted')) {
this.set('formSubmitted', true);
const watchedWord = WatchedWord.create({ word: this.get('word'), action: this.get('actionKey') });
watchedWord.save().then(result => {
this.setProperties({ word: '', formSubmitted: false, showSuccessMessage: true });
this.sendAction('action', WatchedWord.create(result));
Ember.run.schedule('afterRender', () => this.$('.watched-word-input').focus());
}).catch(e => {
this.set('formSubmitted', false);
const msg = (e.responseJSON && e.responseJSON.errors) ?
I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')}) :
I18n.t("generic_error");
bootbox.alert(msg, () => this.$('.watched-word-input').focus());
});
}
}
},
@on("didInsertElement")
_init() {
Ember.run.schedule('afterRender', () => {
this.$('.watched-word-input').keydown(e => {
if (e.keyCode === 13) {
this.send('submit');
}
});
});
}
});

View File

@ -0,0 +1,25 @@
import computed from "ember-addons/ember-computed-decorators";
import UploadMixin from "discourse/mixins/upload";
export default Em.Component.extend(UploadMixin, {
type: 'csv',
classNames: 'watched-words-uploader',
uploadUrl: '/admin/watched_words/upload',
addDisabled: Em.computed.alias("uploading"),
validateUploadedFilesOptions() {
return { csvOnly: true };
},
@computed('actionKey')
data(actionKey) {
return { action_key: actionKey };
},
uploadDone() {
if (this) {
bootbox.alert(I18n.t("admin.watched_words.form.upload_successful"));
this.sendAction("done");
}
}
});

View File

@ -0,0 +1,65 @@
import computed from 'ember-addons/ember-computed-decorators';
import WatchedWord from 'admin/models/watched-word';
export default Ember.Controller.extend({
actionNameKey: null,
adminWatchedWords: Ember.inject.controller(),
showWordsList: Ember.computed.or('adminWatchedWords.filtered', 'adminWatchedWords.showWords'),
findAction(actionName) {
return (this.get('adminWatchedWords.model') || []).findBy('nameKey', actionName);
},
@computed('adminWatchedWords.model', 'actionNameKey')
filteredContent() {
if (!this.get('actionNameKey')) { return []; }
const a = this.findAction(this.get('actionNameKey'));
return a ? a.words : [];
},
@computed('actionNameKey')
actionDescription(actionNameKey) {
return I18n.t('admin.watched_words.action_descriptions.' + actionNameKey);
},
actions: {
recordAdded(arg) {
const a = this.findAction(this.get('actionNameKey'));
if (a) {
a.words.unshiftObject(arg);
a.incrementProperty('count');
Em.run.schedule('afterRender', () => {
// remove from other actions lists
let match = null;
this.get('adminWatchedWords.model').forEach(action => {
if (match) return;
if (action.nameKey !== this.get('actionNameKey')) {
match = action.words.findBy('id', arg.id);
if (match) {
action.words.removeObject(match);
action.decrementProperty('count');
}
}
});
});
}
},
recordRemoved(arg) {
const a = this.findAction(this.get('actionNameKey'));
if (a) {
a.words.removeObject(arg);
a.decrementProperty('count');
}
},
uploadComplete() {
WatchedWord.findAll().then(data => {
this.set('adminWatchedWords.model', data);
});
}
}
});

View File

@ -0,0 +1,51 @@
import debounce from 'discourse/lib/debounce';
export default Ember.Controller.extend({
filter: null,
filtered: false,
showWords: false,
disableShowWords: Ember.computed.alias('filtered'),
filterContentNow() {
if (!!Ember.isEmpty(this.get('allWatchedWords'))) return;
let filter;
if (this.get('filter')) {
filter = this.get('filter').toLowerCase();
}
if (filter === undefined || filter.length < 1) {
this.set('model', this.get('allWatchedWords'));
return;
}
const matchesByAction = [];
this.get('allWatchedWords').forEach(wordsForAction => {
const wordRecords = wordsForAction.words.filter(wordRecord => {
return (wordRecord.word.indexOf(filter) > -1);
});
matchesByAction.pushObject( Ember.Object.create({
nameKey: wordsForAction.nameKey,
name: wordsForAction.name,
words: wordRecords,
count: wordRecords.length
}) );
});
this.set('model', matchesByAction);
},
filterContent: debounce(function() {
this.filterContentNow();
this.set('filtered', !Ember.isEmpty(this.get('filter')));
}, 250).observes('filter'),
actions: {
clearFilter() {
this.setProperties({ filter: '' });
}
}
});

View File

@ -0,0 +1,37 @@
import { ajax } from 'discourse/lib/ajax';
const WatchedWord = Discourse.Model.extend({
save() {
return ajax("/admin/watched_words" + (this.id ? '/' + this.id : '') + ".json", {
type: this.id ? 'PUT' : 'POST',
data: {word: this.get('word'), action_key: this.get('action')},
dataType: 'json'
});
},
destroy() {
return ajax("/admin/watched_words/" + this.get('id') + ".json", {type: 'DELETE'});
}
});
WatchedWord.reopenClass({
findAll() {
return ajax("/admin/watched_words").then(function (list) {
const actions = {};
list.words.forEach(s => {
if (!actions[s.action]) { actions[s.action] = []; }
actions[s.action].pushObject(WatchedWord.create(s));
});
list.actions.forEach(a => {
if (!actions[a]) { actions[a] = []; }
});
return Object.keys(actions).map(function(n) {
return Ember.Object.create({nameKey: n, name: I18n.t('admin.watched_words.actions.' + n), words: actions[n], count: actions[n].length});
});
});
}
});
export default WatchedWord;

View File

@ -90,5 +90,10 @@ export default function() {
this.route('adminPlugins', { path: '/plugins', resetNamespace: true }, function() {
this.route('index', { path: '/' });
});
this.route('adminWatchedWords', { path: '/watched_words', resetNamespace: true}, function() {
this.route('index', { path: '/' } );
this.route('action', { path: '/action/:action_id' } );
});
});
};

View File

@ -0,0 +1,11 @@
export default Discourse.Route.extend({
model(params) {
this.controllerFor('adminWatchedWordsAction').set('actionNameKey', params.action_id);
let filteredContent = this.controllerFor('adminWatchedWordsAction').get('filteredContent');
return Ember.Object.create({
nameKey: params.action_id,
name: I18n.t('admin.watched_words.actions.' + params.action_id),
words: filteredContent
});
}
});

View File

@ -0,0 +1,5 @@
export default Discourse.Route.extend({
beforeModel() {
this.replaceWith('adminWatchedWords.action', this.modelFor('adminWatchedWords')[0].nameKey);
}
});

View File

@ -0,0 +1,15 @@
import WatchedWord from 'admin/models/watched-word';
export default Discourse.Route.extend({
queryParams: {
filter: { replace: true }
},
model() {
return WatchedWord.findAll();
},
afterModel(watchedWordsList) {
this.controllerFor('adminWatchedWords').set('allWatchedWords', watchedWordsList);
}
});

View File

@ -22,6 +22,7 @@
{{nav-item route='adminApi' label='admin.api.title'}}
{{nav-item route='admin.backups' label='admin.backups.title'}}
{{/if}}
{{nav-item route='adminWatchedWords' label='admin.watched_words.title'}}
{{nav-item route='adminPlugins' label='admin.plugins.title'}}
{{plugin-outlet name="admin-menu" connectorTagName="li"}}
</ul>

View File

@ -0,0 +1,7 @@
<b>{{i18n 'admin.watched_words.form.label'}}</b>
{{text-field value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off"}}
{{d-button action="submit" disabled=formSubmitted label="admin.watched_words.form.add"}}
{{#if showSuccessMessage}}
<span class="success-message">{{i18n 'admin.watched_words.form.success'}}</span>
{{/if}}

View File

@ -0,0 +1,7 @@
<label class="btn {{if addDisabled 'disabled'}}">
{{fa-icon "upload"}}
{{i18n 'admin.watched_words.form.upload'}}
<input disabled={{addDisabled}} type="file" accept="text/plain,text/csv" style="visibility: hidden; position: absolute;" />
</label>
<br/>
<span class="instructions">One word per line</span>

View File

@ -0,0 +1,18 @@
<h2>{{model.name}}</h2>
<p class="about">{{actionDescription}}</p>
{{watched-word-form actionKey=actionNameKey action="recordAdded"}}
{{watched-word-uploader uploading=uploading actionKey=actionNameKey done="uploadComplete"}}
<div class='clearfix'></div>
<div class="watched-words-list">
{{#if showWordsList}}
{{#each filteredContent as |word| }}
<div class="watched-word-box">{{admin-watched-word word=word action="recordRemoved"}}</div>
{{/each}}
{{else}}
{{i18n 'admin.watched_words.word_count' count=model.words.length}}
{{/if}}
</div>

View File

@ -0,0 +1,31 @@
<div class='admin-controls'>
<div class='search controls'>
<label class="show-words-checkbox">
{{input type="checkbox" checked=showWords disabled=disableShowWords}}
{{i18n 'admin.watched_words.show_words'}}
</label>
</div>
<div class='controls'>
{{text-field value=filter placeholderKey="admin.watched_words.search" class="no-blur"}}
{{d-button action="clearFilter" label="admin.watched_words.clear_filter"}}
</div>
</div>
<div class="admin-nav pull-left">
<ul class="nav nav-stacked">
{{#each model as |action|}}
{{#link-to 'adminWatchedWords.action' action.nameKey tagName='li' class=action.nameKey}}
{{#link-to 'adminWatchedWords.action' action.nameKey}}
{{action.name}}
{{#if action.count}}<span class="count">({{action.count}})</span>{{/if}}
{{/link-to}}
{{/link-to}}
{{/each}}
</ul>
</div>
<div class="admin-detail pull-left mobile-closed watched-words-detail">
{{outlet}}
</div>
<div class="clearfix"></div>

View File

@ -5,11 +5,13 @@ import { sanitize as textSanitize } from 'pretty-text/sanitizer';
import loadScript from 'discourse/lib/load-script';
function getOpts(opts) {
const siteSettings = Discourse.__container__.lookup('site-settings:main');
const siteSettings = Discourse.__container__.lookup('site-settings:main'),
site = Discourse.__container__.lookup('site:main');
opts = _.merge({
getURL: Discourse.getURLWithCDN,
currentUser: Discourse.__container__.lookup('current-user:main'),
censoredWords: site.censored_words,
siteSettings
}, opts);

View File

@ -50,11 +50,7 @@ const Topic = RestModel.extend({
@computed('fancy_title')
fancyTitle(title) {
// TODO: `siteSettings` should always be present, but there are places in the code
// that call Discourse.Topic.create instead of using the store.
// When the store is used, remove this.
const siteSettings = this.siteSettings || Discourse.SiteSettings;
return censor(emojiUnescape(title || ""), siteSettings.censored_words);
return censor(emojiUnescape(title || ""), Discourse.Site.currentProp('censored_words'));
},
// returns createdAt if there's no bumped date

View File

@ -24,7 +24,6 @@ function censorTree(state, censor) {
export function setup(helper) {
helper.registerOptions((opts, siteSettings) => {
opts.censoredWords = siteSettings.censored_words;
opts.censoredPattern = siteSettings.censored_pattern;
});

View File

@ -22,7 +22,8 @@ export function buildOptions(state) {
emojiUnicodeReplacer,
lookupInlineOnebox,
previewing,
linkify
linkify,
censoredWords
} = state;
let features = {
@ -57,6 +58,7 @@ export function buildOptions(state) {
mentionLookup: state.mentionLookup,
emojiUnicodeReplacer,
lookupInlineOnebox,
censoredWords,
allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null,
markdownIt: true,
previewing

View File

@ -1847,6 +1847,47 @@ table#user-badges {
}
}
.watched-word-box {
display: inline-block;
width: 250px;
margin-bottom: 1em;
float: left;
}
.watched-words-list {
margin-top: 40px;
}
.watched-word {
display: inline-block;
cursor: pointer;
i.fa {
margin-right: 0.25em;
color: dark-light-diff($primary, $secondary, 50%, -50%);
}
&:hover i.fa {
color: $primary;
}
}
.watched-word-form {
display: inline-block;
.success-message {
margin-left: 1em;
}
}
.watched-words-uploader {
float: right;
text-align: right;
.instructions {
font-size: 12px;
}
}
.watched-words-detail {
.about {
margin-top: 24px;
margin-bottom: 40px;
}
}
// Mobile specific styles
// Mobile view text-inputs need some padding
.mobile-view .admin-contents {

View File

@ -0,0 +1,47 @@
class Admin::WatchedWordsController < Admin::AdminController
def index
render_json_dump WatchedWordListSerializer.new(WatchedWord.by_action, scope: guardian, root: false)
end
def create
watched_word = WatchedWord.create_or_update_word(watched_words_params)
if watched_word.valid?
render json: watched_word, root: false
else
render_json_error(watched_word)
end
end
def destroy
watched_word = WatchedWord.find(params[:id])
watched_word.destroy
render json: success_json
end
def upload
file = params[:file] || params[:files].first
action_key = params[:action_key].to_sym
Scheduler::Defer.later("Upload watched words") do
begin
File.open(file.tempfile, encoding: "ISO-8859-1").each_line do |line|
WatchedWord.create_or_update_word(word: line, action_key: action_key) unless line.empty?
end
data = {url: '/ok'}
rescue => e
data = failed_json.merge(errors: [e.message])
end
MessageBus.publish("/uploads/csv", data.as_json, client_ids: [params[:client_id]])
end
render json: success_json
end
private
def watched_words_params
params.permit(:id, :word, :action_key)
end
end

View File

@ -0,0 +1,12 @@
module Jobs
class MigrateCensoredWords < Jobs::Onceoff
def execute_onceoff(args)
row = WatchedWord.exec_sql("SELECT value FROM site_settings WHERE name = 'censored_words'")
if row.count > 0
row.first["value"].split('|').each do |word|
WatchedWord.create(word: word, action: WatchedWord.actions[:censor])
end
end
end
end
end

View File

@ -37,6 +37,14 @@ module Jobs
post.publish_change_to_clients! :revised
end
end
if !post.user.staff? && !post.user.staged
s = post.cooked
s << " #{post.topic.title}" if post.post_number == 1
if WordWatcher.new(s).should_flag?
PostAction.act(Discourse.system_user, post, PostActionType.types[:inappropriate]) rescue PostAction::AlreadyActed
end
end
end
# onebox may have added some links, so extract them now

View File

@ -0,0 +1,54 @@
require_dependency 'enum'
class WatchedWord < ActiveRecord::Base
def self.actions
@actions ||= Enum.new(
block: 1,
censor: 2,
require_approval: 3,
flag: 4
)
end
MAX_WORDS_PER_ACTION = 1000
before_validation do
self.word = self.class.normalize_word(self.word)
end
validates :word, presence: true, uniqueness: true, length: { maximum: 50 }
validates :action, presence: true
validates_each :word do |record, attr, val|
if WatchedWord.where(action: record.action).count >= MAX_WORDS_PER_ACTION
record.errors.add(:word, :too_many)
end
end
after_save :clear_cache
after_destroy :clear_cache
scope :by_action, -> { order("action ASC, word ASC") }
def self.normalize_word(w)
w.strip.downcase.squeeze('*')
end
def self.create_or_update_word(params)
w = find_or_initialize_by(word: normalize_word(params[:word]))
w.action_key = params[:action_key] if params[:action_key]
w.action = params[:action] if params[:action]
w.save
w
end
def action_key=(arg)
self.action = self.class.actions[arg.to_sym]
end
def clear_cache
WordWatcher.clear_cache!
end
end

View File

@ -25,7 +25,8 @@ class SiteSerializer < ApplicationSerializer
:top_tags,
:wizard_required,
:topic_featured_link_allowed_category_ids,
:user_themes
:user_themes,
:censored_words
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :trust_levels, embed: :objects
@ -142,4 +143,8 @@ class SiteSerializer < ApplicationSerializer
def topic_featured_link_allowed_category_ids
scope.topic_featured_link_allowed_category_ids
end
def censored_words
WordWatcher.words_for_action(:censor).join('|')
end
end

View File

@ -0,0 +1,13 @@
class WatchedWordListSerializer < ApplicationSerializer
attributes :actions, :words
def actions
WatchedWord.actions.keys
end
def words
object.map do |word|
WatchedWordSerializer.new(word, root: false)
end
end
end

View File

@ -0,0 +1,7 @@
class WatchedWordSerializer < ApplicationSerializer
attributes :id, :word, :action
def action
WatchedWord.actions[object.action]
end
end

View File

@ -0,0 +1,51 @@
class WordWatcher
def initialize(raw)
@raw = raw
end
def self.words_for_action(action)
WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000).pluck(:word)
end
def self.words_for_action_exists?(action)
WatchedWord.where(action: WatchedWord.actions[action.to_sym]).exists?
end
def self.word_matcher_regexp(action)
s = Discourse.cache.fetch(word_matcher_regexp_key(action), expires_in: 1.day) do
words = words_for_action(action)
words.empty? ? nil : '\b(' + words.map { |w| Regexp.escape(w).gsub("\\*", '\S*') }.join('|'.freeze) + ')\b'
end
s.present? ? Regexp.new(s, Regexp::IGNORECASE) : nil
end
def self.word_matcher_regexp_key(action)
"watched-words-regexp:#{action}"
end
def self.clear_cache!
WatchedWord.actions.sum do |a,i|
Discourse.cache.delete word_matcher_regexp_key(a)
end
end
def requires_approval?
word_matches_for_action?(:require_approval)
end
def should_flag?
word_matches_for_action?(:flag)
end
def should_block?
word_matches_for_action?(:block)
end
def word_matches_for_action?(action)
r = self.class.word_matcher_regexp(action)
r ? r.match(@raw) : false
end
end

View File

@ -3148,6 +3148,31 @@ en:
logster:
title: "Error Logs"
watched_words:
title: "Watched Words"
search: "search"
clear_filter: "Clear"
show_words: "show words"
word_count:
one: "1 word"
other: "%{count} words"
actions:
block: 'Block'
censor: 'Censor'
require_approval: 'Require Approval'
flag: 'Flag'
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.'
form:
label: 'New Word:'
add: 'Add'
success: 'Success'
upload: "Upload"
upload_successful: "Upload successful. Words have been added."
impersonate:
title: "Impersonate"
help: "Use this tool to impersonate a user account for debugging purposes. You will have to log out once finished."

View File

@ -208,6 +208,7 @@ en:
too_many_links:
one: "Sorry, new users can only put one link in a post."
other: "Sorry, new users can only put %{count} links in a post."
contains_blocked_words: "Your post contains words that aren't allowed."
spamming_host: "Sorry you cannot post a link to that host."
user_is_suspended: "Suspended users are not allowed to post."
@ -414,6 +415,10 @@ en:
attributes:
value:
missing_interpolation_keys: 'The following interpolation key(s) are missing: "%{keys}"'
watched_word:
attributes:
word:
too_many: "Too many words for that action"
user_profile:
no_info_me: "<div class='missing-profile'>the About Me field of your profile is currently blank, <a href='/u/%{username_lower}/preferences/about-me'>would you like to fill it out?</a></div>"

View File

@ -270,6 +270,12 @@ Discourse::Application.routes.draw do
get "dump_heap"=> "diagnostics#dump_heap", constraints: AdminConstraint.new
get "dump_statement_cache"=> "diagnostics#dump_statement_cache", constraints: AdminConstraint.new
resources :watched_words, only: [:index, :create, :update, :destroy], constraints: AdminConstraint.new do
collection do
get "action/:id" => "watched_words#index"
end
end
post "watched_words/upload" => "watched_words#upload"
end # admin namespace
get "email_preferences" => "email#preferences_redirect", :as => "email_preferences_redirect"

View File

@ -555,11 +555,6 @@ posting:
type: list
client: true
delete_old_hidden_posts: true
censored_words:
client: true
default: ''
refresh: true
type: list
censored_pattern:
client: true
default: ''

View File

@ -0,0 +1,11 @@
class CreateWatchedWords < ActiveRecord::Migration
def change
create_table :watched_words do |t|
t.string :word, null: false
t.integer :action, null: false
t.timestamps
end
add_index :watched_words, [:action, :word], unique: true
end
end

View File

@ -1,6 +1,7 @@
require_dependency 'post_creator'
require_dependency 'new_post_result'
require_dependency 'post_enqueuer'
require_dependency 'word_watcher'
# Determines what actions should be taken with new posts.
#
@ -66,21 +67,25 @@ class NewPostManager
end
def self.user_needs_approval?(manager)
def self.exempt_user?(user)
user.staff? || user.staged
end
def self.post_needs_approval?(manager)
user = manager.user
return false if user.staff? || user.staged
return false if exempt_user?(user)
(user.trust_level <= TrustLevel.levels[:basic] && user.post_count < SiteSetting.approve_post_count) ||
(user.trust_level < SiteSetting.approve_unless_trust_level.to_i) ||
(manager.args[:title].present? && user.trust_level < SiteSetting.approve_new_topics_unless_trust_level.to_i) ||
is_fast_typer?(manager) ||
matches_auto_block_regex?(manager)
matches_auto_block_regex?(manager) ||
WordWatcher.new("#{manager.args[:title]} #{manager.args[:raw]}").requires_approval?
end
def self.default_handler(manager)
if user_needs_approval?(manager)
if post_needs_approval?(manager)
validator = Validators::PostValidator.new
post = Post.new(raw: manager.args[:raw])
post.user = manager.user
@ -118,6 +123,7 @@ class NewPostManager
SiteSetting.approve_post_count > 0 ||
SiteSetting.approve_unless_trust_level.to_i > 0 ||
SiteSetting.approve_new_topics_unless_trust_level.to_i > 0 ||
WordWatcher.words_for_action_exists?(:require_approval) ||
handlers.size > 1
end
@ -127,8 +133,15 @@ class NewPostManager
end
def perform
if !self.class.exempt_user?(@user) && WordWatcher.new("#{@args[:title]} #{@args[:raw]}").should_block?
result = NewPostResult.new(:created_post, false)
result.errors[:base] << I18n.t('contains_blocked_words')
return result
end
# We never queue private messages
return perform_create_post if @args[:archetype] == Archetype.private_message
if args[:topic_id] && Topic.where(id: args[:topic_id], archetype: Archetype.private_message).exists?
return perform_create_post
end

View File

@ -273,6 +273,12 @@ class PostRevisor
@post.word_count = @fields[:raw].scan(/[[:word:]]+/).size if @fields.has_key?(:raw)
@post.self_edits += 1 if self_edit?
if !@post.acting_user.staff? && !@post.acting_user.staged && WordWatcher.new(@post.raw).should_block?
@post.errors[:base] << I18n.t('contains_blocked_words')
@post_successfully_saved = false
return
end
remove_flags_and_unhide_post
@post.extract_quoted_post_numbers

View File

@ -166,6 +166,7 @@ module PrettyText
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
__optInput.lookupInlineOnebox = __lookupInlineOnebox;
#{opts[:linkify] == false ? "__optInput.linkify = false;": ""}
__optInput.censoredWords = #{WordWatcher.words_for_action(:censor).join('|').to_json};
JS
if opts[:topicId]

View File

@ -1,6 +1,6 @@
class CensoredWordsValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if SiteSetting.censored_words.present? && (censored_words = censor_words(value, censored_words_regexp)).present?
if WordWatcher.words_for_action(:censor).present? && (censored_words = censor_words(value, censored_words_regexp)).present?
record.errors.add(
attribute, :contains_censored_words,
censored_words: join_censored_words(censored_words)
@ -32,9 +32,6 @@ class CensoredWordsValidator < ActiveModel::EachValidator
end
def censored_words_regexp
Regexp.new(
'\b(' + SiteSetting.censored_words.split('|'.freeze).map! { |w| Regexp.escape(w) }.join('|'.freeze) + ')\b',
true
)
WordWatcher.word_matcher_regexp :censor
end
end

View File

@ -8,8 +8,8 @@ const defaultOpts = buildOptions({
emoji_set: 'emoji_one',
highlighted_languages: 'json|ruby|javascript',
default_code_lang: 'auto',
censored_words: ''
},
censoredWords: 'shucks|whiz|whizzer',
getURL: url => url
});

View File

@ -253,22 +253,22 @@ describe NewPostManager do
it "handles user_needs_approval? correctly" do
it "handles post_needs_approval? correctly" do
u = user
default = NewPostManager.new(u,{})
expect(NewPostManager.user_needs_approval?(default)).to eq(false)
expect(NewPostManager.post_needs_approval?(default)).to eq(false)
with_check = NewPostManager.new(u, first_post_checks: true)
expect(NewPostManager.user_needs_approval?(with_check)).to eq(true)
expect(NewPostManager.post_needs_approval?(with_check)).to eq(true)
u.user_stat.post_count = 1
with_check_and_post = NewPostManager.new(u, first_post_checks: true)
expect(NewPostManager.user_needs_approval?(with_check_and_post)).to eq(false)
expect(NewPostManager.post_needs_approval?(with_check_and_post)).to eq(false)
u.user_stat.post_count = 0
u.trust_level = 1
with_check_tl1 = NewPostManager.new(u, first_post_checks: true)
expect(NewPostManager.user_needs_approval?(with_check_tl1)).to eq(false)
expect(NewPostManager.post_needs_approval?(with_check_tl1)).to eq(false)
end
end

View File

@ -247,8 +247,9 @@ describe PrettyText do
end
it 'does censor code fences' do
SiteSetting.censored_words = 'apple|banana'
['apple', 'banana'].each { |w| Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor]) }
expect(PrettyText.cook("# banana")).not_to include('banana')
$redis.flushall
end
end
@ -787,11 +788,12 @@ HTML
end
it 'can censor words correctly' do
SiteSetting.censored_words = 'apple|banana'
['apple', 'banana'].each { |w| Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor]) }
expect(PrettyText.cook('yay banana yay')).not_to include('banana')
expect(PrettyText.cook('yay `banana` yay')).not_to include('banana')
expect(PrettyText.cook("# banana")).not_to include('banana')
expect(PrettyText.cook("# banana")).to include("\u25a0\u25a0")
$redis.flushall
end
it 'supports typographer' do

View File

@ -0,0 +1,4 @@
Fabricator(:watched_word) do
word { sequence(:word) { |i| "word#{i}"} }
action { WatchedWord.actions[:block] }
end

View File

@ -0,0 +1,169 @@
require 'rails_helper'
describe WatchedWord do
let(:tl2_user) { Fabricate(:user, trust_level: TrustLevel[2]) }
let(:admin) { Fabricate(:admin) }
let(:moderator) { Fabricate(:moderator) }
let(:topic) { Fabricate(:topic) }
let(:first_post) { Fabricate(:post, topic: topic) }
let(:require_approval_word) { Fabricate(:watched_word, action: WatchedWord.actions[:require_approval]) }
let(:flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) }
let(:block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) }
context "block" do
def should_block_post(manager)
expect {
result = manager.perform
expect(result).to_not be_success
expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_words'))
}.to_not change { Post.count }
end
it "should prevent the post from being created" do
manager = NewPostManager.new(tl2_user, raw: "Want some #{block_word.word} for cheap?", topic_id: topic.id)
should_block_post(manager)
end
it "look at title too" do
manager = NewPostManager.new(tl2_user, title: "We sell #{block_word.word} online", raw: "Want some poutine for cheap?", topic_id: topic.id)
should_block_post(manager)
end
it "should not block the post from admin" do
manager = NewPostManager.new(admin, raw: "Want some #{block_word.word} for cheap?", topic_id: topic.id)
result = manager.perform
expect(result).to be_success
expect(result.action).to eq(:create_post)
end
it "should not block the post from moderator" do
manager = NewPostManager.new(moderator, raw: "Want some #{block_word.word} for cheap?", topic_id: topic.id)
result = manager.perform
expect(result).to be_success
expect(result.action).to eq(:create_post)
end
it "should block in a private message too" do
manager = NewPostManager.new(
tl2_user,
raw: "Want some #{block_word.word} for cheap?",
title: 'this is a new title',
archetype: Archetype.private_message,
target_usernames: Fabricate(:user, trust_level: TrustLevel[2]).username
)
should_block_post(manager)
end
it "blocks on revisions" do
post = Fabricate(:post, topic: Fabricate(:topic, user: tl2_user), user: tl2_user)
expect {
PostRevisor.new(post).revise!(post.user, { raw: "Want some #{block_word.word} for cheap?" }, revised_at: post.updated_at + 10.seconds)
expect(post.errors).to be_present
post.reload
}.to_not change { post.raw }
end
end
context "require_approval" do
it "should queue the post for approval" do
manager = NewPostManager.new(tl2_user, raw: "My dog's name is #{require_approval_word.word}.", topic_id: topic.id)
result = manager.perform
expect(result.action).to eq(:enqueued)
end
it "looks at title too" do
manager = NewPostManager.new(tl2_user, title: "You won't believe these #{require_approval_word.word} dog names!", raw: "My dog's name is Porkins.", topic_id: topic.id)
result = manager.perform
expect(result.action).to eq(:enqueued)
end
it "should not queue posts from admin" do
manager = NewPostManager.new(admin, raw: "My dog's name is #{require_approval_word.word}.", topic_id: topic.id)
result = manager.perform
expect(result).to be_success
expect(result.action).to eq(:create_post)
end
it "should not queue posts from moderator" do
manager = NewPostManager.new(moderator, raw: "My dog's name is #{require_approval_word.word}.", topic_id: topic.id)
result = manager.perform
expect(result).to be_success
expect(result.action).to eq(:create_post)
end
it "doesn't need approval in a private message" do
manager = NewPostManager.new(
tl2_user,
raw: "Want some #{require_approval_word.word} for cheap?",
title: 'this is a new title',
archetype: Archetype.private_message,
target_usernames: Fabricate(:user, trust_level: TrustLevel[2]).username
)
result = manager.perform
expect(result).to be_success
expect(result.action).to eq(:create_post)
end
end
context "flag" do
def should_flag_post(author, raw, topic)
post = Fabricate(:post, raw: raw, topic: topic, user: author)
expect {
Jobs::ProcessPost.new.execute(post_id: post.id)
}.to change { PostAction.count }.by(1)
expect(PostAction.where(post_id: post.id, post_action_type_id: PostActionType.types[:inappropriate]).exists?).to eq(true)
end
def should_not_flag_post(author, raw, topic)
post = Fabricate(:post, raw: raw, topic: topic, user: author)
expect {
Jobs::ProcessPost.new.execute(post_id: post.id)
}.to_not change { PostAction.count }
end
it "should flag the post as inappropriate" do
should_flag_post(tl2_user, "I thought the #{flag_word.word} was bad.", Fabricate(:topic, user: tl2_user))
end
it "should look at the title too" do
should_flag_post(tl2_user, "I thought the movie was not bad actually.", Fabricate(:topic, user: tl2_user, title: "Read my #{flag_word.word} review!"))
end
it "shouldn't flag posts by admin" do
should_not_flag_post(admin, "I thought the #{flag_word.word} was bad.", Fabricate(:topic, user: admin))
end
it "shouldn't flag posts by moderator" do
should_not_flag_post(moderator, "I thought the #{flag_word.word} was bad.", Fabricate(:topic, user: moderator))
end
it "is compatible with flag_sockpuppets" do
# e.g., handle PostAction::AlreadyActed
SiteSetting.flag_sockpuppets = true
ip_address = '182.189.119.174'
user1 = Fabricate(:user, ip_address: ip_address, created_at: 2.days.ago)
user2 = Fabricate(:user, ip_address: ip_address)
first = create_post(user: user1, created_at: 2.days.ago)
sockpuppet_post = create_post(user: user2, topic: first.topic, raw: "I thought the #{flag_word.word} was bad.")
expect(PostAction.where(post_id: sockpuppet_post.id).count).to eq(1)
end
it "flags in private message too" do
post = Fabricate(:private_message_post, raw: "Want some #{flag_word.word} for cheap?", user: tl2_user)
expect {
Jobs::ProcessPost.new.execute(post_id: post.id)
}.to change { PostAction.count }.by(1)
expect(PostAction.where(post_id: post.id, post_action_type_id: PostActionType.types[:inappropriate]).exists?).to eq(true)
end
it "flags on revisions" do
post = Fabricate(:post, topic: Fabricate(:topic, user: tl2_user), user: tl2_user)
expect {
PostRevisor.new(post).revise!(post.user, { raw: "Want some #{flag_word.word} for cheap?" }, revised_at: post.updated_at + 10.seconds)
}.to change { PostAction.count }.by(1)
expect(PostAction.where(post_id: post.id, post_action_type_id: PostActionType.types[:inappropriate]).exists?).to eq(true)
end
end
end

View File

@ -30,9 +30,13 @@ describe Topic do
end
describe 'censored words' do
after do
$redis.flushall
end
describe 'when title contains censored words' do
it 'should not be valid' do
SiteSetting.censored_words = 'pineapple|pen'
['pineapple', 'pen'].each { |w| Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor]) }
topic.title = 'pen PinEapple apple pen is a complete sentence'
@ -46,7 +50,7 @@ describe Topic do
describe 'titles with censored words not on boundaries' do
it "should be valid" do
SiteSetting.censored_words = 'apple'
Fabricate(:watched_word, word: 'apple', action: WatchedWord.actions[:censor])
topic.title = "Pineapples are great fruit! Applebee's is a great restaurant"
expect(topic).to be_valid
end
@ -62,10 +66,12 @@ describe Topic do
describe 'escape special characters in censored words' do
before do
SiteSetting.censored_words = 'co(onut|coconut|a**le'
['co(onut', 'coconut', 'a**le'].each do |w|
Fabricate(:watched_word, word: w, action: WatchedWord.actions[:censor])
end
end
it 'should not valid' do
it 'should not be valid' do
topic.title = "I have a co(onut a**le"
expect(topic.valid?).to eq(false)

View File

@ -0,0 +1,92 @@
require 'rails_helper'
describe WatchedWord do
it "can't have duplicate words" do
Fabricate(:watched_word, word: "darn", action: described_class.actions[:block])
w = Fabricate.build(:watched_word, word: "darn", action: described_class.actions[:block])
expect(w.save).to eq(false)
w = Fabricate.build(:watched_word, word: "darn", action: described_class.actions[:flag])
expect(w.save).to eq(false)
expect(described_class.count).to eq(1)
end
it "downcases words" do
expect(described_class.create(word: "ShooT").word).to eq('shoot')
end
it "strips leading and trailing spaces" do
expect(described_class.create(word: " poutine ").word).to eq('poutine')
end
it "squeezes multiple asterisks" do
expect(described_class.create(word: "a**les").word).to eq('a*les')
end
describe "action_key=" do
let(:w) { WatchedWord.new(word: "troll") }
it "sets action attr from symbol" do
described_class.actions.keys.each do |k|
w.action_key = k
expect(w.action).to eq(described_class.actions[k])
end
end
it "sets action attr from string" do
described_class.actions.keys.each do |k|
w.action_key = k.to_s
expect(w.action).to eq(described_class.actions[k])
end
end
it "sets error for invalid key" do
w.action_key = "shame"
expect(w).to_not be_valid
expect(w.errors[:action]).to be_present
end
end
describe '#create_or_update_word' do
it "can create a new record" do
expect {
w = described_class.create_or_update_word(word: 'nickelback', action_key: :block)
expect(w.reload.action).to eq(described_class.actions[:block])
}.to change { described_class.count }.by(1)
end
it "can update an existing record with different action" do
existing = Fabricate(:watched_word, action: described_class.actions[:flag])
expect {
w = described_class.create_or_update_word(word: existing.word, action_key: :block)
expect(w.reload.action).to eq(described_class.actions[:block])
expect(w.id).to eq(existing.id)
}.to_not change { described_class.count }
end
it "doesn't error for existing record with same action" do
existing = Fabricate(:watched_word, action: described_class.actions[:flag], created_at: 1.day.ago, updated_at: 1.day.ago)
expect {
w = described_class.create_or_update_word(word: existing.word, action_key: :flag)
expect(w.id).to eq(existing.id)
expect(w.updated_at).to eq(w.updated_at)
}.to_not change { described_class.count }
end
it "allows action param instead of action_key" do
expect {
w = described_class.create_or_update_word(word: 'nickelback', action: described_class.actions[:block])
expect(w.reload.action).to eq(described_class.actions[:block])
}.to change { described_class.count }.by(1)
end
it "normalizes input" do
existing = Fabricate(:watched_word, action: described_class.actions[:flag])
expect {
w = described_class.create_or_update_word(word: " #{existing.word.upcase} ", action_key: :block)
expect(w.reload.action).to eq(described_class.actions[:block])
expect(w.id).to eq(existing.id)
}.to_not change { described_class.count }
end
end
end

View File

@ -0,0 +1,53 @@
require 'rails_helper'
describe WordWatcher do
let(:raw) { "Do you like liquorice?\n\nI really like them. One could even say that I am *addicted* to liquorice. Anf if\nyou can mix it up with some anise, then I'm in heaven ;)" }
after do
$redis.flushall
end
describe "word_matches_for_action?" do
it "is falsey when there are no watched words" do
expect(WordWatcher.new(raw).word_matches_for_action?(:require_approval)).to be_falsey
end
context "with watched words" do
let!(:anise) { Fabricate(:watched_word, word: "anise", action: WatchedWord.actions[:require_approval]) }
it "is falsey without a match" do
expect(WordWatcher.new("No liquorice for me, thanks...").word_matches_for_action?(:require_approval)).to be_falsey
end
it "is returns matched words if there's a match" do
m = WordWatcher.new(raw).word_matches_for_action?(:require_approval)
expect(m).to be_truthy
expect(m[1]).to eq(anise.word)
end
it "finds at start of string" do
m = WordWatcher.new("#{anise.word} is garbage").word_matches_for_action?(:require_approval)
expect(m[1]).to eq(anise.word)
end
it "finds at end of string" do
m = WordWatcher.new("who likes #{anise.word}").word_matches_for_action?(:require_approval)
expect(m[1]).to eq(anise.word)
end
it "finds non-letters in place of letters" do
Fabricate(:watched_word, word: "co(onut", action: WatchedWord.actions[:require_approval])
m = WordWatcher.new("This co(onut is delicious.").word_matches_for_action?(:require_approval)
expect(m[1]).to eq("co(onut")
end
it "handles * for wildcards" do
Fabricate(:watched_word, word: "a**le*", action: WatchedWord.actions[:require_approval])
m = WordWatcher.new("I acknowledge you.").word_matches_for_action?(:require_approval)
expect(m[1]).to eq("acknowledge")
end
end
end
end

View File

@ -0,0 +1,68 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Admin - Watched Words", { loggedIn: true });
QUnit.test("list words in groups", assert => {
visit("/admin/watched_words/action/block");
andThen(() => {
assert.ok(exists('.watched-words-list'));
assert.ok(!exists('.watched-words-list .watched-word'), "Don't show bad words by default.");
});
fillIn('.admin-controls .controls input[type=text]', 'li');
andThen(() => {
assert.equal(find('.watched-words-list .watched-word').length, 1, "When filtering, show words even if checkbox is unchecked.");
});
fillIn('.admin-controls .controls input[type=text]', '');
andThen(() => {
assert.ok(!exists('.watched-words-list .watched-word'), "Clearing the filter hides words again.");
});
click('.show-words-checkbox');
andThen(() => {
assert.ok(exists('.watched-words-list .watched-word'), "Always show the words when checkbox is checked.");
});
click('.nav-stacked .censor');
andThen(() => {
assert.ok(exists('.watched-words-list'));
assert.ok(!exists('.watched-words-list .watched-word'), "Empty word list.");
});
});
QUnit.test("add words", assert => {
visit("/admin/watched_words/action/block");
andThen(() => {
click('.show-words-checkbox');
fillIn('.watched-word-form input', 'poutine');
});
click('.watched-word-form button');
andThen(() => {
let found = [];
_.each(find('.watched-words-list .watched-word'), i => {
if ($(i).text().trim() === 'poutine') {
found.push(true);
}
});
assert.equal(found.length, 1);
});
});
QUnit.test("remove words", assert => {
visit("/admin/watched_words/action/block");
click('.show-words-checkbox');
let word = null;
andThen(() => {
_.each(find('.watched-words-list .watched-word'), i => {
if ($(i).text().trim() === 'anise') {
word = i;
}
});
click('#' + $(word).attr('id'));
});
andThen(() => {
assert.equal(find('.watched-words-list .watched-word').length, 1);
});
});

View File

@ -0,0 +1,12 @@
export default {
"/admin/watched_words.json": {
"actions": ["block", "censor", "require_approval", "flag"],
"words": [
{id: 1, word: "liquorice", action: "block"},
{id: 2, word: "anise", action: "block"},
{id: 3, word: "pyramid", action: "flag"},
{id: 4, word: "scheme", action: "flag"},
{id: 5, word: "coupon", action: "require_approval"}
]
}
};

View File

@ -334,6 +334,17 @@ export default function() {
this.post('/admin/badges', success);
this.delete('/admin/badges/:id', success);
this.get('/admin/watched_words', () => {
return response(200, fixturesByUrl['/admin/watched_words.json']);
});
this.delete('/admin/watched_words/:id.json', success);
this.post('/admin/watched_words.json', request => {
const result = parsePostData(request.requestBody);
result.id = new Date().getTime();
return response(200, result);
});
this.get('/onebox', request => {
if (request.queryParams.url === 'http://www.example.com/has-title.html' ||
request.queryParams.url === 'http://www.example.com/has-title-and-a-url-that-is-more-than-80-characters-because-thats-good-for-seo-i-guess.html') {

View File

@ -11,9 +11,9 @@ const rawOpts = {
emoji_set: 'emoji_one',
highlighted_languages: 'json|ruby|javascript',
default_code_lang: 'auto',
censored_words: 'shucks|whiz|whizzer|a**le',
censored_pattern: '\\d{3}-\\d{4}|tech\\w*'
},
censoredWords: 'shucks|whiz|whizzer|a**le',
getURL: url => url
};