Merge branch 'master' of https://github.com/coralproject/talk into stream-e2e

This commit is contained in:
David Jay
2016-11-22 15:04:36 -05:00
64 changed files with 1502 additions and 4209 deletions
+1
View File
@@ -8,3 +8,4 @@ tests/e2e/reports
*.iml
.env*
gaba.cfg
.idea/
+8 -1
View File
@@ -32,7 +32,7 @@ This is a guide to have a common language to talk about "Talk".
* Protected Profile: information about users that only moderators and admins can see
* Queue - Group of items based on a query, aka - moderation queue
* Target - The item/s on which an action is performed..
* Target - The item/s on which an action is performed
## Actions
@@ -64,3 +64,10 @@ Postmoderation means that comments appear on the site _before_ any moderation ac
* New comments appear in comment streams immediately.
* New comments do not appear in moderation queues unless they are flagged by other users.
### Word lists
* Banned words - words that the site never allows in a comment
* Suspect words - words whose usage needs to be approved by a moderator before being shown in the stream
* Approved words - words that are usually Banned or Suspect sitewide, but approved for use in a specific article stream
+3 -3
View File
@@ -21,7 +21,7 @@ if (app.get('env') !== 'test') {
// APP MIDDLEWARE
//==============================================================================
app.set('trust proxy', 'loopback');
app.set('trust proxy', 1);
app.use(helmet());
app.use(bodyParser.json());
app.use('/client', express.static(path.join(__dirname, 'dist')));
@@ -97,7 +97,7 @@ app.use('/api', (err, req, res, next) => {
res.status(err.status || 500);
res.json({
message: err.message,
error: app.get('env') === 'development' ? err : null
error: app.get('env') === 'development' ? err : {}
});
});
@@ -109,7 +109,7 @@ app.use('/', (err, req, res, next) => {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: app.get('env') === 'development' ? err : null
error: app.get('env') === 'development' ? err : {}
});
});
+17 -20
View File
@@ -13,33 +13,30 @@ process.env.DEBUG = process.env.TALK_DEBUG;
const app = require('../app');
const debug = require('debug')('talk:server');
const http = require('http');
const initPromise = require('../init');
const init = require('../init');
const port = normalizePort(process.env.TALK_PORT || '3000');
let server;
initPromise
.then(() => {
/**
* Get port from environment and store in Express.
*/
init().then(() => {
app.set('port', port);
/**
* Get port from environment and store in Express.
*/
app.set('port', port);
/**
* Create HTTP server.
*/
/**
* Create HTTP server.
*/
server = http.createServer(app);
server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
});
/**
* Normalize a port into a number, string, or false.
+1 -1
View File
@@ -8,7 +8,7 @@ import CommunityContainer from 'containers/Community/CommunityContainer';
import LayoutContainer from 'containers/LayoutContainer';
const routes = (
<Route path='admin' component={LayoutContainer}>
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationQueue} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
+19
View File
@@ -0,0 +1,19 @@
import * as actions from '../constants/auth';
import {base, handleResp, getInit} from '../helpers/response';
// Check Login
const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST});
const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin});
const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
fetch(`${base}/auth`, getInit('GET'))
.then(handleResp)
.then(user => {
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
dispatch(checkLoginSuccess(user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
};
+7 -7
View File
@@ -1,11 +1,11 @@
import React from 'react';
import {Button, Icon} from 'react-mdl';
import timeago from 'timeago.js';
import styles from './CommentList.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
import Linkify from 'react-linkify';
import {FabButton} from 'coral-ui';
const linkify = new Linkify();
@@ -14,7 +14,7 @@ export default props => {
const links = linkify.getMatches(props.comment.get('body'));
return (
<li tabindex={props.index} className={`${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<li tabIndex={props.index} className={`${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<i className={`material-icons ${styles.avatar}`}>person</i>
@@ -26,12 +26,12 @@ export default props => {
{links ?
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
<div className={styles.actions}>
{props.actions.map(action => canShowAction(action, props.comment) ? (
<Button className={styles.actionButton}
{props.actions.map((action, i) => canShowAction(action, props.comment) ? (
<FabButton icon={props.actionsMap[action].icon} className={styles.actionButton}
cStyle={action}
key={i}
onClick={() => props.onClickAction(props.actionsMap[action].status, props.comment.get('id'))}
fab colored>
<Icon name={props.actionsMap[action].icon} />
</Button>
/>
) : null)}
</div>
</div>
@@ -112,15 +112,15 @@ export default class CommentList extends React.Component {
}
render () {
const {singleView, commentIds, comments, hideActive} = this.props;
const {singleView, commentIds, comments, hideActive, key} = this.props;
const {active} = this.state;
return (
<ul className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
<ul className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}>
{commentIds.map((commentId, index) => (
<Comment comment={comments.get(commentId)}
ref={el => { if (el && commentId === active) { this._active = el; } }}
key={`${index }comment`}
key={index}
index={index}
onClickAction={this.onClickAction}
actions={this.props.actions}
@@ -1,27 +0,0 @@
import React from 'react';
import {Layout, Navigation, Drawer, Header} from 'react-mdl';
import {Link} from 'react-router';
import styles from './Header.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations.json';
// App header. If we add a navbar it should be here
export default (props) => (
<Layout fixedDrawer>
<Header title='Talk'>
<Navigation>
<Link className={styles.navLink} to={'/admin/'}>{lang.t('configure.moderate')}</Link>
<Link className={styles.navLink} to={'/admin/configure'}>{lang.t('Configure')}</Link>
</Navigation>
</Header>
<Drawer>
<Navigation>
<Link className={styles.navLink} to={'/admin/'}>{lang.t('configure.moderate')}</Link>
<Link className={styles.navLink} to={'/admin/configure'}>{lang.t('configure.Configure')}</Link>
</Navigation>
</Drawer>
{props.children}
</Layout>
);
const lang = new I18n(translations);
@@ -0,0 +1,12 @@
.layout {
max-width: 800px;
margin: 0 auto;
}
.layout h1 {
font-size: 40px;
}
.layout img {
width: 100%;
}
@@ -0,0 +1,13 @@
import React from 'react';
import {Layout} from 'react-mdl';
import styles from './NotFound.css';
export const NotFound = () => (
<Layout fixedDrawer>
<div className={styles.layout} >
<h1>Page Not Found</h1>
<p>The communicorn feels your pain.</p>
<img src="https://coralproject.net/images/communicorn.jpg" alt="Communicorn"/>
</div>
</Layout>
);
@@ -0,0 +1,13 @@
import React from 'react';
import {Layout} from 'react-mdl';
import styles from './NotFound.css';
export const PermissionRequired = () => (
<Layout fixedDrawer>
<div className={styles.layout} >
<h1>Permission Required</h1>
<p>Were sorry, but you dont have access to that page.</p>
<img src="https://coralproject.net/images/communicorn.jpg" alt="Communicorn"/>
</div>
</Layout>
);
@@ -0,0 +1,21 @@
.header {
background: #505050;
overflow: hidden;
}
.header > div {
position: relative;
padding: 0;
width: 1170px;
margin: 0 auto;
}
.active {
background: #232323;
}
.version {
position: absolute;
right: 0;
width: 50px;
}
+10 -8
View File
@@ -1,20 +1,22 @@
import React from 'react';
import {Navigation, Header} from 'react-mdl';
import {Link} from 'react-router';
import {Link, IndexLink} from 'react-router';
import styles from './Header.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import {Logo} from './Logo';
export default () => (
<Header title='Talk'>
<Header className={styles.header}>
<Logo />
<Navigation>
<Link className={styles.navLink} to="/admin">{lang.t('configure.moderate')}</Link>
<Link className={styles.navLink} to="/admin/community">{lang.t('configure.community')}</Link>
<Link className={styles.navLink} to="/admin/configure">{lang.t('configure.configure')}</Link>
<span>
{`v${process.env.VERSION}`}
</span>
<IndexLink className={styles.navLink} to="/admin" activeClassName={styles.active}>{lang.t('configure.moderate')}</IndexLink>
<Link className={styles.navLink} to="/admin/community" activeClassName={styles.active}>{lang.t('configure.community')}</Link>
<Link className={styles.navLink} to="/admin/configure" activeClassName={styles.active}>{lang.t('configure.configure')}</Link>
</Navigation>
<div className={styles.version}>
{`v${process.env.VERSION}`}
</div>
</Header>
);
@@ -0,0 +1,18 @@
.logo h1 {
color: #272727;
font-size: 20px;
padding: 0 30px;
}
.logo span {
display: inline-block;
margin-left: 10px;
font-size: 18px;
vertical-align: middle;
}
.logo {
background: #E5E5E5;
}
@@ -0,0 +1,12 @@
import React from 'react';
import styles from './Logo.css';
import {CoralLogo} from 'coral-ui';
export const Logo = () => (
<div className={styles.logo}>
<h1>
<CoralLogo stroke="#E5E5E5" />
<span>Talk</span>
</h1>
</div>
);
+7
View File
@@ -0,0 +1,7 @@
export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
@@ -1,19 +1,41 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {Layout} from '../components/ui/Layout';
import {checkLogin} from '../actions/auth';
import {NotFound} from '../components/NotFound';
import {PermissionRequired} from '../components/PermissionRequired';
class LayoutContainer extends Component {
componentWillMount () {
this.props.checkLogin();
}
render () {
const {isAdmin, loggedIn} = this.props.auth;
if (!loggedIn) {
return <NotFound />;
}
if (!isAdmin && loggedIn) {
return <PermissionRequired />;
}
return <Layout {...this.props} />;
}
}
LayoutContainer.propTypes = {};
const mapStateToProps = () => ({});
const mapStateToProps = state => ({
auth: state.auth.toJS()
});
const mapDispatchToProps = (dispatch) => ({dispatch});
const mapDispatchToProps = dispatch => ({
checkLogin: () => dispatch(checkLogin()),
});
export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer);
export default connect(
mapStateToProps,
mapDispatchToProps
)(LayoutContainer);
@@ -1,4 +1,3 @@
@custom-media --big-viewport (min-width: 780px);
.listContainer {
@@ -6,8 +5,13 @@
margin: 0 auto;
}
.tabBar {
background: #9E9E9E;
}
.tab {
flex: 1;
color: white;
}
@media (--big-viewport) {
@@ -61,9 +61,9 @@ class ModerationQueue extends React.Component {
return (
<div>
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
<div className='mdl-tabs__tab-bar'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#pending' onClick={() => this.onTabClick('pending')}
className={`mdl-tabs__tab is-active ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
<a href='#rejected' onClick={() => this.onTabClick('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
+8 -5
View File
@@ -1,12 +1,15 @@
export const base = '/api/v1';
export const getInit = (method, body) => {
const headers = new Headers({
'Content-Type': 'application/json',
'Accept': 'application/json'
});
let init = {
method,
headers: new Headers({
'Content-Type': 'application/json',
'Accept': 'application/json'
}),
credentials: 'same-origin'
};
const init = {method, headers};
if (method.toLowerCase() !== 'get') {
init.body = JSON.stringify(body);
}
+28
View File
@@ -0,0 +1,28 @@
import {Map} from 'immutable';
import * as actions from '../constants/auth';
const initialState = Map({
loggedIn: false,
user: null,
isAdmin: false
});
export default function auth (state = initialState, action) {
switch (action.type) {
case actions.CHECK_LOGIN_FAILURE:
return state
.set('loggedIn', false)
.set('user', null);
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('loggedIn', true)
.set('isAdmin', action.isAdmin)
.set('user', action.user);
case actions.LOGOUT_SUCCESS:
return state
.set('loggedIn', false)
.set('user', null);
default :
return state;
}
}
+3 -1
View File
@@ -2,11 +2,13 @@ import {combineReducers} from 'redux';
import comments from 'reducers/comments';
import settings from 'reducers/settings';
import community from 'reducers/community';
import auth from 'reducers/auth';
// Combine all reducers into a main one
export default combineReducers({
settings,
comments,
community
community,
auth
});
@@ -34,7 +34,11 @@ export default store => next => action => {
// Get comments to fill each of the three lists on the mod queue
const fetchModerationQueueComments = store =>
Promise.all([fetch('/api/v1/queue/comments/pending'), fetch('/api/v1/comments/status/rejected'), fetch('/api/v1/comments/action/flag')])
Promise.all([
fetch('/api/v1/queue/comments/pending'),
fetch('/api/v1/comments?status=rejected'),
fetch('/api/v1/comments?action=flag')
])
.then(res => Promise.all(res.map(r => r.json())))
.then(res => {
res[2] = res[2].map(comment => { comment.flagged = true; return comment; });
@@ -48,7 +52,7 @@ Promise.all([fetch('/api/v1/queue/comments/pending'), fetch('/api/v1/comments/st
const updateComment = (store, comment) => {
fetch(`/api/v1/comments/${comment.get('id')}/status`, {
method: 'POST',
method: 'PUT',
headers: jsonHeader,
body: JSON.stringify({status: comment.get('status')})
})
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -61,8 +61,8 @@ class CommentStream extends Component {
// Set up messaging between embedded Iframe an parent component
// Using recommended Pym init code which violates .eslint standards
const pym = new Pym.Child({polling: 100});
const path = /https?\:\/\/([^?]+)/.exec(pym.parentUrl)[1];
this.props.getStream(path);
const path = /https?\:\/\/([^?]+)/.exec(pym.parentUrl);
this.props.getStream(path && path[1] || window.location);
}
render () {
@@ -84,8 +84,9 @@ class CommentStream extends Component {
const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0];
const rootItem = this.props.items.assets && this.props.items.assets[rootItemId];
const {loggedIn, user} = this.props.auth;
return <div>
const {actions, users, comments} = this.props.items;
const {loggedIn, user, showSignInDialog} = this.props.auth;
return <div className={showSignInDialog ? 'expandForSignin' : ''}>
{
rootItem
? <div>
@@ -105,16 +106,16 @@ class CommentStream extends Component {
id={rootItemId}
premod={this.props.config.moderation}
reply={false}
canPost={loggedIn}
author={user}
/>
{!loggedIn && <SignInContainer />}
</div>
{
rootItem.comments && rootItem.comments.map((commentId) => {
const comment = this.props.items.comments[commentId];
const comment = comments[commentId];
return <div className="comment" key={commentId}>
<hr aria-hidden={true}/>
<AuthorName name={comment.username}/>
<AuthorName author={users[comment.author_id]}/>
<PubDate created_at={comment.created_at}/>
<Content body={comment.body}/>
<div className="commentActionsLeft">
@@ -124,7 +125,7 @@ class CommentStream extends Component {
<LikeButton
addNotification={this.props.addNotification}
id={commentId}
like={this.props.items.actions[comment.like]}
like={actions[comment.like]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
@@ -135,7 +136,7 @@ class CommentStream extends Component {
<FlagButton
addNotification={this.props.addNotification}
id={commentId}
flag={this.props.items.actions[comment.flag]}
flag={actions[comment.flag]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
@@ -151,6 +152,7 @@ class CommentStream extends Component {
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
id={rootItemId}
author={user}
parent_id={commentId}
premod={this.props.config.moderation}
showReply={comment.showReply}/>
@@ -160,13 +162,13 @@ class CommentStream extends Component {
let reply = this.props.items.comments[replyId];
return <div className="reply" key={replyId}>
<hr aria-hidden={true}/>
<AuthorName name={reply.username}/>
<AuthorName author={users[comment.author_id]}/>
<PubDate created_at={reply.created_at}/>
<Content body={reply.body}/>
<div className="replyActionsLeft">
<ReplyButton
updateItem={this.props.updateItem}
id={replyId}/>
parent_id={reply.parent_id}/>
<LikeButton
addNotification={this.props.addNotification}
id={replyId}
@@ -188,8 +190,8 @@ class CommentStream extends Component {
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
<PermalinkButton
comment_id={reply.comment_id}
asset_id={reply.comment_id}
comment_id={reply.parent_id}
asset_id={rootItemId}
/>
</div>
</div>;
+6 -2
View File
@@ -3,8 +3,12 @@ body {
font-family: 'Open Sans', sans-serif;
width: 100%;
font-size: 12px;
margin: 0;
min-height: 700px;
margin: 0px;
padding: 0px 0px 50px 0px;
}
.expandForSignin {
min-height: 550px;
}
button {
+23 -6
View File
@@ -3,6 +3,7 @@ import translations from './../translations';
const lang = new I18n(translations);
import * as actions from '../constants/auth';
import {base, handleResp, getInit} from '../helpers/response';
import {addItem} from './items';
// Dialog Actions
export const showSignInDialog = () => ({type: actions.SHOW_SIGNIN_DIALOG});
@@ -19,8 +20,8 @@ export const cleanState = () => ({type: actions.CLEAN_STATE});
// Sign In Actions
const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
const signInSuccess = (user) => ({type: actions.FETCH_SIGNIN_SUCCESS, user});
const signInFailure = (error) => ({type: actions.FETCH_SIGNIN_FAILURE, error});
const signInSuccess = user => ({type: actions.FETCH_SIGNIN_SUCCESS, user});
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
export const fetchSignIn = (formData) => dispatch => {
dispatch(signInRequest());
@@ -29,6 +30,7 @@ export const fetchSignIn = (formData) => dispatch => {
.then(({user}) => {
dispatch(hideSignInDialog());
dispatch(signInSuccess(user));
dispatch(addItem(user, 'users'));
})
.catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError'))));
};
@@ -54,8 +56,10 @@ export const facebookCallback = (err, data) => dispatch => {
return;
}
try {
dispatch(signInFacebookSuccess(JSON.parse(data)));
const user = JSON.parse(data);
dispatch(signInFacebookSuccess(user));
dispatch(hideSignInDialog());
dispatch(addItem(user, 'users'));
} catch (err) {
dispatch(signInFacebookFailure(err));
return;
@@ -87,9 +91,9 @@ const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUE
const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS});
const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE});
export const fetchForgotPassword = () => dispatch => {
dispatch(forgotPassowordRequest());
fetch(`${base}/user/request-password-reset`, getInit('POST'))
export const fetchForgotPassword = email => dispatch => {
dispatch(forgotPassowordRequest(email));
fetch(`${base}/user/request-password-reset`, getInit('POST', {email}))
.then(handleResp)
.then(() => dispatch(forgotPassowordSuccess()))
.catch(error => dispatch(forgotPassowordFailure(error)));
@@ -114,3 +118,16 @@ export const logout = () => dispatch => {
export const validForm = () => ({type: actions.VALID_FORM});
export const invalidForm = error => ({type: actions.INVALID_FORM, error});
// Check Login
const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST});
const checkLoginSuccess = user => ({type: actions.CHECK_LOGIN_SUCCESS, user});
const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
fetch(`${base}/auth`, getInit('GET'))
.then(handleResp)
.then(user => dispatch(checkLoginSuccess(user)))
.catch(error => dispatch(checkLoginFailure(error)));
};
+16 -12
View File
@@ -15,7 +15,7 @@ const getInit = (method, body) => {
};
const init = {method, headers};
if (method.toLowerCase() !== 'get') {
if (body) {
init.body = JSON.stringify(body);
}
@@ -23,6 +23,9 @@ const getInit = (method, body) => {
};
const responseHandler = response => {
if (response.status === 204) {
return;
}
return response.ok ? response.json() : Promise.reject(`${response.status} ${response.statusText}`);
};
/**
@@ -103,8 +106,16 @@ export function getStream (assetUrl) {
/* Add items to the store */
const itemTypes = Object.keys(json);
for (let i = 0; i < itemTypes.length; i++ ) {
for (let j = 0; j < json[itemTypes[i]].length; j++ ) {
dispatch(addItem(json[itemTypes[i]][j], itemTypes[i]));
if (itemTypes[i] === 'actions') {
for (let j = 0; j < json[itemTypes[i]].length; j++ ) {
let action = json[itemTypes[i]][j];
action.id = `${action.action_type}_${action.item_id}`;
dispatch(addItem(action, 'actions'));
}
} else {
for (let j = 0; j < json[itemTypes[i]].length; j++ ) {
dispatch(addItem(json[itemTypes[i]][j], itemTypes[i]));
}
}
}
@@ -199,8 +210,6 @@ export function postItem (item, type, id) {
};
}
//http://localhost:16180/v1/action/flag/user/user_89654/on/item/87e418c5-aafb-4eb7-9ce4-78f28793782a
/*
* PostAction
* Posts an action to an item
@@ -243,14 +252,9 @@ export function postAction (item_id, action_type, user_id, item_type) {
*
*/
export function deleteAction (item_id, action_type, user_id, item_type) {
export function deleteAction (action_id) {
return () => {
const action = {
action_type,
user_id
};
return fetch(`/api/v1/${item_type}/${item_id}/actions`, getInit('DELETE', action))
return fetch(`/api/v1/actions/${action_id}`, {method: 'DELETE'})
.then(responseHandler);
};
}
+4
View File
@@ -27,3 +27,7 @@ export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
export const INVALID_FORM = 'INVALID_FORM';
export const VALID_FORM = 'VALID_FORM';
export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
+22 -1
View File
@@ -8,6 +8,8 @@ const initialState = Map({
showSignInDialog: false,
view: 'SIGNIN',
error: '',
passwordRequestSuccess: null,
passwordRequestFailure: null,
successSignUp: false
});
@@ -22,6 +24,8 @@ export default function auth (state = initialState, action) {
showSignInDialog: false,
view: 'SIGNIN',
error: '',
passwordRequestFailure: null,
passwordRequestSuccess: null,
successSignUp: false
}));
case actions.CHANGE_VIEW :
@@ -33,6 +37,14 @@ export default function auth (state = initialState, action) {
case actions.FETCH_SIGNIN_REQUEST:
return state
.set('isLoading', true);
case actions.CHECK_LOGIN_FAILURE:
return state
.set('loggedIn', false)
.set('user', null);
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('loggedIn', true)
.set('user', action.user);
case actions.FETCH_SIGNIN_SUCCESS:
return state
.set('loggedIn', true)
@@ -40,7 +52,8 @@ export default function auth (state = initialState, action) {
case actions.FETCH_SIGNIN_FAILURE:
return state
.set('isLoading', false)
.set('error', action.error);
.set('error', action.error)
.set('user', null);
case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS:
return state
.set('user', action.user)
@@ -70,6 +83,14 @@ export default function auth (state = initialState, action) {
case actions.VALID_FORM:
return state
.set('error', '');
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state
.set('passwordRequestFailure', null)
.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email');
case actions.FETCH_FORGOT_PASSWORD_FAILURE:
return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
default :
return state;
}
@@ -1,9 +1,9 @@
import React from 'react';
const packagename = 'coral-plugin-author-name';
const AuthorName = ({name}) =>
const AuthorName = ({author}) =>
<div className={`${packagename}-text`}>
{name}
{author && author.displayName}
</div>;
export default AuthorName;
+6 -5
View File
@@ -12,7 +12,8 @@ class CommentBox extends Component {
id: PropTypes.string,
comments: PropTypes.array,
reply: PropTypes.bool,
canPost: PropTypes.bool
canPost: PropTypes.bool,
currentUser: PropTypes.object
}
state = {
@@ -21,11 +22,11 @@ class CommentBox extends Component {
}
postComment = () => {
const {postItem, updateItem, id, parent_id, addNotification, appendItemArray, premod} = this.props;
const {postItem, updateItem, id, parent_id, addNotification, appendItemArray, premod, author} = this.props;
let comment = {
body: this.state.body,
asset_id: id,
username: this.state.username
author_id: author.id
};
let related;
let parent_type;
@@ -52,7 +53,7 @@ class CommentBox extends Component {
}
render () {
const {styles, reply, canPost} = this.props;
const {styles, reply, author} = this.props;
// How to handle language in plugins? Should we have a dependency on our central translation file?
return <div>
<div
@@ -73,7 +74,7 @@ class CommentBox extends Component {
rows={3}/>
</div>
<div className={`${name}-button-container`}>
{ canPost && (
{ author && (
<button
className={`${name}-button`}
style={styles && styles.button}
+10 -6
View File
@@ -4,18 +4,22 @@ import translations from './translations.json';
const name = 'coral-plugin-flags';
const FlagButton = ({flag, id, postAction, deleteAction, addItem, updateItem, addNotification}) => {
const FlagButton = ({flag, id, postAction, deleteAction, addItem, updateItem, addNotification, currentUser}) => {
const flagged = flag && flag.current_user;
const onFlagClick = () => {
if (!currentUser) {
return;
}
if (!flagged) {
postAction(id, 'flag', '123', 'comments')
postAction(id, 'flag', currentUser.id, 'comments')
.then((action) => {
addItem({...action, current_user:true}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, 'comments');
});
addNotification('success', lang.t('flag-notif'));
} else {
deleteAction(id, 'flag', '123', 'comments')
deleteAction(flagged.id)
.then(() => {
updateItem(id, 'flag', '', 'comments');
});
@@ -31,7 +35,7 @@ const FlagButton = ({flag, id, postAction, deleteAction, addItem, updateItem, ad
: <span className={`${name}-button-text`}>{lang.t('flag')}</span>
}
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged && styles.flaggedIcon}
style={flagged ? styles.flaggedIcon : {}}
aria-hidden={true}>flag</i>
</button>
</div>;
+9 -5
View File
@@ -4,17 +4,21 @@ import translations from './translations.json';
const name = 'coral-plugin-flags';
const LikeButton = ({like, id, postAction, deleteAction, addItem, updateItem}) => {
const LikeButton = ({like, id, postAction, deleteAction, addItem, updateItem, currentUser}) => {
const liked = like && like.current_user;
const onLikeClick = () => {
if (!currentUser) {
return;
}
if (!liked) {
postAction(id, 'like', '123', 'comments')
postAction(id, 'like', currentUser.id, 'comments')
.then((action) => {
addItem({id: action.id, current_user:true, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, 'comments');
});
} else {
deleteAction(id, 'like', '123', 'comments')
deleteAction(liked.id)
.then(() => {
updateItem(like.id, 'count', like.count - 1, 'actions');
updateItem(like.id, 'current_user', false, 'actions');
@@ -5,25 +5,56 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const ForgotContent = ({changeView, ...props}) => (
<div>
<div className={styles.header}>
<h1>{lang.t('signIn.recoverPassword')}</h1>
</div>
<form onSubmit={(e) => {e.preventDefault(); props.fetchForgotPassword();}}>
<div className={styles.formField}>
<label htmlFor="email">{lang.t('signIn.email')}</label>
<input type="text" id="email" />
class ForgotContent extends React.Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit (e) {
e.preventDefault();
this.props.fetchForgotPassword(this.emailInput.value);
}
render () {
const {changeView, auth} = this.props;
const {passwordRequestSuccess, passwordRequestFailure} = auth;
return (
<div>
<div className={styles.header}>
<h1>{lang.t('signIn.recoverPassword')}</h1>
</div>
<form onSubmit={this.handleSubmit}>
<div className={styles.formField}>
<label htmlFor="email">{lang.t('signIn.email')}</label>
<input
ref={input => this.emailInput = input}
type="text"
id="email"
name="email" />
</div>
<Button type="submit" cStyle="black" className={styles.signInButton}>
{lang.t('signIn.recoverPassword')}
</Button>
{
passwordRequestSuccess
? <p className={styles.passwordRequestSuccess}>{passwordRequestSuccess}</p>
: null
}
{
passwordRequestFailure
? <p className={styles.attention}>{passwordRequestFailure}</p>
: null
}
</form>
<div className={styles.footer}>
<span>{lang.t('signIn.needAnAccount')} <a onClick={() => changeView('SIGNUP')}>{lang.t('signIn.register')}</a></span>
<span>{lang.t('signIn.alreadyHaveAnAccount')} <a onClick={() => changeView('SIGNIN')}>{lang.t('signIn.signIn')}</a></span>
</div>
</div>
<Button type="submit" cStyle="black" className={styles.signInButton}>
{lang.t('signIn.recoverPassword')}
</Button>
</form>
<div className={styles.footer}>
<span>{lang.t('signIn.needAnAccount')} <a onClick={() => changeView('SIGNUP')}>{lang.t('signIn.register')}</a></span>
<span>{lang.t('signIn.alreadyHaveAnAccount')} <a onClick={() => changeView('SIGNIN')}>{lang.t('signIn.signIn')}</a></span>
</div>
</div>
);
);
}
}
export default ForgotContent;
+13 -2
View File
@@ -106,7 +106,7 @@ input.error{
.userBox a {
color: #2c69b6;
cursor: pointer;
margin: 0 5px;
margin: 0px;
}
.attention {
@@ -128,4 +128,15 @@ input.error{
.action {
margin-top: 15px;
}
}
.passwordRequestSuccess {
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
}
.passwordRequestFailure {
border: 1px solid orange;
background-color: 1px solid coral
}
@@ -18,7 +18,8 @@ import {
fetchForgotPassword,
facebookCallback,
invalidForm,
validForm
validForm,
checkLogin
} from '../../coral-framework/actions/auth';
class SignInContainer extends Component {
@@ -43,6 +44,10 @@ class SignInContainer extends Component {
this.addError = this.addError.bind(this);
}
componentWillMount () {
this.props.checkLogin();
}
componentDidMount() {
window.authCallback = this.props.facebookCallback;
const {formData} = this.state;
@@ -147,6 +152,7 @@ const mapStateToProps = state => ({
});
const mapDispatchToProps = dispatch => ({
checkLogin: () => dispatch(checkLogin()),
facebookCallback: (err, data) => dispatch(facebookCallback(err, data)),
fetchSignUp: formData => dispatch(fetchSignUp(formData)),
fetchSignIn: formData => dispatch(fetchSignIn(formData)),
+42
View File
@@ -0,0 +1,42 @@
import React, {PropTypes} from 'react';
const CoralLogo = ({height = '30px', width = '30px', stroke = '#FFFFFF'}) => (
<svg width={width} height={height} viewBox='0 0 381 391' version='1.1 xmlns=http://www.w3.org/2000/svg xmlns:xlink=http://www.w3.org/1999/xlink'>
<g stroke='none' strokeWidth='1' fill='none' fillRule='evenodd'>
<g id='Wordmark-Round' transform='translate(-1833.000000, -411.000000)' stroke={stroke} strokeWidth='22' strokeLinecap='round' strokeLinejoin='round'>
<g id='coralProjectLogo-2-Copy-2' transform='translate(1842.000000, 421.000000)'>
<g id='Layer_2' transform='translate(2.268750, 1.133903)'>
<rect id='Rectangle-1' fill='#F47E6B' x='0' y='0' width='358.4625' height='368.518519' rx='40'>
</rect>
<path d='M0.226875,105.679772 C13.7259375,122.688319 41.29125,131.986325 56.71875,116.792023 C67.0415625,106.586895 62.5040625,93.0934473 76.910625,63.3851852 C83.94375,48.7578348 90.523125,38.3259259 101.980313,37.5321937 C115.139063,36.6250712 131.814375,50.3452991 134.31,69.3948718 C137.826563,95.1344729 115.479375,105.339601 121.15125,123.822222 C127.390313,144.119088 157.110938,140.377208 166.52625,165.096296 C170.042813,174.394302 171.630938,190.382336 163.463438,198.319658 C149.170313,212.266667 120.584063,186.073504 80.7675,198.319658 C73.280625,200.587464 56.3784375,205.803419 51.500625,219.523647 C46.73625,233.130484 55.584375,251.046154 67.60875,260.797721 C93.245625,281.661538 119.79,254.674644 159.379688,271.909972 C181.840313,281.661538 203.053125,303.319088 208.725,330.305983 C211.674375,344.593162 209.6325,356.952707 207.704063,364.549858' id='Shape'>
</path>
</g>
<g id='Layer_3' transform='translate(43.106250, 289.145299)'>
<path d='M90.4096875,72.5698006 C78.8390625,41.3874644 64.9996875,31.4091168 54.1096875,28.234188 C41.8584375,24.7190883 30.7415625,29.0279202 17.8096875,21.2039886 C8.394375,15.4210826 3.403125,6.57663818 0.680625,0'id='Shape'></path>
</g><g id='Layer_4' transform='translate(220.068750, 209.772080)'>
<path d='M81.7884375,152.963533 C74.9821875,122.007977 61.8234375,104.77265 50.593125,94.5675214 C31.8759375,77.6723647 14.746875,77.1054131 5.218125,57.2621083 C4.4240625,55.5612536 -5.7853125,33.4501425 5.218125,17.008547 C14.52,3.06153846 33.350625,1.47407407 41.518125,0.907122507 C64.3190625,-0.907122507 73.280625,9.52478632 100.959375,13.039886 C117.180938,15.0809117 130.793438,13.4934473 139.30125,12.0193732'id='Shape'></path>
</g>
<g id='Layer_5' transform='translate(285.862500, 289.145299)'>
<path d='M74.415,2.04102564 C66.3609375,0.793732194 51.046875,-0.566951567 33.12375,5.1025641 C17.5828125,9.97834758 6.80625,18.0290598 0.9075,23.2450142'id='Shape'></path>
</g>
<g id='Layer_6' transform='translate(174.693750, 1.133903)'>
<path d='M184.109063,151.035897 C170.950313,174.507692 158.699063,180.290598 149.850938,181.311111 C133.85625,183.011966 129.091875,168.724786 104.475938,163.168661 C79.2928125,157.499145 72.2596875,169.745299 52.0678125,163.168661 C30.0609375,155.911681 18.7171875,134.821083 12.705,123.822222 C-1.588125,97.4022792 -0.3403125,71.6626781 0.5671875,51.2524217 C1.588125,29.4814815 6.125625,12.0193732 9.6421875,0.907122507'id='Shape'></path>
<path d='M183.541875,69.3948718 C170.496563,56.6951567 157.564688,45.8096866 149.28375,39.0062678 C143.385,34.1304843 134.990625,33.2233618 128.297813,36.8518519 C128.070938,36.9652422 127.844063,37.0786325 127.730625,37.1920228 C122.739375,40.1401709 119.449688,45.3561254 118.8825,51.1390313 C118.201875,59.0763533 117.0675,71.4358974 114.912188,82.8883191 C114.118125,87.0837607 107.085,103.865527 89.7290625,110.668946 C77.2509375,115.544729 62.0503125,110.668946 53.4290625,101.597721 C40.38375,87.7641026 44.8078125,66.9002849 47.416875,55.2210826 C53.5425,26.760114 73.3940625,9.07122507 82.6959375,1.81424501'id='Shape'></path>
</g>
<g id='Layer_7' transform='translate(3.403125, 179.156695)'>
<path d='M0.1134375,0.226780627 C2.949375,6.34985755 8.394375,16.1014245 18.2634375,25.3994302 C27.67875,34.2438746 37.3209375,39.0062678 43.4465625,41.5008547'id='Shape'></path>
</g>
</g>
</g>
</g>
</svg>
);
CoralLogo.propTypes = {
height: PropTypes.string,
width: PropTypes.string,
stroke: PropTypes.string
};
export default CoralLogo;
+9
View File
@@ -0,0 +1,9 @@
.type--approve {
background: #00796b;
color: rgba(255, 255, 255, 0.901961);
}
.type--reject {
background: #d32f2f ;
color: rgba(255, 255, 255, 0.901961);
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import styles from './FabButton.css';
import {FABButton, Icon} from 'react-mdl';
const FabButton = ({cStyle = 'local', icon, className, ...props}) => (
<FABButton className={`${styles[`type--${cStyle}`]} ${className ? className : ''}`} {...props}>
<Icon name={icon} />
</FABButton>
);
export default FabButton;
+2
View File
@@ -1 +1,3 @@
export {default as Dialog} from './components/Dialog';
export {default as CoralLogo} from './components/CoralLogo';
export {default as FabButton} from './components/FabButton';
+12 -3
View File
@@ -1,6 +1,15 @@
const Setting = require('./models/setting');
const wordlist = require('./services/wordlist');
const defaults = {id: '1', moderation: 'pre'};
module.exports = Setting.init(defaults);
module.exports = () => Promise.all([
// presumably this file will grow, which is why I've broken it out.
// 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();
})
]);
+52 -27
View File
@@ -42,31 +42,58 @@ ActionSchema.statics.findByItemIdArray = function(item_ids) {
* @param {String} ids array of user identifiers (uuid)
*/
ActionSchema.statics.getActionSummaries = function(item_ids) {
return ActionSchema.statics.findByItemIdArray(item_ids).then((rawActions) => {
// Create an object with a count of each action type for each item
const actionSummaries = rawActions.reduce((actionObj, action) => {
if (!actionObj[action.item_id]) {
actionObj[action.item_id] = {
id: action.id,
item_type: action.item_type,
action_type: action.action_type,
count: 1,
current_user: false //Update this later when we have authentication
};
} else {
actionObj[action.item_id].count ++;
}
return actionObj;
}, {});
return Action.aggregate([
{
// Return an array extracted from the actionSummaries object
return Object.keys(actionSummaries).reduce((actions, key) => {
let actionSummary = actionSummaries[key];
actionSummary.item_id = key;
actions.push(actionSummary);
return actions;
}, []);
});
// only grab items that match the specified item id's
$match: {
item_id: {
$in: item_ids
}
}
},
{
$group: {
// group unique documents by these properties, we are leveraging the
// fact that each uuid is completly unique.
_id: {
item_id: '$item_id',
action_type: '$action_type'
},
// and sum up all actions matching the above grouping criteria
count: {
$sum: 1
},
// we are leveraging the fact that each uuid is completly unique and
// just grabbing the last instance of the item type here.
item_type: {
$last: '$item_type'
}
}
},
{
$project: {
// suppress the _id field
_id: false,
// map the fields from the _id grouping down a level
item_id: '$_id.item_id',
action_type: '$_id.action_type',
// map the field directly
count: '$count',
item_type: '$item_type',
// set the current user to false here
current_user: {$literal: false}
}
}
])
.exec();
};
/*
@@ -90,9 +117,7 @@ ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_typ
return Action.find({
'action_type': action_type,
'item_type': item_type
},
'item_id'
);
}, 'item_id');
};
const Action = mongoose.model('Action', ActionSchema);
+44 -36
View File
@@ -17,7 +17,6 @@ const CommentSchema = new Schema({
},
asset_id: String,
author_id: String,
username: String,
status: {
type: String,
enum: ['accepted', 'rejected', ''],
@@ -31,19 +30,6 @@ const CommentSchema = new Schema({
}
});
//==============================================================================
// New Statics
//==============================================================================
/**
* Create a comment.
* @param {String} body content of comment
*/
CommentSchema.statics.new = function(body, author_id, asset_id, parent_id, status, username) {
let comment = new Comment({body, author_id, asset_id, parent_id, status, username});
return comment.save();
};
//==============================================================================
// Find Statics
//==============================================================================
@@ -51,7 +37,8 @@ CommentSchema.statics.new = function(body, author_id, asset_id, parent_id, statu
/**
* Finds a comment by the id.
* @param {String} id identifier of comment (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findById = function(id) {
return Comment.findOne({'id': id});
};
@@ -59,7 +46,8 @@ CommentSchema.statics.findById = function(id) {
/**
* Finds ALL the comments by the asset_id.
* @param {String} asset_id identifier of the asset which owns this comment (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findByAssetId = function(asset_id) {
return Comment.find({asset_id});
};
@@ -68,7 +56,8 @@ CommentSchema.statics.findByAssetId = function(asset_id) {
* Finds the accepted comments by the asset_id.
* get the comments that are accepted.
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findAcceptedByAssetId = function(asset_id) {
return Comment.find({asset_id: asset_id, status:'accepted'});
};
@@ -76,7 +65,8 @@ CommentSchema.statics.findAcceptedByAssetId = function(asset_id) {
/**
* Finds the new and accepted comments by the asset_id.
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) {
return Comment.find({asset_id: asset_id, status: {'$in': ['accepted', '']}});
};
@@ -84,7 +74,8 @@ CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) {
/**
* Find comments by an action that was performed on them.
* @param {String} action_type the type of action that was performed on the comment
*/
* @return {Promise}
*/
CommentSchema.statics.findByActionType = function(action_type) {
return Action
.findCommentsIdByActionType(action_type, 'comment')
@@ -99,50 +90,54 @@ CommentSchema.statics.findByActionType = function(action_type) {
* Find not moderated comments by an action that was performed on them.
* @param {String} action_type the type of action that was performed on the comment
* @param {String} status the status of the comment to search for
*/
* @return {Promise}
*/
CommentSchema.statics.findByStatusByActionType = function(status, action_type) {
return Action
.findCommentsIdByActionType(action_type, 'comment')
.then((actions) => {
return Comment.find({
'status': status,
'id': {
'$in': actions.map(a => {
return a.item_id;
})
status: status,
id: {
$in: actions.map(a => a.item_id)
}
});
});
};
/**
* Find comments by their status.
* @param {String} status the status of the comment to search for
*/
* @return {Promise}
*/
CommentSchema.statics.findByStatus = function(status) {
return Comment.find({'status': status});
return Comment.find({
status: status === 'new' ? '' : status
});
};
/**
* Find comments that need to be moderated (aka moderation queue).
* @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings.
*/
* @return {Promise}
*/
CommentSchema.statics.moderationQueue = function(moderation) {
switch(moderation){
// Pre-moderation: New comments are shown in the moderator queues immediately.
case 'pre':
return Comment.findByStatus('').then((comments) => {
return comments;
});
// Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users.
case 'post':
return Comment.findByStatusByActionType('', 'flag').then((comments) => {
return comments;
});
default:
throw new Error('Moderation setting not found.');
return Promise.reject(Error('Moderation setting not found.'));
}
};
@@ -154,16 +149,18 @@ CommentSchema.statics.moderationQueue = function(moderation) {
* Change the status of a comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
* @return {Promise}
*/
CommentSchema.statics.changeStatus = function(id, status) {
return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}}, {upsert: false, new: true});
return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}});
};
/**
* Add an action to the comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} action the new action to the comment
*/
* @return {Promise}
*/
CommentSchema.statics.addAction = function(id, user_id, action_type) {
// check that the comment exist
let action = new Action({
@@ -183,7 +180,8 @@ CommentSchema.statics.addAction = function(id, user_id, action_type) {
* Change the status of a comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
* @return {Promise}
*/
CommentSchema.statics.removeById = function(id) {
return Comment.remove({'id': id});
};
@@ -193,7 +191,8 @@ CommentSchema.statics.removeById = function(id) {
* @param {String} id identifier of the comment (uuid)
* @param {String} action_type the type of the action to be removed
* @param {String} user_id the id of the user performing the action
*/
* @return {Promise}
*/
CommentSchema.statics.removeAction = function(item_id, user_id, action_type) {
return Action.remove({
action_type,
@@ -203,6 +202,15 @@ CommentSchema.statics.removeAction = function(item_id, user_id, action_type) {
});
};
/**
* Returns all the comments in the collection.
* @return {Promise}
*/
CommentSchema.statics.all = () => {
return Comment.find();
};
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
module.exports = Comment;
+3 -2
View File
@@ -2,7 +2,7 @@ const mongoose = require('../mongoose');
const Schema = mongoose.Schema;
/**
* this Schema manages application settings that get used on front- and backend
* this Schema manages application settings that get used on front and backend
* NOTE: when you set a setting here, it will not automatically be exposed to
* the front end. You must add it to the whitelist in the settings route
* in /routes/api/settings/index.js
@@ -12,7 +12,8 @@ const SettingSchema = new Schema({
id: {type: String, default: '1'},
moderation: {type: String, enum: ['pre', 'post'], default: 'pre'},
infoBoxEnable: {type: Boolean, default: false},
infoBoxContent: {type: String, default: ''}
infoBoxContent: {type: String, default: ''},
wordlist: [String]
}, {
timestamps: {
createdAt: 'created_at',
+11 -1
View File
@@ -402,7 +402,7 @@ UserService.findById = (id) => {
};
/**
* Finds users in an array of idd.
* Finds users in an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
UserService.findByIdArray = (ids) => {
@@ -411,6 +411,16 @@ UserService.findByIdArray = (ids) => {
});
};
/**
* Finds public user information by an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
UserService.findPublicByIdArray = (ids) => {
return UserModel.find({
'id': {$in: ids}
}, 'id displayName');
};
/**
* Creates a JWT from a user email. Only works for local accounts.
* @param {String} email of the local user
+4 -14
View File
@@ -18,13 +18,8 @@
"config": {
"pre-git": {
"commit-msg": [],
"pre-commit": [
"npm run lint",
"npm test"
],
"pre-push": [
"npm test"
],
"pre-commit": ["npm run lint", "npm test"],
"pre-push": ["npm test"],
"post-commit": [],
"post-merge": []
}
@@ -33,12 +28,7 @@
"type": "git",
"url": "git+https://github.com/coralproject/talk.git"
},
"keywords": [
"talk",
"coral",
"coralproject",
"ask"
],
"keywords": ["talk", "coral", "coralproject", "ask"],
"author": "",
"license": "Apache-2.0",
"bugs": {
@@ -58,13 +48,13 @@
"helmet": "^3.1.0",
"jsonwebtoken": "^7.1.9",
"lodash": "^4.16.6",
"lodash.debounce": "^4.0.8",
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"nodemailer": "^2.6.4",
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"natural": "^0.4.0",
"prompt": "^1.0.0",
"react-linkify": "^0.1.3",
"redis": "^2.6.3",
+6 -3
View File
@@ -5,9 +5,12 @@ router.get('/embed/stream/preview', (req, res) => {
res.render('embed-stream', {basePath: '/client/embed/stream'});
});
router.get('/password-reset/:token', (req, res, next) => {
// render a page or something?
res.send('ok');
// this route is expecting there to be a token in the hash
// see /views/password-reset-email.ejs
router.get('/password-reset', (req, res, next) => {
// TODO: store the redirect uri in the token or something fancy
// admins and regular users should probably be redirected to different places.
res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL});
});
router.get('*', (req, res) => {
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const Action = require('../../../models/action');
const router = express.Router();
router.delete('/:action_id', (req, res, next) => {
Action
.findOneAndRemove({
id: req.params.action_id
})
.then(() => {
res.status(204).end();
})
.catch(error => {
next(error);
});
});
module.exports = router;
+1 -1
View File
@@ -8,7 +8,7 @@ const router = express.Router();
* This returns the user if they are logged in.
*/
router.get('/', authorization.needed(), (req, res) => {
res.json(req.user);
res.json(req.user.toObject());
});
/**
+79 -115
View File
@@ -1,147 +1,111 @@
const express = require('express');
const Comment = require('../../../models/comment');
const wordlist = require('../../../services/wordlist');
const router = express.Router();
//==============================================================================
// Get Routes
//==============================================================================
router.get('/', (req, res, next) => {
Comment.find({}).then((comments) => {
res.status(200).json(comments);
let query;
if (req.query.status) {
query = Comment.findByStatus(req.query.status);
} else if (req.query.action_type) {
query = Comment.findByActionType(req.query.action_type);
} else {
query = Comment.all();
}
query.then(comments => {
res.json(comments);
})
.catch(next);
.catch((err) => {
next(err);
});
});
router.post('/', wordlist.filter('body'), (req, res, next) => {
const {
body,
asset_id,
parent_id,
author_id
} = req.body;
Comment
.create({
body,
asset_id,
parent_id,
status: req.wordlist.matched ? 'rejected' : '',
author_id
})
.then((comment) => {
res.status(201).send(comment);
})
.catch((err) => {
next(err);
});
});
router.get('/:comment_id', (req, res, next) => {
Comment
.findById(req.params.comment_id)
.then(comment => {
if (!comment) {
res.status(404).end();
return;
}
res.status(200).json(comment);
})
.catch(next);
});
// Get all the comments that have an action_type over them.
router.get('/action/:action_type', (req, res, next) => {
Comment
.findByActionType(req.params.action_type)
.then((comments) => {
res.status(200).json(comments);
})
.catch(next);
});
// Get all the comments that were rejected.
router.get('/status/rejected', (req, res, next) => {
Comment.findByStatus('rejected').then(comments => {
res.status(200).json(comments);
})
.catch(next);
});
// Get all the comments that were accepted.
router.get('/status/accepted', (req, res, next) => {
Comment.findByStatus('accepted').then((comments) => {
res.status(200).json(comments);
})
.catch(error => {
next(error);
});
});
// Get all the not moderated comments.
router.get('/status/new', (req, res, next) => {
Comment.findByStatus('').then((comments) => {
res.status(200).json(comments);
})
.catch(error => {
next(error);
});
});
//==============================================================================
// Post Routes
//==============================================================================
router.post('/', (req, res, next) => {
const {body, author_id, asset_id, parent_id, status, username} = req.body;
Comment
.new(body, author_id, asset_id, parent_id, status, username)
.then((comment) => {
res.status(200).send({'id': comment.id});
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.post('/:comment_id', (req, res, next) => {
Comment
.findById(req.params.comment_id)
.then((comment) => {
comment.body = req.body.body;
comment.author_id = req.body.author_id;
comment.asset_id = req.body.asset_id;
comment.parent_id = req.body.parent_id;
comment.status = req.body.status;
return comment.save();
})
.then((comment) => {
res.status(200).send(comment);
})
.catch(error => {
next(error);
});
});
router.post('/:comment_id/status', (req, res, next) => {
Comment
.changeStatus(req.params.comment_id, req.body.status)
.then(comment => res.status(200).send(comment))
.catch(error => next(error));
});
router.post('/:comment_id/actions', (req, res, next) => {
Comment
.addAction(req.params.comment_id, req.body.user_id, req.body.action_type)
.then((action) => {
res.status(200).send(action);
})
.catch(error => {
next(error);
});
});
//==============================================================================
// Delete Routes
//==============================================================================
router.delete('/:comment_id', (req, res, next) => {
Comment
.removeById(req.params.comment_id)
.then(() => {
res.status(201).send({});
res.status(204).end();
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.delete('/:comment_id/actions', (req, res, next) => {
console.log(req.params);
router.put('/:comment_id/status', (req, res, next) => {
const {
status
} = req.body;
Comment
.removeAction(req.params.comment_id, req.body.user_id, req.body.action_type)
.changeStatus(req.params.comment_id, status)
.then(() => {
res.status(201).send({});
res.status(204).end();
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.post('/:comment_id/actions', (req, res, next) => {
const {
user_id,
action_type
} = req.body;
Comment
.addAction(req.params.comment_id, user_id, action_type)
.then((action) => {
res.status(201).json(action);
})
.catch((err) => {
next(err);
});
});
+1
View File
@@ -9,5 +9,6 @@ router.use('/queue', require('./queue'));
router.use('/settings', require('./settings'));
router.use('/stream', require('./stream'));
router.use('/user', require('./user'));
router.use('/actions', require('./actions'));
module.exports = router;
+9 -4
View File
@@ -1,4 +1,5 @@
const express = require('express');
const _ = require('lodash');
const Comment = require('../../../models/comment');
const User = require('../../../models/user');
@@ -25,9 +26,9 @@ router.get('/', (req, res, next) => {
case 'pre':
return Promise.all([Comment.findAcceptedByAssetId(asset.id), asset]);
case 'post':
return Promise.all([Comment.findAcceptedAndNewByAssetId(asset.id), asset]);
return Promise.all([Comment.findAcceptedAndNewByAssetId(asset.id), asset]);
default:
throw new Error('Moderation setting not found.');
return Promise.reject(new Error('Moderation setting not found.'));
}
})
// Get all the users and actions for those comments.
@@ -35,8 +36,12 @@ router.get('/', (req, res, next) => {
return Promise.all([
[asset],
comments,
User.findByIdArray(comments.map((comment) => comment.author_id)),
Action.getActionSummaries(comments.map((comment) => comment.id))
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
Action.getActionSummaries(_.uniq([
asset.id,
...comments.map((comment) => comment.id),
...comments.map((comment) => comment.author_id)
]))
]);
})
.then(([assets, comments, users, actions]) => {
+5 -1
View File
@@ -79,6 +79,10 @@ router.post('/', (req, res, next) => {
router.post('/update-password', (req, res, next) => {
const {token, password} = req.body;
if (!password || password.length < 8) {
return res.status(400).send('Password must be at least 8 characters');
}
User.verifyPasswordResetToken(token)
.then(user => {
return User.changePassword(user.id, password);
@@ -100,7 +104,7 @@ router.post('/request-password-reset', (req, res, next) => {
const {email} = req.body;
if (!email) {
return next();
return next('you must submit an email when requesting a password.');
}
User
+164
View File
@@ -0,0 +1,164 @@
const debug = require('debug')('talk:services:wordlist');
const _ = require('lodash');
const natural = require('natural');
const tokenizer = new natural.WordTokenizer();
const Setting = require('../models/setting');
/**
* The root wordlist object.
* @type {Object}
*/
const wordlist = {
list: [],
enabled: false
};
/**
* 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
.getSettings()
.then((settings) => {
// Insert the settings wordlist.
wordlist.insert(settings.wordlist);
});
};
/**
* Inserts the wordlist data and enables the wordlist.
* @param {Array} list list of words to be added to the wordlist
*/
wordlist.insert = (list) => {
// 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());
})));
debug(`Added ${list.length} words to the wordlist, now the wordlist is ${wordlist.list.length} entries long.`);
// Enable the wordlist.
wordlist.enabled = true;
return Promise.resolve(wordlist);
};
/**
* 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) => {
// Lowercase the word to ensure that we don't miss a match due to
// capitalization.
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
// 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]) {
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;
});
};
// 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.ErrContainsProfanity = ErrContainsProfanity;
+262 -158
View File
@@ -7,221 +7,320 @@ host: talk-stg.coralproject.net
schemes:
- https
basePath: /api/v1
consumes:
- application/json
produces:
- application/json
paths:
/comments:
# get:
# tags:
# - Comments
# produces:
# - application/json
# summary: Comment Types
# description: |
# This endpoint retrieves comments
# parameters:
# - name: id
# in: query
# description: Comment by id
# required: false
# type: string
# responses:
# 200:
# description: An array of comments
# schema:
# type: array
# items:
# $ref: '#/definitions/Comment'
get:
tags:
- Comments
parameters:
- name: status
in: query
description: Performs a search based on the comment's status.
type: string
enum:
- flag
- name: action_type
in: query
description: Performs a search based on the actions that have been added to it.
type: string
enum:
- rejected
- accepted
- new
responses:
200:
description: Comments matching the query.
schema:
type: array
items:
- $ref: '#/definitions/Comment'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
post:
description: Add a new comment
tags:
- Comments
parameters:
- name: body
in: body
description: Body
required: true
description: The comment to create.
schema:
$ref: '#/definitions/Comment'
responses:
201:
description: "OK: Comment Added"
description: The comment that was created.
schema:
$ref: '#/definitions/Comment'
$ref: '#/definitions/Comment'
500:
description: "Error"
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}:
get:
tags:
- Comments
parameters:
- name: comment_id
in: path
description: The id of the comment to retrieve.
type: string
required: true
responses:
200:
description: The comment was found.
schema:
$ref: '#/definitions/Comment'
404:
description: The comment was not found.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
delete:
tags:
- Comments
parameters:
- name: comment_id
in: path
description: The id of the comment to delete.
type: string
required: true
responses:
204:
description: The comment was deleted.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}/status:
put:
tags:
- Comments
- Moderation
parameters:
- name: comment_id
in: path
description: The id of the comment to retrieve.
type: string
required: true
- name: body
in: body
description: The status to update to.
required: true
schema:
type: object
properties:
status:
type: string
description: The status to update to.
responses:
204:
description: The comment status was updated.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}/actions:
post:
tags:
- Comments
description: Add a action
- Actions
parameters:
- name: comment_id
in: path
description: Comment ID
required: true
description: The id of the comment to retrieve.
type: string
required: true
- name: body
in: body
description: comment
description: The action to add.
required: true
schema:
$ref: '#/definitions/Action'
type: object
properties:
action_type:
type: string
description: The action to add
responses:
201:
description: Action Added
description: The action created.
schema:
type: array
items:
$ref: '#/definitions/Comment'
/comments/{comment_id}/status:
post:
$ref: '#/definitions/Action'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/actions/{action_id}:
delete:
tags:
- Comments
description: Add a new status
- Actions
parameters:
- name: comment_id
- name: action_id
in: path
description: Comment ID
required: true
description: The id of the action to delete.
type: string
- name: body
in: body
description: comment
required: true
schema:
$ref: '#/definitions/ModerationAction'
responses:
204:
description: ModerationAction Added
/queue:
description: The action was deleted.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth:
get:
tags:
- Queue
description: Queue
parameters:
- name: type
in: query
description:
"pending: no status | flagged: flagged action + no status | rejected: rejected status"
required: true
type: string
enum:
- pending
- flagged
- rejected
- name: limit
in: query
description: Queue limit
required: false
type: integer
- name: skip
in: query
description: Skip
required: false
type: integer
- Auth
description: Retrieves the current authentication credentials.
responses:
200:
description: ModerationAction Added
description: The current user.
schema:
$ref: '#/definitions/User'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
delete:
tags:
- Auth
description: Logs out the current authenticated user.
responses:
204:
description: The current user has been logged out.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth/local:
post:
tags:
- Auth
parameters:
- name: body
in: body
required: true
description: The login credentials.
schema:
type: object
properties:
email:
type: string
description: The email address of the current user.
password:
type: string
description: The password of the current user.
responses:
200:
description: The user has authenticated sucesfully.
schema:
$ref: '#/definitions/User'
401:
description: The authentication error.
schema:
$ref: '#/definitions/Error'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth/facebook:
get:
tags:
- Auth
responses:
302:
description: Redirects the user to perform external facebook authentication.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/queue/comments/pending:
get:
tags:
- Comments
- Moderation
responses:
200:
description: The comments that are not moderated.
schema:
type: array
items:
$ref: '#/definitions/ModerationAction'
- $ref: '#/definitions/Comment'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/stream:
get:
tags:
- Stream
description: Stream
- Actions
- Assets
- Comments
- Users
parameters:
- name: asset_id
- name: asset_url
in: query
description: Description
required: true
description: The asset url to get the comment stream from.
type: string
format: url
responses:
200:
description: OK
description: The comment stream.
schema:
type: array
items:
$ref: '#/definitions/Item'
type: object
properties:
assets:
type: array
items:
- $ref: '#/definitions/Asset'
comments:
type: array
items:
- $ref: '#/definitions/Comment'
users:
type: array
items:
- $ref: '#/definitions/User'
actions:
type: array
items:
- $ref: '#/definitions/Actions'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/settings:
get:
tags:
- Settings
description: Settings
responses:
200:
description: Get Setting
description: The settings.
schema:
type: array
items:
$ref: '#/definitions/Setting'
$ref: '#/definitions/Settings'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
put:
tags:
- Settings
description: Settings
responses:
204:
description: OK
/user/request-password-reset:
post:
tags:
- Users
description: trigger a reset password email. sends a success code whether email was found or no.
responses:
204:
description: OK
/user/update-password:
post:
tags:
- Users
description: Update existing user password
parameters:
- name: token
type: string
in: body
description: JSON Web token taken taken from emailed link
required: true
- name: password
type: string
in: body
description: new password to be settings
required: true
responses:
204:
description: OK
/asset:
get:
tags:
- Asset
description: Get an asset by id.
responses:
200:
description: OK
put:
tags:
- Asset
description: Upsert an asset.
responses:
204:
description: OK
/asset?url={url}:
get:
tags:
- Asset
parameters:
- name: url
in: query
description: The url of the asset.
required: true
description: Get an asset by its url.
responses:
200:
description: OK
description: The settings were updated.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
definitions:
Error:
type: object
properties:
message:
type: string
description: The error that occured.
Item:
type: object
ModerationAction:
@@ -243,9 +342,9 @@ definitions:
type: string
format: date-time
description: Display name of comment
user_id:
author_id:
type: string
description: Display name of comment
description: User who posted the comment
parent_id:
type: string
description: Display name of comment
@@ -314,5 +413,10 @@ definitions:
type: string
description: An array of the authors for this asset.
publication_date:
type: date
desctipion: When this asset was published.
type: string
format: datetime
description: When this asset was published.
User:
type: object
Settings:
type: object
@@ -29,21 +29,23 @@ describe('itemActions', () => {
],
actions: [
{
type: 'like',
id: '123',
action_type: 'like',
item_id: '123',
count: 1,
id: 'like_123',
current_user: false
},
{
type: 'flag',
id: '456',
action_type: 'flag',
item_id: '456',
count: 5,
id: 'flag_456',
current_user: true
}
]
};
it('should get an stream from an asset_id and send the appropriate dispatches', () => {
it('should get an stream from an asset_url and send the appropriate dispatches', () => {
fetchMock.get('*', JSON.stringify(response));
return actions.getStream(assetUrl)(store.dispatch)
.then((res) => {
@@ -173,7 +175,7 @@ describe('itemActions', () => {
fetchMock.delete('*', {});
return actions.deleteAction('abc', 'flag', '123', 'comments')(store.dispatch)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions');
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/actions/abc');
expect(response).to.deep.equal({});
});
});
+9 -8
View File
@@ -10,10 +10,6 @@ describe('Action: models', () => {
action_type: 'flag',
item_id: '123',
item_type: 'comments'
}, {
action_type: 'like',
item_id: '789',
item_type: 'comments'
}, {
action_type: 'flag',
item_id: '456',
@@ -22,6 +18,10 @@ describe('Action: models', () => {
action_type: 'flag',
item_id: '123',
item_type: 'comments'
}, {
action_type: 'like',
item_id: '123',
item_type: 'comments'
}]).then((actions) => {
mockActions = actions;
});
@@ -39,7 +39,7 @@ describe('Action: models', () => {
describe('#findByItemIdArray()', () => {
it('should find an array of actions from an array of item_ids', () => {
return Action.findByItemIdArray(['123', '456']).then((result) => {
expect(result).to.have.length(3);
expect(result).to.have.length(4);
});
});
});
@@ -48,16 +48,17 @@ describe('Action: models', () => {
it('should return properly formatted summaries from an array of item_ids', () => {
return Action.getActionSummaries(['123', '789']).then((result) => {
expect(result).to.have.length(2);
const sorted = result.sort((a, b) => a.count - b.count);
delete sorted[0].id;
delete sorted[1].id;
expect(sorted[0]).to.deep.equal({
action_type: 'like',
count: 1,
item_id: '789',
item_id: '123',
item_type: 'comments',
current_user: false
});
expect(sorted[1]).to.deep.equal({
action_type: 'flag',
count: 2,
+16
View File
@@ -43,6 +43,22 @@ describe('User: models', () => {
});
});
describe('#findPublicByIdArray()', () => {
it('should find an array of users from an array of ids', () => {
const ids = mockUsers.map((user) => user.id);
return User.findPublicByIdArray(ids).then((result) => {
expect(result).to.have.length(3);
const sorted = result.sort((a, b) => {
if(a.displayName < b.displayName) {return -1;}
if(a.displayName > b.displayName) {return 1;}
return 0;
});
expect(sorted[0]).to.have.property('displayName')
.and.to.equal('Marvel');
});
});
});
describe('#findLocalUser', () => {
it('should find a user when we give the right credentials', () => {
+52 -103
View File
@@ -10,6 +10,7 @@ const expect = chai.expect;
chai.should();
chai.use(require('chai-http'));
const wordlist = require('../../../../services/wordlist');
const Comment = require('../../../../models/comment');
const Action = require('../../../../models/action');
const User = require('../../../../models/user');
@@ -64,13 +65,13 @@ describe('Get /comments', () => {
]);
});
it('should return all the comments', function(done){
chai.request(app)
it('should return all the comments', () => {
return chai.request(app)
.get('/api/v1/comments')
.end(function(err, res){
expect(err).to.be.null;
.then((res) => {
expect(res).to.have.status(200);
done();
});
});
});
@@ -122,48 +123,42 @@ describe('Get comments by status and action', () => {
]);
});
it('should return all the rejected comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/rejected')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the rejected comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=rejected')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'abc');
done();
});
});
it('should return all the approved comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/accepted')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the approved comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=accepted')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'hij');
done();
});
});
it('should return all the new comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/new')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the new comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=new')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'def');
done();
});
});
it('should return all the flagged comments', function(done){
chai.request(app)
.get('/api/v1/comments/action/flag')
.end(function(err, res){
it('should return all the flagged comments', () => {
return chai.request(app)
.get('/api/v1/comments?action_type=flag')
.then((res) => {
expect(res).to.have.status(200);
expect(err).to.be.null;
expect(res.body.length).to.equal(1);
expect(res.body[0]).to.have.property('id', 'abc');
done();
});
});
});
@@ -190,18 +185,31 @@ describe('Post /comments', () => {
beforeEach(() => {
return Promise.all([
User.createLocalUsers(users),
Action.create(actions)
Action.create(actions),
wordlist.insert([
'bad words'
])
]);
});
it('it should create a comment', function(done) {
chai.request(app)
it('should create a comment', () => {
return chai.request(app)
.post('/api/v1/comments')
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.end(function(err, res){
expect(res).to.have.status(200);
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
done();
});
});
it('should create a comment with a rejected status if it contains a bad word', () => {
return chai.request(app)
.post('/api/v1/comments')
.send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('status', 'rejected');
});
});
});
@@ -251,72 +259,14 @@ describe('Get /:comment_id', () => {
]);
});
it('should return the right comment for the comment_id', function(done){
chai.request(app)
it('should return the right comment for the comment_id', () => {
return chai.request(app)
.get('/api/v1/comments/abc')
.end(function(err, res){
expect(err).to.be.null;
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.property('body');
expect(res.body).to.have.property('body', 'comment 10');
done();
});
});
});
describe('Put /:comment_id', () => {
const comments = [{
id: 'abc',
body: 'comment 10',
asset_id: 'asset',
author_id: '123'
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456'
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456'
}];
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
}];
const actions = [{
action_type: 'flag',
item_id: 'abc'
}, {
action_type: 'like',
item_id: 'hij'
}];
beforeEach(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
});
it('it should update comment', function(done) {
chai.request(app)
.post('/api/v1/comments/abc')
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.end(function(err, res){
expect(err).to.be.null;
expect(res).to.have.status(200);
expect(res.body).to.have.property('body', 'Something body.');
done();
});
});
});
@@ -369,7 +319,7 @@ describe('Remove /:comment_id', () => {
return chai.request(app)
.delete('/api/v1/comments/abc')
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.status(204);
return Comment.findById('abc');
})
@@ -384,7 +334,7 @@ process.on('unhandledRejection', (reason) => {
console.error(reason);
});
describe('Post /:comment_id/status', () => {
describe('Put /:comment_id/status', () => {
const comments = [{
id: 'abc',
@@ -433,12 +383,11 @@ describe('Post /:comment_id/status', () => {
it('it should update status', function() {
return chai.request(app)
.post('/api/v1/comments/abc/status')
.put('/api/v1/comments/abc/status')
.send({status: 'accepted'})
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.body;
expect(res.body).to.have.property('status', 'accepted');
expect(res).to.have.status(204);
expect(res.body).to.be.empty;
});
});
});
@@ -495,7 +444,7 @@ describe('Post /:comment_id/actions', () => {
.post('/api/v1/comments/abc/actions')
.send({'user_id': '456', 'action_type': 'flag'})
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('item_type', 'comment');
expect(res.body).to.have.property('action_type', 'flag');
+119
View File
@@ -0,0 +1,119 @@
const expect = require('chai').expect;
const wordlist = require('../../services/wordlist');
describe('wordlist: services', () => {
before(() => wordlist.insert([
'BAD',
'bad',
'how to murder',
'how to kill'
]));
beforeEach(() => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
});
describe('#init', () => {
it('has entries', () => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
});
});
describe('#match', () => {
it('does match on a bad word', () => {
[
'how to kill',
'what is bad',
'bad',
'BAD.',
'how to murder',
'How To mUrDer'
].forEach((word) => {
expect(wordlist.match(word)).to.be.true;
});
});
it('does not match on a good word', () => {
[
'how to',
'kill',
'bads',
'how to be a great person?',
'how to not kill?'
].forEach((word) => {
expect(wordlist.match(word)).to.be.false;
});
});
});
describe('#filter', () => {
it('matches on bodies containing bad words', (done) => {
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('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 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();
});
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- extremely naive implementation of a password reset email -->
<p>We received a request to reset your password. If you did not request this change, you can ignore this email.<br />
If you did, <a href="<%= rootURL %>/admin/password-reset/<%= token %>">please click here to reset password</a>.</p>
If you did, <a href="<%= rootURL %>/admin/password-reset#<%= token %>">please click here to reset password</a>.</p>
<% if (process.env.NODE_ENV !== 'production') { %>
<p style="color: red"><%= token %></p>
<% } %>
+135
View File
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title>Password Reset</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<style media="screen">
body, #root {
width: 100%;
height: 100%;
margin: 0;
background: #fff;
}
#root form {
max-width: 300px;
border: 1px solid lightgrey;
box-shadow: 0px 10px 24px 2px rgba(0,0,0,0.2);
margin: 50px auto;
padding: 15px;
}
.legend {
text-align: center;
width: 100%;
font-weight: bold;
}
label {
display: block;
margin-top: 10px;
margin-bottom: 3px;
padding-right: 30px;
}
small {
color: #888;
}
input {
border-radius: 4px;
margin-top: 3px;
border: 1px solid lightgrey;
font-size: 16px;
width: 100%;
padding: 14px;
height: 100%;
display: inline-block;
}
.submit-password-reset {
border-radius: 4px;
border: none;
display: block;
background-color: #333;
color: white;
text-align: center;
width: 100%;
padding: 10px;
margin-top: 10px;
cursor: pointer;
}
.error-console {
display: none;
margin-top: 10px;
border-radius: 4px;
background-color: pink;
color: red;
border: 1px solid red;
padding: 10px;
}
.error-console.active {
display: block;
}
</style>
</head>
<body>
<div id="root">
<form id="reset-password-form">
<legend class="legend">Set new password</legend>
<label for="password">
New password
<input type="password" name="password" placeholder="new password" />
<p><small>Password must be at least 8 characters</small></p>
</label>
<label for="confirm-password">
Confirm password
<input type="password" name="confirm-password" placeholder="confirm password" />
</label>
<button class="submit-password-reset" type="submit">Apply</button>
<div class="error-console">foo</div>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script>
$(function () {
function showError(message) {
$('.error-console').text(message).addClass('active');
}
function handleSubmit (e) {
e.preventDefault();
$('.error-console').removeClass('active');
var password = $('[name="password"]').val();
var confirm = $('[name="confirm-password"]').val();
if (password !== confirm || password === '' || password.length < 8) {
showError('passwords must match and be 8 characters.');
return false;
}
$.ajax({
url: '/api/v1/user/update-password',
contentType: 'application/json',
method: 'POST',
data: JSON.stringify({password: password, token: location.hash.replace('#', '')})
}).then(function (success) {
location.href = '<%= redirectUri %>';
}).catch(function (error) {
showError(error.responseText);
});
}
$('#reset-password-form').on('submit', handleSubmit);
});
</script>
</body>
</html>