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('adminPlugins', { path: '/plugins', resetNamespace: true }, function() {
this.route('index', { path: '/' }); 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='adminApi' label='admin.api.title'}}
{{nav-item route='admin.backups' label='admin.backups.title'}} {{nav-item route='admin.backups' label='admin.backups.title'}}
{{/if}} {{/if}}
{{nav-item route='adminWatchedWords' label='admin.watched_words.title'}}
{{nav-item route='adminPlugins' label='admin.plugins.title'}} {{nav-item route='adminPlugins' label='admin.plugins.title'}}
{{plugin-outlet name="admin-menu" connectorTagName="li"}} {{plugin-outlet name="admin-menu" connectorTagName="li"}}
</ul> </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'; import loadScript from 'discourse/lib/load-script';
function getOpts(opts) { 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({ opts = _.merge({
getURL: Discourse.getURLWithCDN, getURL: Discourse.getURLWithCDN,
currentUser: Discourse.__container__.lookup('current-user:main'), currentUser: Discourse.__container__.lookup('current-user:main'),
censoredWords: site.censored_words,
siteSettings siteSettings
}, opts); }, opts);

View File

@ -50,11 +50,7 @@ const Topic = RestModel.extend({
@computed('fancy_title') @computed('fancy_title')
fancyTitle(title) { fancyTitle(title) {
// TODO: `siteSettings` should always be present, but there are places in the code return censor(emojiUnescape(title || ""), Discourse.Site.currentProp('censored_words'));
// 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);
}, },
// returns createdAt if there's no bumped date // returns createdAt if there's no bumped date

View File

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

View File

@ -22,7 +22,8 @@ export function buildOptions(state) {
emojiUnicodeReplacer, emojiUnicodeReplacer,
lookupInlineOnebox, lookupInlineOnebox,
previewing, previewing,
linkify linkify,
censoredWords
} = state; } = state;
let features = { let features = {
@ -57,6 +58,7 @@ export function buildOptions(state) {
mentionLookup: state.mentionLookup, mentionLookup: state.mentionLookup,
emojiUnicodeReplacer, emojiUnicodeReplacer,
lookupInlineOnebox, lookupInlineOnebox,
censoredWords,
allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null, allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null,
markdownIt: true, markdownIt: true,
previewing 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 specific styles
// Mobile view text-inputs need some padding // Mobile view text-inputs need some padding
.mobile-view .admin-contents { .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 post.publish_change_to_clients! :revised
end end
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 end
# onebox may have added some links, so extract them now # 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, :top_tags,
:wizard_required, :wizard_required,
:topic_featured_link_allowed_category_ids, :topic_featured_link_allowed_category_ids,
:user_themes :user_themes,
:censored_words
has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :trust_levels, embed: :objects has_many :trust_levels, embed: :objects
@ -142,4 +143,8 @@ class SiteSerializer < ApplicationSerializer
def topic_featured_link_allowed_category_ids def topic_featured_link_allowed_category_ids
scope.topic_featured_link_allowed_category_ids scope.topic_featured_link_allowed_category_ids
end end
def censored_words
WordWatcher.words_for_action(:censor).join('|')
end
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: logster:
title: "Error Logs" 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: impersonate:
title: "Impersonate" title: "Impersonate"
help: "Use this tool to impersonate a user account for debugging purposes. You will have to log out once finished." 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: too_many_links:
one: "Sorry, new users can only put one link in a post." one: "Sorry, new users can only put one link in a post."
other: "Sorry, new users can only put %{count} links 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." spamming_host: "Sorry you cannot post a link to that host."
user_is_suspended: "Suspended users are not allowed to post." user_is_suspended: "Suspended users are not allowed to post."
@ -414,6 +415,10 @@ en:
attributes: attributes:
value: value:
missing_interpolation_keys: 'The following interpolation key(s) are missing: "%{keys}"' 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: 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>" 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_heap"=> "diagnostics#dump_heap", constraints: AdminConstraint.new
get "dump_statement_cache"=> "diagnostics#dump_statement_cache", 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 end # admin namespace
get "email_preferences" => "email#preferences_redirect", :as => "email_preferences_redirect" get "email_preferences" => "email#preferences_redirect", :as => "email_preferences_redirect"

View File

@ -555,11 +555,6 @@ posting:
type: list type: list
client: true client: true
delete_old_hidden_posts: true delete_old_hidden_posts: true
censored_words:
client: true
default: ''
refresh: true
type: list
censored_pattern: censored_pattern:
client: true client: true
default: '' 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 'post_creator'
require_dependency 'new_post_result' require_dependency 'new_post_result'
require_dependency 'post_enqueuer' require_dependency 'post_enqueuer'
require_dependency 'word_watcher'
# Determines what actions should be taken with new posts. # Determines what actions should be taken with new posts.
# #
@ -66,21 +67,25 @@ class NewPostManager
end 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 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 <= TrustLevel.levels[:basic] && user.post_count < SiteSetting.approve_post_count) ||
(user.trust_level < SiteSetting.approve_unless_trust_level.to_i) || (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) || (manager.args[:title].present? && user.trust_level < SiteSetting.approve_new_topics_unless_trust_level.to_i) ||
is_fast_typer?(manager) || 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 end
def self.default_handler(manager) def self.default_handler(manager)
if user_needs_approval?(manager) if post_needs_approval?(manager)
validator = Validators::PostValidator.new validator = Validators::PostValidator.new
post = Post.new(raw: manager.args[:raw]) post = Post.new(raw: manager.args[:raw])
post.user = manager.user post.user = manager.user
@ -118,6 +123,7 @@ class NewPostManager
SiteSetting.approve_post_count > 0 || SiteSetting.approve_post_count > 0 ||
SiteSetting.approve_unless_trust_level.to_i > 0 || SiteSetting.approve_unless_trust_level.to_i > 0 ||
SiteSetting.approve_new_topics_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 handlers.size > 1
end end
@ -127,8 +133,15 @@ class NewPostManager
end end
def perform 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 # We never queue private messages
return perform_create_post if @args[:archetype] == Archetype.private_message 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? if args[:topic_id] && Topic.where(id: args[:topic_id], archetype: Archetype.private_message).exists?
return perform_create_post return perform_create_post
end end

View File

@ -273,6 +273,12 @@ class PostRevisor
@post.word_count = @fields[:raw].scan(/[[:word:]]+/).size if @fields.has_key?(:raw) @post.word_count = @fields[:raw].scan(/[[:word:]]+/).size if @fields.has_key?(:raw)
@post.self_edits += 1 if self_edit? @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 remove_flags_and_unhide_post
@post.extract_quoted_post_numbers @post.extract_quoted_post_numbers

View File

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

View File

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

View File

@ -8,8 +8,8 @@ const defaultOpts = buildOptions({
emoji_set: 'emoji_one', emoji_set: 'emoji_one',
highlighted_languages: 'json|ruby|javascript', highlighted_languages: 'json|ruby|javascript',
default_code_lang: 'auto', default_code_lang: 'auto',
censored_words: ''
}, },
censoredWords: 'shucks|whiz|whizzer',
getURL: url => url 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 u = user
default = NewPostManager.new(u,{}) 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) 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 u.user_stat.post_count = 1
with_check_and_post = NewPostManager.new(u, first_post_checks: true) 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.user_stat.post_count = 0
u.trust_level = 1 u.trust_level = 1
with_check_tl1 = NewPostManager.new(u, first_post_checks: true) 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
end end

View File

@ -247,8 +247,9 @@ describe PrettyText do
end end
it 'does censor code fences' do 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') expect(PrettyText.cook("# banana")).not_to include('banana')
$redis.flushall
end end
end end
@ -787,11 +788,12 @@ HTML
end end
it 'can censor words correctly' do 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('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")).not_to include('banana')
expect(PrettyText.cook("# banana")).to include("\u25a0\u25a0") expect(PrettyText.cook("# banana")).to include("\u25a0\u25a0")
$redis.flushall
end end
it 'supports typographer' do 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 end
describe 'censored words' do describe 'censored words' do
after do
$redis.flushall
end
describe 'when title contains censored words' do describe 'when title contains censored words' do
it 'should not be valid' 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' 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 describe 'titles with censored words not on boundaries' do
it "should be valid" 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" topic.title = "Pineapples are great fruit! Applebee's is a great restaurant"
expect(topic).to be_valid expect(topic).to be_valid
end end
@ -62,10 +66,12 @@ describe Topic do
describe 'escape special characters in censored words' do describe 'escape special characters in censored words' do
before 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 end
it 'should not valid' do it 'should not be valid' do
topic.title = "I have a co(onut a**le" topic.title = "I have a co(onut a**le"
expect(topic.valid?).to eq(false) 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.post('/admin/badges', success);
this.delete('/admin/badges/:id', 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 => { this.get('/onebox', request => {
if (request.queryParams.url === 'http://www.example.com/has-title.html' || 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') { 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', emoji_set: 'emoji_one',
highlighted_languages: 'json|ruby|javascript', highlighted_languages: 'json|ruby|javascript',
default_code_lang: 'auto', default_code_lang: 'auto',
censored_words: 'shucks|whiz|whizzer|a**le',
censored_pattern: '\\d{3}-\\d{4}|tech\\w*' censored_pattern: '\\d{3}-\\d{4}|tech\\w*'
}, },
censoredWords: 'shucks|whiz|whizzer|a**le',
getURL: url => url getURL: url => url
}; };