Merge branch 'master' into addINSTALL

This commit is contained in:
Kim Gardner
2016-12-23 16:12:18 -05:00
committed by GitHub
31 changed files with 656 additions and 436 deletions
-1
View File
@@ -28,7 +28,6 @@
"yoda": [1],
"no-path-concat": [2],
"eol-last": [1],
"no-continue": [1],
"no-nested-ternary": [1],
"no-tabs": [2],
"no-unneeded-ternary": [1],
+2 -1
View File
@@ -15,7 +15,8 @@
},
"NODE_ENV": "production",
"TALK_SMTP_PORT": "2525",
"REWRITE_ENV": "TALK_PORT:PORT,TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:POSTMARK_SMTP_SERVER,TALK_SMTP_USERNAME:POSTMARK_API_TOKEN,TALK_SMTP_PASSWORD:POSTMARK_API_TOKEN"
"REWRITE_ENV": "TALK_PORT:PORT,TALK_MONGO_URL:MONGO_URI,TALK_REDIS_URL:REDIS_URL,TALK_SMTP_HOST:POSTMARK_SMTP_SERVER,TALK_SMTP_USERNAME:POSTMARK_API_TOKEN,TALK_SMTP_PASSWORD:POSTMARK_API_TOKEN",
"NPM_CONFIG_PRODUCTION": "false"
},
"addons": [{
"plan": "mongolab:sandbox",
+15 -13
View File
@@ -1,10 +1,11 @@
import coralApi from '../../../coral-framework/helpers/response';
import * as commentActions from '../constants/comments';
import * as commentTypes from '../constants/comments';
import * as actionTypes from '../constants/actions';
// Get comments to fill each of the three lists on the mod queue
export const fetchModerationQueueComments = () => {
return dispatch => {
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH});
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
return Promise.all([
coralApi('/queue/comments/pending'),
coralApi('/comments?status=rejected'),
@@ -20,11 +21,12 @@ export const fetchModerationQueueComments = () => {
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
};
})
.then(({comments, users}) => {
.then(({comments, users, actions}) => {
/* Post comments and users to redux store. Actions will be posted when they are needed. */
dispatch({type: commentActions.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
dispatch({type: commentActions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
});
};
@@ -35,8 +37,8 @@ export const createComment = (name, body) => {
return dispatch => {
const comment = {body, name};
return coralApi('/comments', {method: 'POST', comment})
.then(res => dispatch({type: commentActions.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentActions.COMMENT_CREATE_FAILED, error}));
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
};
};
@@ -47,23 +49,23 @@ export const createComment = (name, body) => {
// Update a comment. Now to update a comment we need to send back the whole object
export const updateStatus = (status, comment) => {
return dispatch => {
dispatch({type: commentActions.COMMENT_STATUS_UPDATE, id: comment.id, status});
dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status});
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
.then(res => dispatch({type: commentActions.COMMENT_UPDATE_SUCCESS, res}))
.catch(error => dispatch({type: commentActions.COMMENT_UPDATE_FAILED, error}));
.then(res => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_SUCCESS, res}))
.catch(error => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_FAILURE, error}));
};
};
export const flagComment = id => (dispatch, getState) => {
dispatch({type: commentActions.COMMENT_FLAG, id});
dispatch({type: commentTypes.COMMENT_FLAG, id});
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
};
// Dialog Actions
export const showBanUserDialog = (userId, userName, commentId) => {
return {type: commentActions.SHOW_BANUSER_DIALOG, userId, userName, commentId};
return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId};
};
export const hideBanUserDialog = (showDialog) => {
return {type: commentActions.HIDE_BANUSER_DIALOG, showDialog};
return {type: commentTypes.HIDE_BANUSER_DIALOG, showDialog};
};
@@ -10,6 +10,8 @@ export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING';
export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
export const fetchSettings = () => dispatch => {
dispatch({type: SETTINGS_LOADING});
coralApi('/settings')
@@ -21,10 +23,16 @@ export const fetchSettings = () => dispatch => {
});
};
// for updating top-level settings
export const updateSettings = settings => {
return {type: SETTINGS_UPDATED, settings};
};
// this is a nested property, so it needs a special action.
export const updateWordlist = (listName, list) => {
return {type: WORDLIST_UPDATED, listName, list};
};
export const saveSettingsToServer = () => (dispatch, getState) => {
let settings = getState().settings.toJS().settings;
if (settings.charCount) {
+5 -2
View File
@@ -8,6 +8,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import {Icon} from 'react-mdl';
import Highlighter from 'react-highlight-words';
import {FabButton, Button} from 'coral-ui';
const linkify = new Linkify();
@@ -31,7 +32,7 @@ export default props => {
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={`actions ${styles.actions}`}>
{props.actions.map((action, i) => getActionButton(action, i, props))}
{props.modActions.map((action, i) => getActionButton(action, i, props))}
</div>
</div>
<div>
@@ -42,7 +43,9 @@ export default props => {
<div className={styles.itemBody}>
<span className={styles.body}>
<Linkify component='span' properties={{style: linkStyles}}>
{comment.body}
<Highlighter
searchWords={props.suspectWords}
textToHighlight={comment.body} />
</Linkify>
</span>
</div>
@@ -1,12 +1,11 @@
import React from 'react';
import React, {PropTypes} from 'react';
import styles from './CommentList.css';
import key from 'keymaster';
import Hammer from 'hammerjs';
import Comment from 'components/Comment';
// Each action has different meaning and configuration
const actions = {
const modActions = {
'reject': {status: 'rejected', icon: 'close', key: 'r'},
'approve': {status: 'accepted', icon: 'done', key: 't'},
'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'},
@@ -15,6 +14,23 @@ const actions = {
// Renders a comment list and allow performing actions
export default class CommentList extends React.Component {
static propTypes = {
isActive: PropTypes.bool,
singleView: PropTypes.bool,
commentIds: PropTypes.arrayOf(PropTypes.string).isRequired,
comments: PropTypes.object.isRequired,
users: PropTypes.object.isRequired,
onClickAction: PropTypes.func,
modActions: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
// list of actions (flags, etc) associated with the comments
actions: PropTypes.shape({
ids: PropTypes.arrayOf(PropTypes.string)
}),
suspectWords: PropTypes.arrayOf(PropTypes.string)
}
constructor (props) {
super(props);
@@ -44,22 +60,22 @@ export default class CommentList extends React.Component {
// Add swipe to approve or reject
bindGestures () {
const {actions} = this.props;
const {modActions} = this.props;
this._hammer = new Hammer(this.base);
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
if (actions.indexOf('reject') !== -1) {
if (modActions.indexOf('reject') !== -1) {
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
}
if (actions.indexOf('approve') !== -1) {
if (modActions.indexOf('approve') !== -1) {
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
}
}
// Add key handlers. Each action has one and added j/k for moving around
bindKeyHandlers () {
this.props.actions.filter(action => actions[action].key).forEach(action => {
key(actions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(actions[action].status));
this.props.modActions.filter(action => modActions[action].key).forEach(action => {
key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status));
});
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'));
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'));
@@ -122,7 +138,7 @@ export default class CommentList extends React.Component {
}
render () {
const {singleView, commentIds, comments, users, hideActive, key} = this.props;
const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props;
const {active} = this.state;
return (
@@ -133,14 +149,16 @@ export default class CommentList extends React.Component {
{commentIds.map((commentId, index) => {
const comment = comments[commentId];
const author = users[comment.author_id];
return <Comment comment={comment}
return <Comment
suspectWords={suspectWords}
comment={comment}
author={author}
key={index}
index={index}
onClickAction={this.onClickAction}
onClickShowBanDialog={this.onClickShowBanDialog}
actions={this.props.actions}
actionsMap={actions}
modActions={this.props.modActions}
actionsMap={modActions}
isActive={commentId === active}
hideActive={hideActive} />;
})}
@@ -0,0 +1 @@
export const ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS = 'ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS';
+4 -4
View File
@@ -1,12 +1,12 @@
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
export const COMMENTS_MODERATION_QUEUE_FETCH = 'COMMENTS_MODERATION_QUEUE_FETCH';
export const COMMENTS_MODERATION_QUEUE_FETCH_REQUEST = 'COMMENTS_MODERATION_QUEUE_FETCH_REQUEST';
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED';
export const COMMENT_STREAM_FETCH_SUCCESS = 'COMMENT_STREAM_FETCH_SUCCESS';
export const COMMENT_UPDATE_SUCCESS = 'COMMENT_UPDATE_SUCCESS';
export const COMMENT_UPDATE_FAILED = 'COMMENT_UPDATE_FAILED';
export const COMMENT_STATUS_UPDATE = 'COMMENT_STATUS_UPDATE';
export const COMMENT_STATUS_UPDATE_SUCCESS = 'COMMENT_STATUS_UPDATE_SUCCESS';
export const COMMENT_STATUS_UPDATE_FAILURE = 'COMMENT_STATUS_UPDATE_FAILURE';
export const COMMENT_STATUS_UPDATE_REQUEST = 'COMMENT_STATUS_UPDATE_REQUEST';
export const COMMENT_FLAG = 'COMMENT_FLAG';
@@ -69,110 +69,115 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
}
};
const CommentSettings = ({fetchingSettings, updateSettings, settingsError, settings, errors}) => {
const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => {
if (fetchingSettings) {
/* maybe a spinner here at some point */
return <p>Loading settings...</p>;
}
return <List>
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'pre'} />
</ListItemAction>
<ListItemContent>
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
{lang.t('configure.enable-pre-moderation-text')}
</p>
</ListItemContent>
</ListItem>
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</ListItemAction>
<ListItemContent>
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
<span>{lang.t('configure.comment-count-text-pre')}</span>
<input type='text'
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
htmlFor='charCount'
onChange={updateCharCount(updateSettings, settingsError)}
value={settings.charCount}/>
<span>{lang.t('configure.comment-count-text-post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{lang.t('configure.comment-count-error')}
</span>
}
</p>
</ListItemContent>
</ListItem>
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</ListItemAction>
<ListItemContent>
{lang.t('configure.include-comment-stream')}
<p>
{lang.t('configure.include-comment-stream-desc')}
</p>
</ListItemContent>
</ListItem>
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
<ListItemContent>
<Textfield
onChange={updateInfoBoxContent(updateSettings)}
value={settings.infoBoxContent}
label={lang.t('configure.include-text')}
rows={3}/>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.close-after')}
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
value={getTimeoutAmount(settings.closedTimeout)}
label={lang.t('configure.closed-comments-label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
<Option value={'days'}>{lang.t('configure.days')}</Option>
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
</SelectField>
</div>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.closed-comments-desc')}
<Textfield
onChange={updateClosedMessage(updateSettings)}
value={settings.closedMessage}
label={lang.t('configure.closed-comments-label')}
rows={3}/>
</ListItemContent>
</ListItem>
</List>;
return (
<div>
<h3>{title}</h3>
<List>
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'pre'} />
</ListItemAction>
<ListItemContent>
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
{lang.t('configure.enable-pre-moderation-text')}
</p>
</ListItemContent>
</ListItem>
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
checked={settings.charCountEnable} />
</ListItemAction>
<ListItemContent>
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
<span>{lang.t('configure.comment-count-text-pre')}</span>
<input type='text'
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
htmlFor='charCount'
onChange={updateCharCount(updateSettings, settingsError)}
value={settings.charCount}/>
<span>{lang.t('configure.comment-count-text-post')}</span>
{
errors.charCount &&
<span className={styles.settingsError}>
<br/>
<Icon name="error_outline"/>
{lang.t('configure.comment-count-error')}
</span>
}
</p>
</ListItemContent>
</ListItem>
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
<ListItemAction>
<Checkbox
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
checked={settings.infoBoxEnable} />
</ListItemAction>
<ListItemContent>
{lang.t('configure.include-comment-stream')}
<p>
{lang.t('configure.include-comment-stream-desc')}
</p>
</ListItemContent>
</ListItem>
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
<ListItemContent>
<Textfield
onChange={updateInfoBoxContent(updateSettings)}
value={settings.infoBoxContent}
label={lang.t('configure.include-text')}
rows={3}/>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.close-after')}
<br />
<Textfield
type='number'
pattern='[0-9]+'
style={{width: 50}}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
value={getTimeoutAmount(settings.closedTimeout)}
label={lang.t('configure.closed-comments-label')} />
<div className={styles.configTimeoutSelect}>
<SelectField
label="comments closed time window"
value={getTimeoutMeasure(settings.closedTimeout)}
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
<Option value={'days'}>{lang.t('configure.days')}</Option>
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
</SelectField>
</div>
</ListItemContent>
</ListItem>
<ListItem className={styles.configSettingInfoBox}>
<ListItemContent>
{lang.t('configure.closed-comments-desc')}
<Textfield
onChange={updateClosedMessage(updateSettings)}
value={settings.closedMessage}
label={lang.t('configure.closed-comments-label')}
rows={3}/>
</ListItemContent>
</ListItem>
</List>
</div>
);
};
export default CommentSettings;
@@ -112,12 +112,12 @@
letter-spacing: 0.03em;
}
#bannedWordlist {
#bannedWordlist, #suspectWordlist {
width: 100%;
padding: 10px;
}
.bannedWordHeader {
.wordlistHeader {
font-weight: bold;
font-size:18px;
margin-bottom:3px;
@@ -1,6 +1,11 @@
import React from 'react';
import {connect} from 'react-redux';
import {fetchSettings, updateSettings, saveSettingsToServer} from '../../actions/settings';
import {
fetchSettings,
updateSettings,
saveSettingsToServer,
updateWordlist,
} from '../../actions/settings';
import {
List,
ListItem,
@@ -14,6 +19,7 @@ import translations from '../../translations.json';
import EmbedLink from './EmbedLink';
import CommentSettings from './CommentSettings';
import Wordlist from './Wordlist';
import has from 'lodash/has';
class Configure extends React.Component {
constructor (props) {
@@ -21,7 +27,6 @@ class Configure extends React.Component {
this.state = {
activeSection: 'comments',
wordlist: [],
changed: false,
errors: {}
};
@@ -31,15 +36,6 @@ class Configure extends React.Component {
this.props.dispatch(fetchSettings());
}
componentWillUpdate = (newProps) => {
if ((!this.props.settings
|| !this.props.settings.wordlist)
&& newProps.settings.wordlist
&& newProps.settings.wordlist.length !== 0 ) {
this.setState({wordlist: newProps.settings.wordlist.join(', ')});
}
}
saveSettings = () => {
this.props.dispatch(saveSettingsToServer());
this.setState({changed: false});
@@ -49,15 +45,9 @@ class Configure extends React.Component {
this.setState({activeSection});
}
onChangeWordlist = (event) => {
event.preventDefault();
const newlist = event.target.value;
this.setState({wordlist: newlist.toLowerCase(), changed: true});
this.props.dispatch(updateSettings({
wordlist: newlist.toLowerCase()
.split(',')
.map((word) => word.trim())
}));
onChangeWordlist = (listName, list) => {
this.setState({changed: true});
this.props.dispatch(updateWordlist(listName, list));
}
onSettingUpdate = (setting) => {
@@ -74,46 +64,46 @@ class Configure extends React.Component {
});
}
getSection = (section) => {
getSection (section) {
const pageTitle = this.getPageTitle(section);
switch(section){
case 'comments':
return <CommentSettings
title={pageTitle}
fetchingSettings={this.props.fetchingSettings}
settings={this.props.settings}
updateSettings={this.onSettingUpdate}
errors={this.state.errors}
settingsError={this.onSettingError}/>;
case 'embed':
return <EmbedLink/>;
return <EmbedLink title={pageTitle} />;
case 'wordlist':
return <Wordlist
wordlist={this.state.wordlist}
onChangeWordlist={this.onChangeWordlist}/>;
return has(this, 'props.settings.wordlist')
? <Wordlist
bannedWords={this.props.settings.wordlist.banned}
suspectWords={this.props.settings.wordlist.suspect}
onChangeWordlist={this.onChangeWordlist} />
: <p>loading wordlists</p>;
}
}
getPageTitle = (section) => {
getPageTitle (section) {
switch(section) {
case 'comments':
return lang.t('configure.comment-settings');
case 'embed':
return lang.t('configure.embed-comment-stream');
case 'wordlist':
return lang.t('configure.wordlist');
default:
return '';
}
}
render () {
let pageTitle = this.getPageTitle(this.state.activeSection);
const section = this.getSection(this.state.activeSection);
const showSave = Object.keys(this.state.errors).reduce(
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
if (this.props.fetchingSettings) {
pageTitle += ' - Loading...';
}
return (
<div className={styles.container}>
<div className={styles.leftColumn}>
@@ -151,7 +141,6 @@ class Configure extends React.Component {
</div>
<div className={styles.mainContent}>
<h1>{pageTitle}</h1>
{ this.props.saveFetchingError }
{ this.props.fetchSettingsError }
{ section }
@@ -31,16 +31,21 @@ class EmbedLink extends Component {
render () {
const embedText = `<div id='coralStreamEmbed'></div><script type='text/javascript' src='${window.location.protocol}//pym.nprapps.org/pym.v1.min.js'></script><script>var pymParent = new pym.Parent('coralStreamEmbed', '${window.location.protocol}//${window.location.host}/embed/stream', {title: 'Comments'});</script>`;
return <List>
<ListItem className={styles.configSettingEmbed}>
<p>{lang.t('configure.copy-and-paste')}</p>
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
{lang.t('embedlink.copy')}
</Button>
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
</ListItem>
</List>;
return (
<div>
<h3>{this.props.title}</h3>
<List>
<ListItem className={styles.configSettingEmbed}>
<p>{lang.t('configure.copy-and-paste')}</p>
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
{lang.t('embedlink.copy')}
</Button>
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
</ListItem>
</List>
</div>
);
}
}
@@ -1,21 +1,38 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import styles from './Configure.css';
import {
Card
} from 'react-mdl';
import TagsInput from 'react-tagsinput';
const Wordlist = ({wordlist, onChangeWordlist}) => <Card id={styles.bannedWordlist} shadow={2}>
<p className={styles.bannedWordHeader}>{lang.t('configure.banned-word-header')}</p>
<p className={styles.bannedWordText}>{lang.t('configure.banned-word-text')}</p>
<textarea
rows={5}
type='text'
className={styles.bannedWordInput}
onChange={onChangeWordlist}
value={wordlist}/>
</Card>;
import styles from './Configure.css';
import {Card} from 'react-mdl';
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
<div>
<h3>{lang.t('configure.banned-words-title')}</h3>
<Card id={styles.bannedWordlist} shadow={2}>
<p className={styles.wordlistHeader}>{lang.t('configure.banned-word-header')}</p>
<p className={styles.wordlistDesc}>{lang.t('configure.banned-word-text')}</p>
<TagsInput
value={bannedWords}
inputProps={{placeholder: 'word or phrase'}}
addOnPaste={true}
pasteSplit={data => data.split(',').map(d => d.trim())}
onChange={tags => onChangeWordlist('banned', tags)} />
</Card>
<h3>{lang.t('configure.suspect-words-title')}</h3>
<Card id={styles.suspectWordlist} shadow={2}>
<p className={styles.wordlistHeader}>{lang.t('configure.suspect-word-header')}</p>
<p className={styles.wordlistDesc}>{lang.t('configure.suspect-word-text')}</p>
<TagsInput
value={suspectWords}
inputProps={{placeholder: 'word or phrase'}}
addOnPaste={true}
pasteSplit={data => data.split(',').map(d => d.trim())}
onChange={tags => onChangeWordlist('suspect', tags)} />
</Card>
</div>
);
export default Wordlist;
@@ -13,6 +13,7 @@ import {
fetchModerationQueueComments
} from 'actions/comments';
import {userStatusUpdate} from 'actions/users';
import {fetchSettings} from 'actions/settings';
import styles from './ModerationQueue.css';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -36,6 +37,7 @@ class ModerationQueue extends React.Component {
// Fetch comments and bind singleView key before render
componentWillMount () {
this.props.dispatch(fetchSettings());
this.props.dispatch(fetchModerationQueueComments());
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
@@ -86,7 +88,7 @@ class ModerationQueue extends React.Component {
// Render the tabbed lists moderation queues
render () {
const {comments, users} = this.props;
const {comments, users, settings} = this.props;
const {activeTab, singleView, modalOpen} = this.state;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
@@ -106,6 +108,7 @@ class ModerationQueue extends React.Component {
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<CommentList
suspectWords={settings.settings.wordlist.suspect}
isActive={activeTab === 'pending'}
singleView={singleView}
commentIds={premodIds}
@@ -113,7 +116,7 @@ class ModerationQueue extends React.Component {
users={users.byId}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
actions={['reject', 'approve', 'ban']}
modActions={['reject', 'approve', 'ban']}
loading={comments.loading} />
<BanUserDialog
open={comments.showBanUserDialog}
@@ -123,24 +126,26 @@ class ModerationQueue extends React.Component {
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
<CommentList
suspectWords={settings.settings.wordlist.suspect}
isActive={activeTab === 'rejected'}
singleView={singleView}
commentIds={rejectedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
actions={['approve']}
modActions={['approve']}
loading={comments.loading} />
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
isActive={activeTab === 'rejected'}
suspectWords={settings.settings.wordlist.suspect}
singleView={singleView}
commentIds={flaggedIds}
comments={comments.byId}
users={users.byId}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
actions={['reject', 'approve']}
modActions={['reject', 'approve']}
loading={comments.loading} />
</div>
<ModerationKeysModal open={modalOpen}
@@ -152,6 +157,8 @@ class ModerationQueue extends React.Component {
}
const mapStateToProps = state => ({
actions: state.actions.toJS(),
settings: state.settings.toJS(),
comments: state.comments.toJS(),
users: state.users.toJS()
});
@@ -0,0 +1,24 @@
import {Map, Set} from 'immutable';
import * as types from '../constants/actions';
const initialState = Map({
ids: Set()
});
export default (state = initialState, action) => {
switch (action.type) {
case types.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS: return addActions(state, action);
default:
return state;
}
};
const addActions = (state, action) => {
const ids = action.actions.map(action => action.item_id);
const map = action.actions.reduce((memo, action) => {
memo[action.item_id] = action;
return memo;
}, {});
const newIds = state.get('ids').concat(ids);
return state.merge(map).set('ids', newIds);
};
+2 -2
View File
@@ -24,10 +24,10 @@ const initialState = Map({
// Handle the comment actions
export default (state = initialState, action) => {
switch (action.type) {
case actions.COMMENTS_MODERATION_QUEUE_FETCH: return state.set('loading', true);
case actions.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST: return state.set('loading', true);
case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state);
case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false);
case actions.COMMENT_STATUS_UPDATE: return updateStatus(state, action);
case actions.COMMENT_STATUS_UPDATE_REQUEST: return updateStatus(state, action);
case actions.COMMENT_FLAG: return flag(state, action);
case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action);
case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state);
+4 -2
View File
@@ -4,6 +4,7 @@ import settings from 'reducers/settings';
import community from 'reducers/community';
import users from 'reducers/users';
import auth from 'reducers/auth';
import actions from 'reducers/actions';
import assets from 'reducers/assets';
// Combine all reducers into a main one
@@ -12,6 +13,7 @@ export default combineReducers({
comments,
community,
auth,
users,
assets
actions,
assets,
users
});
+14 -2
View File
@@ -1,8 +1,13 @@
import {Map} from 'immutable';
import {Map, List} from 'immutable';
import * as types from '../actions/settings';
const initialState = Map({
settings: Map(),
settings: Map({
wordlist: Map({
banned: List(),
suspect: List()
})
}),
saveSettingsError: null,
fetchSettingsError: null,
fetchingSettings: false
@@ -18,16 +23,23 @@ export default (state = initialState, action) => {
case types.SAVE_SETTINGS_LOADING: return state.set('fetchingSettings', true).set('saveSettingsError', null);
case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action);
case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action);
case types.WORDLIST_UPDATED: return updateWordlist(state, action);
default: return state;
}
};
// only for updating top-level settings
const updateSettings = (state, action) => {
const s = state.set('fetchingSettings', false).set('fetchSettingsError', null);
const settings = s.get('settings').merge(action.settings);
return s.set('settings', settings);
};
// any nested settings must have a specialized setter
const updateWordlist = (state, action) => {
return state.setIn(['settings', 'wordlist', action.listName], action.wordlist);
};
const saveComplete = (state, action) => {
const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
const settings = s.get('settings').merge(action.settings);
+11 -3
View File
@@ -49,8 +49,12 @@
"comment-settings": "Comment Settings",
"embed-comment-stream": "Embed Comment Stream",
"banned-word-header": "Write the bannned words list",
"banned-word-text": "Comments which contain these words or phrases, not separated by commas and not case sensitive, will be automatically removed from the comment stream.",
"wordlist": "Banned words list",
"suspect-word-header": "Write the suspect words list",
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
"wordlist": "Banned & Suspect Words",
"banned-words-title": "Banned words list",
"suspect-words-title": "Suspect words list",
"save-changes": "Save Changes",
"copy-and-paste": "Copy and paste code below into your CMS to embed your comment box in your articles",
"moderate": "Moderate",
@@ -130,9 +134,13 @@
"include-text": "Incluir tu texto aqui.",
"comment-settings": "Configuración de Comentarios",
"embed-comment-stream": "Colocar Hilo de Comentarios",
"wordlist": "Lista de palabras no permitidas",
"wordlist": "Palabras Suspendidas y Suspechosas",
"banned-word-header": "Escribir las palabras no permitidas",
"suspect-word-header": "Write the suspect words list",
"banned-word-text": "Comentarios que contengan estas palabras o frases, no separadas por comas y en mayusculas o minusuculas, serán automaticamente separadas de los comentarios publicados.",
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
"banned-words-title": "Banned words list",
"suspect-words-title": "Suspect words list",
"save-changes": "Guardar Cambios",
"copy-and-paste": "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus articulos",
"moderate": "Moderar",
+1 -9
View File
@@ -1,15 +1,7 @@
const Setting = require('./models/setting');
const wordlist = require('./services/wordlist');
module.exports = () => Promise.all([
// Upsert the settings object.
Setting
.init({id: '1', moderation: 'pre'})
.then(() => {
// Load in the wordlist now that settings have been init'd.
return wordlist.init();
})
Setting.init({id: '1', moderation: 'pre'})
]);
+10 -1
View File
@@ -38,8 +38,17 @@ ActionSchema.statics.findById = function(id) {
*/
ActionSchema.statics.insertUserAction = (action) => {
// Actions are made unique by using a query that can be reproducable, i.e.,
// not containing user inputable values.
let query = {
action_type: action.action_type,
item_type: action.item_type,
item_id: action.item_id,
user_id: action.user_id
};
// Create/Update the action.
return Action.findOneAndUpdate(action, action, {
return Action.findOneAndUpdate(query, action, {
// Ensure that if it's new, we return the new object created.
new: true,
+9 -5
View File
@@ -25,10 +25,6 @@ const AssetSchema = new Schema({
type: Date,
default: null
},
settings: {
type: Schema.Types.Mixed,
default: null
},
closedAt: {
type: Date,
default: null
@@ -44,7 +40,15 @@ const AssetSchema = new Schema({
subsection: String,
author: String,
publication_date: Date,
modified_date: Date
modified_date: Date,
// This object is used exclusivly for storing settings that are to override
// the base settings from the base Settings object. This is to be accessed
// always after running `rectifySettings` against it.
settings: {
type: Schema.Types.Mixed,
default: null
},
}, {
versionKey: false,
timestamps: {
+8 -1
View File
@@ -3,6 +3,13 @@ const Schema = mongoose.Schema;
const _ = require('lodash');
const cache = require('../services/cache');
const WordlistSchema = new Schema({
banned: [String],
suspect: [String]
}, {
_id: false
});
/**
* SettingSchema manages application settings that get used on front and backend.
* @type {Schema}
@@ -38,7 +45,7 @@ const SettingSchema = new Schema({
type: String,
default: ''
},
wordlist: [String],
wordlist: WordlistSchema,
charCount: {
type: Number,
default: 5000
+2
View File
@@ -129,12 +129,14 @@
"react": "15.3.2",
"react-addons-test-utils": "15.3.2",
"react-dom": "15.3.2",
"react-highlight-words": "^0.6.0",
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
"react-onclickoutside": "^5.7.1",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
"redux": "^3.6.0",
"redux-mock-store": "^1.2.1",
"redux-thunk": "^2.1.0",
+10 -1
View File
@@ -96,7 +96,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
// premod, set it to `premod`.
let status;
if (req.wordlist.matched) {
if (req.wordlist.banned) {
status = Promise.resolve('rejected');
} else {
status = Asset
@@ -134,6 +134,15 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
status,
author_id: req.user.id
}))
.then((comment) => {
if (req.wordlist.suspect) {
return Comment
.addAction(comment.id, null, 'flag', 'body', 'Matched suspect word filters.')
.then(() => comment);
}
return comment;
})
.then((comment) => {
// The comment was created! Send back the created comment.
+1 -1
View File
@@ -119,7 +119,7 @@ router.post('/request-password-reset', (req, res, next) => {
const options = {
subject: 'Password Reset Requested - Talk',
from: 'noreply@coralproject.net',
from: process.env.TALK_SMTP_FROM_ADDRESS,
to: email,
html: resetEmailTemplate({
token,
+1
View File
@@ -1,6 +1,7 @@
const nodemailer = require('nodemailer');
const smtpRequiredProps = [
'TALK_SMTP_FROM_ADDRESS',
'TALK_SMTP_USERNAME',
'TALK_SMTP_PASSWORD',
'TALK_SMTP_HOST'
+171 -132
View File
@@ -8,157 +8,196 @@ const Setting = require('../models/setting');
* The root wordlist object.
* @type {Object}
*/
const wordlist = {
list: [],
enabled: false
};
class Wordlist {
/**
* Loads wordlists in from the naughty-words package based on languages
* selected.
* @param {Array} languages language codes to add to the wordlist
*/
wordlist.init = () => {
return Setting
.retrieve()
.then((settings) => {
constructor() {
this.lists = {
banned: [],
suspect: []
};
}
// Insert the settings wordlist.
wordlist.insert(settings.wordlist);
/**
* Loads wordlists in from the database
*/
load() {
return Setting
.retrieve()
.then((settings) => {
// Insert the settings wordlist.
this.upsert(settings.wordlist);
});
}
/**
* Inserts the wordlist data
* @param {Array} list list of words to be set to the wordlist
*/
upsert(lists) {
// Add the words to this array, but also lowercase the words so that an
// easy comparison can take place.
['banned', 'suspect'].forEach((k) => {
if (!(k in lists)) {
return;
}
this.lists[k] = Wordlist.parseList(lists[k]);
debug(`Added ${lists[k].length} words to the ${k} wordlist.`);
});
};
/**
* Inserts the wordlist data and enables the wordlist.
* @param {Array} list list of words to be added to the wordlist
*/
wordlist.insert = (list) => {
return Promise.resolve(this);
}
// Add the words to this array, but also lowercase the words so that an
// easy comparison can take place.
wordlist.list = _.uniq(wordlist.list.concat(list.map((word) => {
return tokenizer.tokenize(word.toLowerCase());
})));
/**
* Parses the list content.
* @param {Array} list array of words to parse for a list.
* @return {Array} the parsed list
*/
static parseList(list) {
return _.uniq(list.map((word) => tokenizer.tokenize(word.toLowerCase())));
}
debug(`Added ${list.length} words to the wordlist, now the wordlist is ${wordlist.list.length} entries long.`);
/**
* Tests the phrase to see if it contains any of the defined blockwords.
* @param {String} phrase value to check for blockwords.
* @return {Boolean} true if a blockword is found, false otherwise.
*/
match(list, phrase) {
// Enable the wordlist.
wordlist.enabled = true;
// Lowercase the word to ensure that we don't miss a match due to
// capitalization.
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
return Promise.resolve(wordlist);
};
// This will return true in the event that at least one blockword is found
// in the phrase.
return list.some((blockphrase) => {
/**
* Tests the phrase to see if it contains any of the defined blockwords.
* @param {String} phrase value to check for blockwords.
* @return {Boolean} true if a blockword is found, false otherwise.
*/
wordlist.match = (phrase) => {
// First, let's see if we can find the first word in the blockphrase in the
// source phrase.
let idx = lowerPhraseWords.indexOf(blockphrase[0]);
// Lowercase the word to ensure that we don't miss a match due to
// capitalization.
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
if (idx === -1) {
// This will return true in the event that at least one blockword is found
// in the phrase.
return wordlist.list.some((blockphrase) => {
// First, let's see if we can find the first word in the blockphrase in the
// source phrase.
let idx = lowerPhraseWords.indexOf(blockphrase[0]);
if (idx === -1) {
// The first blockword in the blockphrase did not match the source phrase
// anywhere.
return false;
}
// Here we'll quick respond with true in the event that the blockphrase was
// just a single word.
if (blockphrase.length === 1) {
return true;
}
// We found the first word in the source phrase! Lets ensure it matches the
// rest of the blockphrase...
// Check to see if it even has the length to support this word!
if (lowerPhraseWords.length < idx + blockphrase.length - 1) {
// We couldn't possibly have the entire phrase here because we don't have
// enough entries!
return false;
}
for (let i = 1; i < blockphrase.length; i++) {
// Check to see if the next word also matches!
if (lowerPhraseWords[idx + i] !== blockphrase[i]) {
// The first blockword in the blockphrase did not match the source phrase
// anywhere.
return false;
}
// Here we'll quick respond with true in the event that the blockphrase was
// just a single word.
if (blockphrase.length === 1) {
return true;
}
// We found the first word in the source phrase! Lets ensure it matches the
// rest of the blockphrase...
// Check to see if it even has the length to support this word!
if (lowerPhraseWords.length < idx + blockphrase.length - 1) {
// We couldn't possibly have the entire phrase here because we don't have
// enough entries!
return false;
}
for (let i = 1; i < blockphrase.length; i++) {
// Check to see if the next word also matches!
if (lowerPhraseWords[idx + i] !== blockphrase[i]) {
return false;
}
}
// We've walked over all the words of the blockphrase, and haven't had a
// mismatch... It does contain the whole word!
return true;
});
}
/**
* Perform the filtering based on the loaded wordlists.
*/
filter(body, ...fields) {
// Start with the sensible default that the content does not contain
// profanity.
let errors = {};
// Loop over all the fields from the body that we want to check.
for (let i = 0; i < fields.length; i++) {
let field = fields[i];
let phrase = _.get(body, field, false);
// If the field doesn't exist in the body, then it can't be profane!
if (!phrase) {
// Return that there wasn't a profane word here.
continue;
}
// Check if the field contains a banned word.
if (this.match(this.lists.banned, phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`);
errors.banned = ErrContainsProfanity;
// Stop looping through the fields now, we discovered the worst possible
// situation (a banned word).
break;
}
// Check if the field contains a banned word.
if (this.match(this.lists.suspect, phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`);
errors.suspect = ErrContainsProfanity;
// Continue looping through the fields now, we discovered a possible bad
// word (suspect).
continue;
}
}
// We've walked over all the words of the blockphrase, and haven't had a
// mismatch... It does contain the whole word!
return true;
});
};
return errors;
}
/**
* Connect middleware for scanning request bodies for wordlisted words and
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
* it will just set that parameter to false.
* @param {Array} fields selectors for the body to extract the fields to be
* tested
* @return {Function} the Connect middleware
*/
static filter(...fields) {
return (req, res, next) => {
// Create a new instance of the Wordlist.
const wl = new Wordlist();
wl
.load()
.then(() => {
// Perform a filtering operation using the new instance of the
// Wordlist.
req.wordlist = wl.filter(req.body, ...fields);
// Call the next piece of middleware.
next();
});
};
}
}
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new Error('contains profanity');
ErrContainsProfanity.status = 400;
/**
* Connect middleware for scanning request bodies for wordlisted words and
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
* it will just set that parameter to false.
* @param {Array} fields selectors for the body to extract the fields to be
* tested
* @return {Function} the Connect middleware
*/
wordlist.filter = (...fields) => (req, res, next) => {
// Start with the sensible default that the content does not contain
// profanity.
req.wordlist = {
matched: false
};
// If the wordlist isn't enabled, then don't actually perform checking and
// forward the request!
if (!wordlist.enabled) {
return next();
}
// Loop over all the fields from the body that we want to check.
const containsProfanity = fields.some((field) => {
let phrase = _.get(req.body, field, false);
// If the field doesn't exist in the body, then it can't be profane!
if (!phrase) {
// Return that there wasn't a profane word here.
return false;
}
// Check if the field contains a profane word.
if (wordlist.match(phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a wordlisted word/phrase`);
return true;
}
return false;
});
// The body could contain some profanity, address that here.
if (containsProfanity) {
req.wordlist.matched = ErrContainsProfanity;
}
next();
};
module.exports = wordlist;
module.exports = Wordlist;
module.exports.ErrContainsProfanity = ErrContainsProfanity;
+42 -10
View File
@@ -8,22 +8,18 @@ const expect = chai.expect;
chai.should();
chai.use(require('chai-http'));
const wordlist = require('../../../../services/wordlist');
const Comment = require('../../../../models/comment');
const Asset = require('../../../../models/asset');
const Action = require('../../../../models/action');
const User = require('../../../../models/user');
const Setting = require('../../../../models/setting');
const settings = {id: '1', moderation: 'pre'};
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
describe('/api/v1/comments', () => {
// Ensure that the settings are always available.
beforeEach(() => Promise.all([
wordlist.insert(['bad words']),
Setting.init(settings)
]));
beforeEach(() => Setting.init(settings));
describe('#get', () => {
const comments = [{
@@ -168,12 +164,22 @@ describe('/api/v1/comments', () => {
describe('#post', () => {
let asset_id;
let postmod_asset_id;
beforeEach(() => Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => {
beforeEach(() => Promise.all([
Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => {
// Update the asset id.
asset_id = asset.id;
}));
// Update the asset id.
asset_id = asset.id;
}),
Asset.findOrCreateByUrl('https://coralproject.net/section/postmod-article-is-the-best').then((asset) => {
// Update the asset id.
postmod_asset_id = asset.id;
return Asset.overrideSettings(postmod_asset_id, {moderation: 'post'});
}),
]));
it('should create a comment', () => {
return chai.request(app)
@@ -198,6 +204,32 @@ describe('/api/v1/comments', () => {
});
});
it('should create a comment with no status and a flag if it contains a suspected word', () => {
return chai.request(app)
.post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''})
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('status', null);
return Promise.all([
res.body,
Action.findByType('flag', 'comments')
]);
})
.then(([comment, actions]) => {
expect(actions).to.have.length(1);
let action = actions[0];
expect(action).to.have.property('item_id', comment.id);
expect(action).to.have.property('field', 'body');
expect(action).to.have.property('detail', 'Matched suspect word filters.');
});
});
it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => {
return Asset
.findOrCreateByUrl('https://coralproject.net/article1')
+45 -72
View File
@@ -1,54 +1,58 @@
const expect = require('chai').expect;
const wordlist = require('../../services/wordlist');
const Wordlist = require('../../services/wordlist');
describe('wordlist: services', () => {
before(() => wordlist.insert([
'BAD',
'bad',
'how to murder',
'how to kill'
]));
const wordlists = {
banned: [
'cookies',
'how to do bad things',
'how to do really bad things'
],
suspect: [
'do bad things'
]
};
beforeEach(() => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
});
let wordlist = new Wordlist();
describe('#init', () => {
before(() => wordlist.upsert(wordlists));
it('has entries', () => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
expect(wordlist.lists.banned).to.not.be.empty;
expect(wordlist.lists.suspect).to.not.be.empty;
});
});
describe('#match', () => {
const bannedList = Wordlist.parseList(wordlists.banned);
it('does match on a bad word', () => {
[
'how to kill',
'what is bad',
'bad',
'BAD.',
'how to murder',
'How To mUrDer'
'how to do really bad things',
'what is cookies',
'cookies',
'COOKIES.',
'how to do bad things',
'How To do bad things!'
].forEach((word) => {
expect(wordlist.match(word)).to.be.true;
expect(wordlist.match(bannedList, word)).to.be.true;
});
});
it('does not match on a good word', () => {
[
'how to',
'kill',
'bads',
'cookie',
'how to be a great person?',
'how to not kill?'
'how to not do really bad things?'
].forEach((word) => {
expect(wordlist.match(word)).to.be.false;
expect(wordlist.match(bannedList, word)).to.be.false;
});
});
@@ -56,62 +60,31 @@ describe('wordlist: services', () => {
describe('#filter', () => {
it('matches on bodies containing bad words', (done) => {
before(() => wordlist.upsert(wordlists));
let req = {
body: {
content: 'how to kill?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.equal(wordlist.ErrContainsProfanity);
done();
});
it('matches on bodies containing bad words', () => {
let errors = wordlist.filter({
content: 'how to do really bad things?'
}, 'content');
expect(errors).to.have.property('banned', Wordlist.ErrContainsProfanity);
});
it('does not match on bodies not containing bad words', (done) => {
let req = {
body: {
content: 'how to be a great person?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.false;
done();
});
it('does not match on bodies not containing bad words', () => {
let errors = wordlist.filter({
content: 'how to not do really bad things?'
}, 'content');
expect(errors).to.not.have.property('banned');
});
it('does not match on bodies not containing the bad word field', (done) => {
let req = {
body: {
author: 'how to kill?',
content: 'how to be a great person?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.false;
done();
});
it('does not match on bodies not containing the bad word field', () => {
let errors = wordlist.filter({
author: 'how to do really bad things?',
content: 'how to be a great person?'
}, 'content');
expect(errors).to.not.have.property('banned');
});
});
+51
View File
@@ -13,6 +13,57 @@
margin: 0;
background: #fff;
}
/* putting this here until I can get webpack to behave */
.react-tagsinput {
background-color: #fff;
border: 1px solid #ccc;
overflow: hidden;
padding-left: 5px;
padding-top: 5px;
}
.react-tagsinput--focused {
border-color: rgb(142, 76, 65);
}
.react-tagsinput-tag {
background-color: rgb(255, 220, 214);
border-radius: 2px;
border: 1px solid rgb(244, 126, 107);
color: rgb(244, 126, 107);
display: inline-block;
font-family: sans-serif;
font-size: 13px;
font-weight: 400;
margin-bottom: 5px;
margin-right: 5px;
padding: 5px;
}
.react-tagsinput-remove {
cursor: pointer;
font-weight: bold;
color: rgb(101, 24, 23);
}
.react-tagsinput-tag a::before {
content: " ×";
}
.react-tagsinput-input {
background: transparent;
border: 0;
color: #777;
font-family: sans-serif;
font-size: 13px;
font-weight: 400;
margin-bottom: 6px;
margin-top: 1px;
outline: none;
padding: 5px;
width: 90px;
}
</style>
</head>
<body>