Merge branch 'master' into frontenddocs

This commit is contained in:
Kim Gardner
2017-01-10 11:40:03 -05:00
committed by GitHub
83 changed files with 1825 additions and 901 deletions
+2
View File
@@ -11,3 +11,5 @@ dump.rdb
.env
gaba.cfg
.idea/
coverage/
yarn.lock
+52 -69
View File
@@ -1,95 +1,78 @@
# Contribution Guide
# Contributor's Guide
We're very excited that you're interested in contributing to Talk! There is much to do. Before you begin, please review this document to get a sense of the practices and philosophies that hold this project together.
Welcome! We are very excited that you are interested in contributing to Talk.
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
## Doing the Work
## Product Roadmap
We are here to make it as seamless as possible to contribute to Talk. The following lists are meant to make it straightforward to perform the mechanics of working on the project so you can focus your energy toward writing and reviewing content.
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
### Code Reviews
## Contribute to the documentation
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
One of the most valuable aspects of working in software. It is something that should challenge the reviewer and author alike. It is a way of focusing knowledge, experience and opinions for the benefit of the project and the participants.
We are looking for _documentarians_ to:
Code reviews are a collaboration to make _the work_ as good as it can be. Code reviews are not a good venue for providing direct instruction to _the author._ Focus on positive, incremental improvements that can be made on the work at hand.
* make clarity, grammar and completeness updates,
* create new / missing sections, and
* take the lead in making sections, or the over all structure better.
Please take your time when writing and reviewing code. Here are some fundamental questions to open up a reviewing headspace.
### But how?
**Is the code clear, efficient and a pleasure to read?**
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
Somewhere at the intersection of good variable names, well laid out file structures, consistent formatting and appropriate comments lies beautiful code. Code is language spoken to at least two very distinct audiences, the computer that interprets it and the developer who encounters it. Both should be at the front of your mind when reviewing code.
## Integrate into your environment
Thinking like a computer, you could ask:
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
* Is the code using memory efficiently?
* Is data being moved around unnecessarily?
* Are multiple network requests being made where fewer would do?
* Is there excess processing happening in a synchronous flow that may disrupt user experience?
* Are there large libraries included for small gains?
Talk is designed to integrate into existing environments in a variety of ways:
Then, returning to your human roots... Is the code readable?
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
* Monitoring Hooks (coming in 2017)
* Can I understand what is happening here (and maybe even why) by simply opening up the file, starting at the top and reading downward?
* Do comments convey clear, full thoughts in a narrative language that provides background for the code choices?
* Are the files separated logically such that each one contains a clear concept of code?
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
## Write some code
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
### Build a New Feature / Plugin
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
To get an idea of where the Coral Team is going, see:
* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
* our [issues](https://github.com/coralproject/talk/issues).
**Is the API documentation up to date? Are all client calls written against the docs?**
Examples:
We use [swagger](https://github.com/coralproject/talk/blob/master/swagger.yaml) to track our API documentation.
* If APIs are created or updated, is the swagger.yml file up to date? There's nothing more frustrating than trying to develop against docs that are out of date or wrong. We need to be meticulous here as it's the little differences that can cause the most frustration and tricky bugs.
* If client code calls APIs, are they written against the swagger.yml file? Are all return codes handled?
**Is there sufficient test coverage?**
Our tests folder is set up to mirror the code folders: [https://github.com/coralproject/talk/tree/master/tests](https://github.com/coralproject/talk/tree/master/tests)
* Can you a sense of the logic behind the code by reading the tests?
* Can you see both what should happen and what should _never, ever_ be allowed to happen?
* Are there future cases that are guarded against via the creation of unit tests (aka, making sure things are typed, specifically checking for all values that will be used, etc...)?
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
### Forking, Branching and Merging
### Work on the Core
Talk follows the _master as tip_ repo structure. `master` is the bleeding edge. It should be _as stable as possible_ but may suffer instabilities, generally during times that fundamental architectural elements are added.
There is always more work to be done to make an application more stable, scaleable and secure.
Releases are _tagged_ off the master branch.
If you see issues in the code or have ideas on how we may improve Talk, please consider:
Contributions to Talk follow this process. There are a lot of steps, but mechanically following these steps will standardize communication, help stop errors and let you focus on your contribution.
* At the outset of a piece of work, a branch or fork is made from master.
* The work is done in that fork.
* As soon as the work has taken shape, a PR is created for discussion. (If the PR is created for review before it's ready to merge, please make that clear in the description/title.)
* At least one other contributor to the project must review all code (see Code Reviews below.)
* If there are merge conflicts with master, merge master into the branch.
* Ensure that [circleci](https://circleci.com/) passes all tests for your branch. (If you have forked and do not have circleci set up, you and the reviewer should independently ensure that all the of Continuous Integration steps pass before merging.)
* If merge conflicts exist with `master`, merge `master` into your branch and re-run CI before merging into master.
* Merge to master, but _you're not quite done yet!_
* Deploy master to staging (or have a core member do so.)
* Ensure that all your changes are working on staging.
* Have your reviewer verify the same.
* ... aaaand the work is delivered!
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
* [filing an issue](https://github.com/coralproject/talk/issues), or
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
## Continuous Integration
We use circleci to run our ci: [https://circleci.com/gh/coralproject/talk](https://circleci.com/gh/coralproject/talk)
Our pipeline will _test_, _lint_, and _build_ all pushes to the repo.
Any branch not passing CI will not be merged into master.
If you're working in a fork, please run each of the steps locally before submitting a PR.
## Coding Style
### API Design
When building APIs, we follow these principles:
* Follow [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) principles for basic operations.
* Avoid routing yourself into a corner, for example, by putting a variable other than an object's id directly after an object.
* Put non-required, flexible variables into query params, required/identity based values in request params.
+95
View File
@@ -0,0 +1,95 @@
# Installing a dev environment
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## Requirements
### System
- Any flavor of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
### Software
* [Node](https://nodejs.org/es/download/package-manager) v7 or later
* Mongo v3.2 or later
* Redis v3.2 or later
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
## First time setup
### Installation
Navigate to a directory.
```
git clone https://github.com/coralproject/talk
cd talk
npm install
```
### Environmental Variables
Talk uses environmental variables for configuration. You can learn about them in the [README file](README.md).
## Workflows
### The server
Starting the server:
```
npm start
```
Browse to `http://localhost:3000` (or your custom port.)
### Building the front end
Our build process will build all front end components registered [here](https://github.com/coralproject/talk/blob/6052cac1d3494f8060325a88bb2ce03c88c2f94c/webpack.config.dev.js#L9-L15).
One time build:
```
npm build
```
Build, then rebuild when a file is updated (development build):
```
npm build-watch
```
### Testing
Run all tests once:
`
npm test
`
Run our end to end tests (will install Selenium and nightwatch):
`
npm run e2e
`
_Please ensure all tests are passing before submitting a PR!_
## Troubleshooting
##### Can't ping the redis server!
- Check that Redis Server is running.
- Check that TALK_REDIS_URL is set.
##### Authenticaiton doesn't work!
- Make sure Redis is the correct version.
+13 -5
View File
@@ -4,14 +4,17 @@ A commenting platform from [The Coral Project](https://coralproject.net).
## Contributing to Talk
### Product Roadmap
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
## Usage
### Installation
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!!
### Configuration
The Talk application requires specific configuration options to be available
@@ -34,6 +37,11 @@ available in the format: `<scheme>://<host>` without the path.
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required*) - SMTP port.
### Install from Source
If you want to run Talk in development mode from source (without docker) you can read the [INSTALL file](INSTALL.md).
### License
Copyright 2016 Mozilla Foundation
+27 -1
View File
@@ -5,8 +5,10 @@ const path = require('path');
const helmet = require('helmet');
const passport = require('./services/passport');
const session = require('express-session');
const enabled = require('debug').enabled;
const RedisStore = require('connect-redis')(session);
const redis = require('./services/redis');
const csrf = require('csurf');
const app = express();
@@ -42,6 +44,7 @@ const session_opts = {
rolling: true,
saveUninitialized: false,
resave: false,
unset: 'destroy',
name: 'talk.sid',
cookie: {
secure: false,
@@ -73,6 +76,29 @@ app.use(session(session_opts));
app.use(passport.initialize());
app.use(passport.session());
//==============================================================================
// CSRF MIDDLEWARE
//==============================================================================
if (process.env.TEST_MODE === 'unit') {
// Add this fake test token in the event we are in unit test mode, and don't
// include the CSRF protection.
app.locals.csrfToken = 'UNIT_TESTS';
} else {
// Setup route middlewares for CSRF protection.
// Default ignore methods are GET, HEAD, OPTIONS
app.use(csrf({}));
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken();
next();
});
}
//==============================================================================
// ROUTES
//==============================================================================
@@ -95,7 +121,7 @@ app.use((req, res, next) => {
// returning a status code that makes sense.
app.use('/api', (err, req, res, next) => {
if (err !== ErrNotFound) {
if (app.get('env') !== 'test') {
if (app.get('env') !== 'test' || enabled('talk:errors')) {
console.error(err);
}
}
+6 -2
View File
@@ -6,6 +6,7 @@
const program = require('commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('../util');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
@@ -19,13 +20,16 @@ util.onshutdown([
*/
function processJobs() {
// Start the processor.
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => scraper.shutdown()
() => kue.Task.shutdown()
]);
}
+8 -3
View File
@@ -5,6 +5,8 @@ const debug = require('debug')('talk:server');
const http = require('http');
const init = require('../init');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('../util');
@@ -12,7 +14,7 @@ const util = require('../util');
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000'));
const port = normalizePort(process.env.TALK_PORT || '3000');
app.set('port', port);
@@ -119,15 +121,18 @@ startApp();
// Enable job processing on the thread if enabled.
if (program.jobs) {
// Start the processor.
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
}
// Define a safe shutdown function to call in the event we need to shutdown
// because the node hooks are below which will interrupt the shutdown process.
// Shutdown the mongoose connection, the app server, and the scraper.
util.onshutdown([
() => program.jobs ? scraper.shutdown() : null,
() => program.jobs ? kue.Task.shutdown() : null,
() => mongoose.disconnect(),
() => server.close()
]);
+10 -6
View File
@@ -80,12 +80,16 @@ function createUser(options) {
.then((user) => {
console.log(`Created user ${user.id}.`);
return User
.addRoleToUser(user.id, result.role.trim())
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
});
if (result.role && result.role.length > 0) {
return User
.addRoleToUser(user.id, result.role.trim())
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
});
} else {
util.shutdown();
}
})
.catch((err) => {
console.error(err);
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
import CommentStream from 'containers/CommentStream/CommentStream';
import Configure from 'containers/Configure/Configure';
import Streams from 'containers/Streams/Streams';
@@ -10,7 +10,7 @@ import LayoutContainer from 'containers/LayoutContainer';
const routes = (
<Route path='/admin' component={LayoutContainer}>
<IndexRoute component={ModerationQueue} />
<IndexRoute component={ModerationContainer} />
<Route path='embed' component={CommentStream} />
<Route path='community' component={CommunityContainer} />
<Route path='configure' component={Configure} />
+3 -3
View File
@@ -10,9 +10,9 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
coralApi('/auth')
.then(user => {
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
dispatch(checkLoginSuccess(user, isAdmin));
.then(result => {
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
};
+3 -3
View File
@@ -34,9 +34,9 @@ export const fetchModerationQueueComments = () => {
// Create a new comment
export const createComment = (name, body) => {
return dispatch => {
const comment = {body, name};
return coralApi('/comments', {method: 'POST', comment})
return (dispatch) => {
const formData = {body, name};
return coralApi('/comments', {method: 'POST', body: formData})
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
};
+3 -2
View File
@@ -41,14 +41,15 @@ export const newPage = () => ({
type: COMMENTERS_NEW_PAGE
});
export const setRole = (id, role) => dispatch => {
export const setRole = (id, role) => (dispatch) => {
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
.then(() => {
return dispatch({type: SET_ROLE, id, role});
});
};
export const setCommenterStatus = (id, status) => dispatch => {
export const setCommenterStatus = (id, status) => (dispatch) => {
return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}})
.then(() => {
return dispatch({type: SET_COMMENTER_STATUS, id, status});
+1 -1
View File
@@ -6,7 +6,7 @@ import * as actions from '../constants/user';
*/
// change status of a user
export const userStatusUpdate = (status, userId, commentId) => {
return dispatch => {
return (dispatch) => {
dispatch({type: actions.UPDATE_STATUS_REQUEST});
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
@@ -1,45 +1,46 @@
import React from 'react';
import {Dialog} from 'coral-ui';
import Button from 'coral-ui/components/Button';
import styles from './BanUserDialog.css';
import Button from 'coral-ui/components/Button';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => {
const {userName = '', userId = '', commentId = ''} = user;
return (
<Dialog className={styles.dialog} open={open} onClose={() => handleClose()} onCancel={() => handleClose()} title={lang.t('bandialog.ban_user')}>
<span className={styles.close} onClick={() => handleClose()}>×</span>
<div>
<div className={styles.header}>
<h3>
{lang.t('bandialog.ban_user')}
</h3>
</div>
<div className={styles.separator}>
<h4>
{lang.t('bandialog.are_you_sure', userName)}
</h4>
<i>
{lang.t('bandialog.note')}
</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} full>
{lang.t('bandialog.cancel')}
</Button>
<Button cStyle="black" onClick={() => onClickBanUser(userId, commentId)} full>
{lang.t('bandialog.yes_ban_user')}
</Button>
</div>
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
<Dialog
className={styles.dialog}
id="banuserDialog"
open={open}
onClose={() => handleClose()}
onCancel={() => handleClose()}
title={lang.t('bandialog.ban_user')}>
<span className={styles.close} onClick={handleClose}>×</span>
<div>
<div className={styles.header}>
<h3>
{lang.t('bandialog.ban_user')}
</h3>
</div>
<div className={styles.separator}>
<h4>
{lang.t('bandialog.are_you_sure', user.userName)}
</h4>
<i>
{lang.t('bandialog.note')}
</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} full>
{lang.t('bandialog.cancel')}
</Button>
<Button cStyle="black" onClick={() => onClickBanUser(user.userId, user.commentId)} full>
{lang.t('bandialog.yes_ban_user')}
</Button>
</div>
</div>
</Dialog>
);
};
);
export default BanUserDialog;
@@ -21,14 +21,12 @@ export default class CommentList extends React.Component {
comments: PropTypes.object.isRequired,
users: PropTypes.object.isRequired,
onClickAction: PropTypes.func,
modActions: PropTypes.arrayOf(PropTypes.string),
// list of actions (flags, etc) associated with the comments
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
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)
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
}
constructor (props) {
+2
View File
@@ -2,6 +2,8 @@ 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 CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
@@ -43,7 +43,7 @@ class CommentStream extends React.Component {
render ({comments, users}, {snackbar, snackbarMsg}) {
return (
<div className={styles.container}>
<CommentBox onSubmit={this.onSubmit} />
<CommentBox onSubmit={this.onSubmit}/>
<CommentList isActive hideActive
singleView={false}
commentIds={comments.ids}
@@ -0,0 +1,102 @@
import React from 'react';
import {connect} from 'react-redux';
import key from 'keymaster';
import {
updateStatus,
showBanUserDialog,
hideBanUserDialog,
fetchModerationQueueComments
} from 'actions/comments';
import {userStatusUpdate} from 'actions/users';
import {fetchSettings} from 'actions/settings';
import ModerationQueue from './ModerationQueue';
class ModerationContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
activeTab: 'pending',
singleView: false,
modalOpen: false
};
this.onClose = this.onClose.bind(this);
this.onTabClick = this.onTabClick.bind(this);
}
componentWillMount() {
this.props.fetchModerationQueueComments();
this.props.fetchSettings();
key('s', () => this.setState({singleView: !this.state.singleView}));
key('shift+/', () => this.setState({modalOpen: true}));
key('esc', () => this.setState({modalOpen: false}));
}
componentWillUnmount() {
key.unbind('s');
key.unbind('shift+/');
key.unbind('esc');
}
componentDidMount() {
// Hack for dynamic mdl tabs
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
}
}
onTabClick(activeTab) {
this.setState({activeTab});
}
onClose() {
this.setState({modalOpen: false});
}
render () {
const {comments} = this.props;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
return (
<ModerationQueue
onTabClick={this.onTabClick}
onClose={this.onClose}
premodIds={premodIds}
rejectedIds={rejectedIds}
flaggedIds={flaggedIds}
{...this.props}
{...this.state}
/>
);
}
}
const mapStateToProps = state => ({
comments: state.comments.toJS(),
settings: state.settings.toJS(),
users: state.users.toJS()
});
const mapDispatchToProps = dispatch => {
return {
fetchSettings: () => dispatch(fetchSettings()),
fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()),
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ModerationContainer);
@@ -1,168 +1,71 @@
import React from 'react';
import {connect} from 'react-redux';
import key from 'keymaster';
import styles from './ModerationQueue.css';
import ModerationKeysModal from 'components/ModerationKeysModal';
import CommentList from 'components/CommentList';
import BanUserDialog from 'components/BanUserDialog';
import {
updateStatus,
showBanUserDialog,
hideBanUserDialog,
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';
import translations from '../../translations.json';
/*
* Renders the moderation queue as a tabbed layout with 3 moderation
* queues :
* * pending: filtered by status Untouched
* * rejected: filtered by status Rejected
* * flagged: with a flagged action on them
*/
class ModerationQueue extends React.Component {
constructor (props) {
super(props);
this.state = {activeTab: 'pending', singleView: false, modalOpen: false};
}
// 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}));
key('esc', () => this.setState({modalOpen: false}));
}
// Unbind singleView key before unmount
componentWillUnmount () {
key.unbind('s');
key.unbind('shift+/');
key.unbind('esc');
}
// Hack for dynamic mdl tabs
componentDidMount () {
if (typeof componentHandler !== 'undefined') {
// FIXME: fix this hack
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
}
}
// Dispatch the update status action
onCommentAction (action, comment) {
// If not banning then change the status to approved or flagged as action = status
this.props.dispatch(updateStatus(action, comment));
}
showBanUserDialog (userId, userName, commentId) {
this.props.dispatch(showBanUserDialog(userId, userName, commentId));
}
hideBanUserDialog () {
this.props.dispatch(hideBanUserDialog(false));
}
banUser (userId, commentId) {
this.props.dispatch(userStatusUpdate('banned', userId, commentId))
.then(() => {
this.props.dispatch(fetchModerationQueueComments());
});
}
onTabClick (activeTab) {
this.setState({activeTab});
}
// Render the tabbed lists moderation queues
render () {
const {comments, users, settings} = this.props;
const {activeTab, singleView, modalOpen} = this.state;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
return (
<div>
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#pending' onClick={() => this.onTabClick('pending')}
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')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
</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}
comments={comments.byId}
users={users.byId}
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
modActions={['reject', 'approve', 'ban']}
loading={comments.loading} />
<BanUserDialog
open={comments.showBanUserDialog}
handleClose={() => this.hideBanUserDialog()}
onClickBanUser={(userId, commentId) => this.banUser(userId, commentId)}
user={comments.banUser}/>
</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)}
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)}
modActions={['reject', 'approve']}
loading={comments.loading} />
</div>
<ModerationKeysModal open={modalOpen}
onClose={() => this.setState({modalOpen: false})} />
</div>
</div>
);
}
}
const mapStateToProps = state => ({
actions: state.actions.toJS(),
settings: state.settings.toJS(),
comments: state.comments.toJS(),
users: state.users.toJS()
});
export default connect(mapStateToProps)(ModerationQueue);
const lang = new I18n(translations);
export default ({onTabClick, ...props}) => (
<div>
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<a href='#pending' onClick={() => onTabClick('pending')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
<a href='#rejected' onClick={() => onTabClick('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
<a href='#flagged' onClick={() => onTabClick('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
</div>
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'pending'}
singleView={props.singleView}
commentIds={props.premodIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
onClickShowBanDialog={props.showBanUserDialog}
modActions={['reject', 'approve', 'ban']}
loading={props.comments.loading}/>
<BanUserDialog
open={props.comments.showBanUserDialog}
handleClose={props.hideBanUserDialog}
onClickBanUser={props.banUser}
user={props.comments.banUser}
/>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.rejectedIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
modActions={['approve']}
loading={props.comments.loading}
/>
</div>
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
<CommentList
suspectWords={props.settings.settings.wordlist.suspect}
isActive={props.activeTab === 'rejected'}
singleView={props.singleView}
commentIds={props.flaggedIds}
comments={props.comments.byId}
users={props.users.byId}
onClickAction={props.updateStatus}
modActions={['reject', 'approve']}
loading={props.comments.loading}/>
</div>
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
</div>
</div>
);
+1 -1
View File
@@ -37,7 +37,7 @@ const updateSettings = (state, action) => {
// any nested settings must have a specialized setter
const updateWordlist = (state, action) => {
return state.setIn(['settings', 'wordlist', action.listName], action.wordlist);
return state.setIn(['settings', 'wordlist', action.listName], action.list);
};
const saveComplete = (state, action) => {
+2 -1
View File
@@ -48,7 +48,7 @@
"include-text": "Include your text here.",
"comment-settings": "Comment Settings",
"embed-comment-stream": "Embed Comment Stream",
"banned-word-header": "Write the bannned words list",
"banned-word-header": "Write the 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.",
@@ -146,6 +146,7 @@
"moderate": "Moderar",
"configure": "Configurar",
"community": "Comunidad",
"streams": "Streams",
"closed-comments-desc": "Escribe un mensaje para cuando los comentarios se encuentran cerrados",
"closed-comments-label": "Escribe un mensaje...",
"never": "Nunca",
@@ -138,7 +138,7 @@ class CommentStream extends Component {
</div>
: <p>{closedMessage}</p>
}
{!loggedIn && <SignInContainer offset={signInOffset} />}
{!loggedIn && <SignInContainer offset={signInOffset}/>}
{
rootItem.comments && rootItem.comments.map((commentId) => {
const comment = comments[commentId];
+12 -9
View File
@@ -23,7 +23,7 @@ const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
export const fetchSignIn = (formData) => dispatch => {
export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
coralApi('/auth/local', {method: 'POST', body: formData})
.then(({user}) => {
@@ -72,8 +72,9 @@ const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST});
const signUpSuccess = user => ({type: actions.FETCH_SIGNUP_SUCCESS, user});
const signUpFailure = error => ({type: actions.FETCH_SIGNUP_FAILURE, error});
export const fetchSignUp = formData => dispatch => {
export const fetchSignUp = formData => (dispatch) => {
dispatch(signUpRequest());
coralApi('/users', {method: 'POST', body: formData})
.then(({user}) => {
dispatch(signUpSuccess(user));
@@ -81,7 +82,9 @@ export const fetchSignUp = formData => dispatch => {
dispatch(changeView('SIGNIN'));
}, 3000);
})
.catch(() => dispatch(signUpFailure(lang.t('error.emailInUse')))); // We need to inprove error handling. TODO (bc)
.catch(error => {
dispatch(signUpFailure(lang.t(`error.${error.message}`)));
});
};
// Forgot Password Actions
@@ -90,9 +93,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 = email => dispatch => {
export const fetchForgotPassword = email => (dispatch) => {
dispatch(forgotPassowordRequest(email));
coralApi('/users/request-password-reset', {method: 'POST', body: {email}})
coralApi('/account/password/reset', {method: 'POST', body: {email}})
.then(() => dispatch(forgotPassowordSuccess()))
.catch(error => dispatch(forgotPassowordFailure(error)));
};
@@ -124,13 +127,13 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
coralApi('/auth')
.then(user => {
if (!user) {
.then((result) => {
if (!result.user) {
throw new Error('Not logged in');
}
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
dispatch(checkLoginSuccess(user, isAdmin));
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
};
+6 -2
View File
@@ -220,8 +220,12 @@ export function postItem (item, type, id) {
*/
export function postAction (item_id, item_type, action) {
return () => {
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action});
return (dispatch) => {
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action})
.then((json) => {
dispatch(updateItem(action.item_id, action.action_type, action.id, item_type));
return json;
});
};
}
+3 -5
View File
@@ -14,10 +14,10 @@ const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error});
export const saveBio = (user_id, formData) => dispatch => {
dispatch(saveBioRequest());
coralApi(`/users/${user_id}/bio`, {method: 'PUT', body: formData})
.then(({settings}) => {
coralApi('/account/settings', {method: 'PUT', body: formData})
.then(() => {
dispatch(addNotification('success', lang.t('successBioUpdate')));
dispatch(saveBioSuccess(settings));
dispatch(saveBioSuccess(formData));
})
.catch(error => dispatch(saveBioFailure(error)));
};
@@ -42,8 +42,6 @@ export const fetchCommentsByUserId = userId => {
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
})
.catch(error => {
console.error(error.stack);
console.error('FAILURE_COMMENTS_BY_USER', error);
dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error});
});
};
+1
View File
@@ -31,3 +31,4 @@ 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 CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
+1
View File
@@ -4,3 +4,4 @@ export const SAVE_BIO_FAILURE = 'SAVE_BIO_FAILURE';
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
+22 -2
View File
@@ -2,16 +2,30 @@ export const base = '/api/v1';
const buildOptions = (inputOptions = {}) => {
const csurfDOM = document.head.querySelector('[property=csrf]');
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin'
credentials: 'same-origin',
_csrf: csurfDOM ? csurfDOM.content : false
};
const options = Object.assign({}, defaultOptions, inputOptions);
if (options._csrf) {
switch (options.method.toLowerCase()) {
case 'post':
case 'put':
case 'delete':
options.headers['x-csrf-token'] = options._csrf;
break;
}
}
if (options.method.toLowerCase() !== 'get') {
options.body = JSON.stringify(options.body);
}
@@ -23,7 +37,13 @@ const handleResp = res => {
if (res.status === 401) {
throw new Error('Not Authorized to make this request');
} else if (res.status > 399) {
throw new Error('Error! Status ', res.status);
return res.json().then(err => {
let message = err.message || res.status;
if (err.error && err.error.translation_key) {
message = err.error.translation_key;
}
throw new Error(message);
});
} else if (res.status === 204) {
return res.text();
} else {
+1 -1
View File
@@ -2,5 +2,5 @@ export default {
email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
displayName: displayName => (/^(?=.{3,}).*$/.test(displayName))
displayName: displayName => (/^[a-z0-9_]+$/.test(displayName))
};
+3
View File
@@ -41,6 +41,9 @@ export default function auth (state = initialState, action) {
.set('view', action.view);
case actions.CLEAN_STATE:
return initialState;
case actions.CHECK_CSRF_TOKEN:
return state
.set('_csrf', action._csrf);
case actions.FETCH_SIGNIN_REQUEST:
return state
.set('isLoading', true);
+3 -2
View File
@@ -31,12 +31,13 @@ export default function user (state = initialState, action) {
case authActions.FETCH_SIGNIN_FACEBOOK_FAILURE:
return initialState;
case actions.SAVE_BIO_SUCCESS:
return state
.set('settings', action.settings);
return state.set('settings', action.settings);
case actions.COMMENTS_BY_USER_SUCCESS:
return state.set('myComments', action.comments);
case assetActions.MULTIPLE_ASSETS_SUCCESS:
return state.set('myAssets', action.assets);
case actions.LOGOUT_SUCCESS:
return initialState;
default :
return state;
}
+17 -5
View File
@@ -7,10 +7,16 @@
"error": {
"email": "Not a valid E-Mail",
"password": "Password must be at least 8 characters",
"displayName": "Display name is too short",
"confirmPassword": "Passwords don`t match. Please, check again",
"displayName": "Display names can contain letters, numbers and _ only",
"confirmPassword": "Passwords don't match. Please, check again",
"emailPasswordError": "Email and/or password combination incorrect.",
"emailInUse": "Email address already in use"
"EMAIL_REQUIRED": "An email address is required",
"PASSWORD_REQUIRED": "Must input a password",
"PASSWORD_LENGTH": "Password is too short",
"EMAIL_IN_USE": "Email address already in use",
"DISPLAY_NAME_REQUIRED": "Must input a display name",
"NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only",
"PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error."
}
},
"es": {
@@ -21,10 +27,16 @@
"error": {
"email": "No es un email válido",
"password": "La contraseña debe tener por lo menos 8 caracteres",
"displayName": "El nombre es muy corto",
"displayName": "Los nombres pueden contener letras, números y _",
"confirmPassword": "Las contraseñas no coinciden",
"emailPasswordError": "Email y/o contraseña incorrecta.",
"emailInUse": "Email address already in use"
"EMAIL_REQUIRED": "Se requiere una dirección de correo electrónico",
"PASSWORD_REQUIRED": "Debe ingresar una contraseña",
"PASSWORD_LENGTH": "La contraseña es muy corta",
"EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso",
"DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre",
"NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _",
"PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error"
}
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ class FlagButton extends Component {
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
<button onClick={this.onReportClick} className={`${name}-button`}>
<button onClick={!this.props.banned ? this.onReportClick : null} className={`${name}-button`}>
{
flagged
? <span className={`${name}-button-text`}>{lang.t('reported')}</span>
@@ -10,7 +10,11 @@ import CommentHistory from 'coral-plugin-history/CommentHistory';
import SettingsHeader from '../components/SettingsHeader';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
class SignInContainer extends Component {
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
class SettingsContainer extends Component {
constructor (props) {
super(props);
this.state = {
@@ -59,7 +63,7 @@ class SignInContainer extends Component {
? <CommentHistory
comments={commentsMostRecentFirst}
assets={user.myAssets.map(id => items.assets[id])} />
: <p>Loading comment history...</p>
: <p>{lang.t('user-no-comment')}</p>
}
</TabContent>
<TabContent show={activeTab === 1}>
@@ -83,4 +87,4 @@ const mapDispatchToProps = dispatch => ({
export default connect(
mapStateToProps,
mapDispatchToProps
)(SignInContainer);
)(SettingsContainer);
+8
View File
@@ -0,0 +1,8 @@
{
"en":{
"user-no-comment": "This user has not yet left a comment."
},
"es":{
"user-no-comment": "Aún no ha escrito ningún comentario."
}
}
+2
View File
@@ -21,6 +21,7 @@ export default {
emailInUse: 'Email address already in use',
requiredField: 'This field is required',
passwordsDontMatch: 'Passwords don\'t match.',
specialCharacters: 'Display names can contain letters, numbers and _ only',
checkTheForm: 'Invalid Form. Please, check the fields'
}
},
@@ -46,6 +47,7 @@ export default {
emailInUse: 'Este email se encuentra en uso',
requiredField: 'Este campo es requerido',
passwordsDontMatch: 'Las contraseñas no coinciden',
specialCharacters: 'Los nombres pueden contener letras, números y _',
checkTheForm: 'Formulario Inválido. Por favor, completa los campos'
}
}
+3 -1
View File
@@ -42,7 +42,9 @@ export default class Dialog extends Component {
componentWillUnmount() {
const dialog = this.dialog;
dialog.removeEventListener('cancel', this.props.onCancel);
if (dialog) {
dialog.removeEventListener('cancel', this.props.onCancel);
}
}
render() {
+1 -1
View File
@@ -583,7 +583,7 @@ paths:
description: The user that has been created.
schema:
$ref: '#/definitions/User'
/users/update-password:
/account/password/reset:
post:
parameters:
- name: body
+45
View File
@@ -0,0 +1,45 @@
// ErrPasswordTooShort is returned when the password length is too short.
const ErrPasswordTooShort = new Error('password must be at least 8 characters');
ErrPasswordTooShort.translation_key = 'PASSWORD_LENGTH';
ErrPasswordTooShort.status = 400;
const ErrMissingEmail = new Error('email is required');
ErrMissingEmail.translation_key = 'EMAIL_REQUIRED';
ErrMissingEmail.status = 400;
const ErrMissingPassword = new Error('password is required');
ErrMissingPassword.translation_key = 'PASSWORD_REQUIRED';
ErrMissingPassword.status = 400;
const ErrEmailTaken = new Error('Email address already in use');
ErrEmailTaken.translation_key = 'EMAIL_IN_USE';
ErrEmailTaken.status = 400;
const ErrSpecialChars = new Error('No special characters are allowed in a display name');
ErrSpecialChars.translation_key = 'NO_SPECIAL_CHARACTERS';
ErrSpecialChars.status = 400;
const ErrMissingDisplay = new Error('A display name is required to create a user');
ErrMissingDisplay.translation_key = 'DISPLAY_NAME_REQUIRED';
ErrMissingDisplay.status = 400;
// ErrMissingToken is returned in the event that the password reset is requested
// without a token.
const ErrMissingToken = new Error('token is required');
ErrMissingToken.status = 400;
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new Error('Suspected profanity. If you think this in error, please let us know!');
ErrContainsProfanity.translation_key = 'PROFANITY_ERROR';
ErrContainsProfanity.status = 400;
module.exports = {
ErrPasswordTooShort,
ErrMissingEmail,
ErrMissingPassword,
ErrEmailTaken,
ErrSpecialChars,
ErrMissingDisplay,
ErrContainsProfanity
};
+1 -1
View File
@@ -3,5 +3,5 @@ const Setting = require('./models/setting');
module.exports = () => Promise.all([
// Upsert the settings object.
Setting.init({id: '1', moderation: 'pre'})
Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}})
]);
+1 -1
View File
@@ -13,7 +13,7 @@ const ActionSchema = new Schema({
item_type: String,
item_id: String,
user_id: String,
metadata: Object, //Holds arbitrary metadata about the action.
metadata: Schema.Types.Mixed
}, {
timestamps: {
createdAt: 'created_at',
+7 -17
View File
@@ -1,7 +1,6 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const _ = require('lodash');
const cache = require('../services/cache');
const WordlistSchema = new Schema({
banned: [String],
@@ -53,6 +52,10 @@ const SettingSchema = new Schema({
charCountEnable: {
type: Boolean,
default: false
},
requireEmailConfirmation: {
type: Boolean,
default: false
}
}, {
timestamps: {
@@ -98,7 +101,8 @@ SettingSchema.method('filterForUser', function(user = false) {
'closeTimeout',
'closedMessage',
'charCountEnable',
'charCount'
'charCount',
'requireEmailConfirmation'
]);
}
@@ -121,19 +125,11 @@ const SettingService = module.exports = {};
*/
const selector = {id: '1'};
/**
* Cache expiry time in seconds for when the cached entry of the settings object
* expires. 2 minutes.
*/
const EXPIRY_TIME = 60 * 2;
/**
* Gets the entire settings record and sends it back
* @return {Promise} settings the whole settings record
*/
SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => {
return Setting.findOne(selector);
}).then((setting) => new Setting(setting));
SettingService.retrieve = () => Setting.findOne(selector);
/**
* This will update the settings object with whatever you pass in
@@ -146,12 +142,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, {
upsert: true,
new: true,
setDefaultsOnInsert: true
}).then((settings) => {
// Invalidate the settings cache.
return cache
.set('settings', settings, EXPIRY_TIME)
.then(() => settings);
});
/**
+217 -94
View File
@@ -4,8 +4,12 @@ const _ = require('lodash');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const Action = require('./action');
const Comment = require('./comment');
const Wordlist = require('../services/wordlist');
const errors = require('../errors');
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run
// through during the salting process.
@@ -31,6 +35,37 @@ if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
}
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
const ProfileSchema = new mongoose.Schema({
// ID provides the identifier for the user profile, in the case of a local
// provider, the id would be an email, in the case of a social provider,
// the id would be the foreign providers identifier.
id: {
type: String,
required: true
},
// Provider is simply the name attached to the authentication mode. In the
// case of a locally provided profile, this will simply be `local`, or a
// social provider which for Facebook would just be `facebook`.
provider: {
type: String,
required: true
},
// Metadata provides a place to put provider specific details. An example of
// something that could be stored here is the `metadata.confirmed_at` could be
// used by the `local` provider to indicate when the email address was
// confirmed.
metadata: {
type: mongoose.Schema.Types.Mixed
}
}, {
_id: false
});
// UserSchema is the mongoose schema defined as the representation of a User in
// MongoDB.
const UserSchema = new mongoose.Schema({
@@ -60,26 +95,7 @@ const UserSchema = new mongoose.Schema({
// Profiles describes the array of identities for a given user. Any one user
// can have multiple profiles associated with them, including multiple email
// addresses.
profiles: [new mongoose.Schema({
// ID provides the identifier for the user profile, in the case of a local
// provider, the id would be an email, in the case of a social provider,
// the id would be the foreign providers identifier.
id: {
type: String,
required: true
},
// Provider is simply the name attached to the authentication mode. In the
// case of a locally provided profile, this will simply be `local`, or a
// social provider which for Facebook would just be `facebook`.
provider: {
type: String,
required: true
}
}, {
_id: false
})],
profiles: [ProfileSchema],
// Roles provides an array of roles (as strings) that is associated with a
// user.
@@ -295,6 +311,27 @@ UserService.createLocalUsers = (users) => {
}));
};
/**
* Check the requested displayname for naughty words (currently in English) and special chars
* @param {String} displayName word to be checked for profanity
* @return {Promise} rejected if the machine's sensibilites are offended
*/
const isValidDisplayName = (displayName) => {
const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
if (!displayName) {
return Promise.reject(errors.ErrMissingDisplay);
}
if (!onlyLettersNumbersUnderscore.test(displayName)) {
return Promise.reject(errors.ErrSpecialChars);
}
// check for profanity
return Wordlist.displayNameCheck(displayName);
};
/**
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
@@ -303,49 +340,54 @@ UserService.createLocalUsers = (users) => {
* @param {Function} done callback
*/
UserService.createLocalUser = (email, password, displayName) => {
if (!email) {
return Promise.reject('email is required');
return Promise.reject(errors.ErrMissingEmail);
}
email = email.toLowerCase();
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
if (!password) {
return Promise.reject('password is required');
return Promise.reject(errors.ErrMissingPassword);
}
if (!displayName) {
return Promise.reject('displayName is required');
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
if (err) {
return reject(err);
}
let user = new UserModel({
displayName: displayName,
password: hashedPassword,
roles: [],
profiles: [
{
id: email,
provider: 'local'
return isValidDisplayName(displayName)
.then(() => { // displayName is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
if (err) {
return reject(err);
}
]
});
user.save((err) => {
if (err) {
if (err.code === 11000) {
return reject('Email address already in use');
}
return reject(err);
}
return resolve(user);
let user = new UserModel({
displayName: displayName,
password: hashedPassword,
roles: [],
profiles: [
{
id: email,
provider: 'local'
}
]
});
user.save((err) => {
if (err) {
if (err.code === 11000) {
return reject(errors.ErrEmailTaken);
}
return reject(err);
}
return resolve(user);
});
});
});
});
});
};
/**
@@ -499,47 +541,63 @@ UserService.createPasswordResetToken = function (email) {
email = email.toLowerCase();
return UserModel.findOne({profiles: {$elemMatch: {id: email}}})
.then(user => {
.then((user) => {
if (!user) {
if (user === null) {
// since we don't want to reveal that the email does/doesn't exist
// just go ahead and resolve the Promise with null and check in the endpoint
return Promise.resolve(null);
// Since we don't want to reveal that the email does/doesn't exist
// just go ahead and resolve the Promise with null and check in the
// endpoint.
return;
}
const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v};
const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'});
const payload = {
jti: uuid.v4(),
email,
userId: user.id,
version: user.__v
};
return token;
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
algorithm: 'HS256',
expiresIn: '1d',
subject: PASSWORD_RESET_JWT_SUBJECT
});
});
};
/**
* verifies a jwt and returns the associated user
* @param {String} token the JSON Web Token to verify
* Verifies that the token was indeed signed by the session secret.
* @param {String} token JWT token from the client
* @return {Promise}
*/
UserService.verifyPasswordResetToken = token => {
UserService.verifyToken = (token, options = {}) => {
return new Promise((resolve, reject) => {
jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => {
if (error) {
return reject(error);
// Set the allowed algorithms.
options.algorithms = ['HS256'];
jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
if (err) {
return reject(err);
}
resolve(decoded);
});
})
.then(decoded => {
/**
* TODO: check the jti from this decoded token in redis
* and make an entry if it does not exist.
* reject if entry already exists.
*/
return UserService.findById(decoded.userId);
});
};
/**
* Verifies a jwt and returns the associated user.
* @param {String} token the JSON Web Token to verify
*/
UserService.verifyPasswordResetToken = (token) => {
return UserService
.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT
})
.then((decoded) => UserService.findById(decoded.userId));
};
/**
* Finds a user using a value which gets compared using a prefix match against
* the user's email address and/or their display name.
@@ -578,34 +636,25 @@ UserService.search = (value) => {
* Returns a count of the current users.
* @return {Promise}
*/
UserService.count = () => {
return UserModel.count();
};
UserService.count = () => UserModel.count();
/**
* Returns all the users.
* @return {Promise}
*/
UserService.all = () => {
return UserModel.find();
};
UserService.all = () => UserModel.find();
/**
* Adds a new User bio
* Updates the user's settings.
* @return {Promise}
*/
UserService.addBio = (id, bio) => (
UserModel.findOneAndUpdate({
id
}, {
$set: {
'settings.bio': bio
}
}, {
new: true
})
);
UserService.updateSettings = (id, settings) => UserModel.update({
id
}, {
$set: {
settings
}
});
/**
* Add an action to the user.
@@ -621,3 +670,77 @@ UserService.addAction = (item_id, user_id, action_type, metadata) => Action.inse
action_type,
metadata
});
/**
* This creates a token based around confirming the local profile.
* @param {String} userID The user id for the user that we are creating the
* token for.
* @param {String} email The email that we are needing to get confirmed.
* @return {Promise}
*/
UserService.createEmailConfirmToken = (userID, email) => {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
email = email.toLowerCase();
return UserService
.findById(userID)
.then((user) => {
if (!user) {
return Promise.reject(new Error('user not found'));
}
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
return Promise.reject(new Error('email address already confirmed'));
}
const payload = {
email,
userID
};
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
jwtid: uuid.v4(),
algorithm: 'HS256',
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
});
});
};
/**
* This verifies that a given token was for the email confirmation and updates
* that user's profile with a 'confirmed_at' parameter with the current date.
* @param {String} token the token containing the email confirmation details
* signed with our secret.
* @return {Promise}
*/
UserService.verifyEmailConfirmation = (token) => {
return UserService
.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT
})
.then(({userID, email}) => {
return UserModel
.update({
id: userID,
profiles: {
$elemMatch: {
id: email,
provider: 'local'
}
}
}, {
$set: {
'profiles.$.metadata.confirmed_at': new Date()
}
});
});
};
+3 -2
View File
@@ -9,8 +9,8 @@
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
"test": "NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
"test": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
@@ -52,6 +52,7 @@
"cli-table": "^0.3.1",
"commander": "^2.9.0",
"connect-redis": "^3.1.0",
"csurf": "^1.9.0",
"debug": "^2.2.0",
"ejs": "^2.5.2",
"env-rewrite": "^1.0.2",
+133
View File
@@ -0,0 +1,133 @@
const express = require('express');
const router = express.Router();
const User = require('../../../models/user');
const mailer = require('../../../services/mailer');
const authorization = require('../../../middleware/authorization');
const errors = require('../../../errors');
//==============================================================================
// ROUTES
//==============================================================================
router.get('/', authorization.needed(), (req, res, next) => {
res.json(req.user);
});
// POST /email/confirm takes the password confirmation token available as a
// payload parameter and if it verifies, it updates the confirmed_at date on the
// local profile.
router.post('/email/confirm', (req, res, next) => {
const {
token
} = req.body;
if (!token) {
return next(errors.ErrMissingToken);
}
User
.verifyEmailConfirmation(token)
.then(() => {
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
/**
* this endpoint takes an email (username) and checks if it belongs to a User account
* if it does, create a JWT and send an email
*/
router.post('/password/reset', (req, res, next) => {
const {email} = req.body;
if (!email) {
return next('you must submit an email when requesting a password.');
}
User
.createPasswordResetToken(email)
.then((token) => {
// Check to see if the token isn't defined.
if (!token) {
// As it isn't, don't send any emails!
return;
}
return mailer.sendSimple({
app: req.app, // needed to render the templates.
template: 'email/password-reset', // needed to know which template to render!
locals: { // specifies the template locals.
token,
rootURL: process.env.TALK_ROOT_URL
},
subject: 'Password Reset',
to: email
});
})
.then(() => {
// we want to send a 204 regardless of the user being found in the db
// if we fail on missing emails, it would reveal if people are registered or not.
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
/**
* expects 2 fields in the body of the request
* 1) the token that was in the url of the email link {String}
* 2) the new password {String}
*/
router.put('/password/reset', (req, res, next) => {
const {
token,
password
} = req.body;
if (!token) {
return next(errors.ErrMissingToken);
}
if (!password || password.length < 8) {
return next(errors.ErrPasswordTooShort);
}
User.verifyPasswordResetToken(token)
.then(user => {
return User.changePassword(user.id, password);
})
.then(() => {
res.status(204).end();
})
.catch(error => {
console.error(error);
next(authorization.ErrNotAuthorized);
});
});
router.put('/settings', authorization.needed(), (req, res, next) => {
const {
bio
} = req.body;
User
.updateSettings(req.user.id, {bio})
.then(() => {
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
module.exports = router;
+10 -8
View File
@@ -8,29 +8,31 @@ const router = express.Router();
* This returns the user if they are logged in.
*/
router.get('/', (req, res, next) => {
if (req.user) {
return next();
}
// When there is no user on the request, then just send back a 204 to this
// request. It's not really "an error" if what they asked for isn't available,
// but it could be.
res.status(204).end();
}, (req, res) => {
// Send back the user object.
res.json(req.user.toObject());
res.json({user: req.user.toObject()});
});
/**
* This destroys the session of a user, if they have one.
*/
router.delete('/', authorization.needed(), (req, res) => {
req.session.destroy(() => {
res.status(204).end();
});
delete req.session.passport;
res.status(204).end();
});
//==============================================================================
// PASSPORT ROUTES
//==============================================================================
/**
* This sends back the user data as JSON.
*/
@@ -49,7 +51,7 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
return next(err);
}
// We logged in the user! Let's send back the user data.
// We logged in the user! Let's send back the user data and the CSRF token.
res.json({user});
});
};
+1 -1
View File
@@ -137,7 +137,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
.then((comment) => {
if (req.wordlist.suspect) {
return Comment
.addAction(comment.id, null, 'flag', 'body', 'Matched suspect word filters.')
.addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'})
.then(() => comment);
}
+1
View File
@@ -17,6 +17,7 @@ router.use('/actions', authorization.needed(), require('./actions'));
router.use('/auth', require('./auth'));
router.use('/stream', require('./stream'));
router.use('/users', require('./users'));
router.use('/account', require('./account'));
// Bind the kue handler to the /kue path.
router.use('/kue', authorization.needed('admin'), require('../../services/kue').kue.app);
+1
View File
@@ -8,6 +8,7 @@ const User = require('../../../models/user');
const Action = require('../../../models/action');
const Asset = require('../../../models/asset');
const Setting = require('../../../models/setting');
const ErrInvalidAssetURL = new Error('asset_url is invalid');
ErrInvalidAssetURL.status = 400;
+90 -99
View File
@@ -1,12 +1,8 @@
const express = require('express');
const router = express.Router();
const User = require('../../../models/user');
const Setting = require('../../../models/setting');
const mailer = require('../../../services/mailer');
const ejs = require('ejs');
const fs = require('fs');
const path = require('path');
const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs'));
const resetEmailTemplate = ejs.compile(resetEmailFile.toString());
const authorization = require('../../../middleware/authorization');
router.get('/', authorization.needed('admin'), (req, res, next) => {
@@ -50,116 +46,78 @@ router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) =>
router.post('/:user_id/status', (req, res, next) => {
User
.setStatus(req.params.user_id, req.body.status, req.body.comment_id)
.then(status => {
res.json(status);
.then((status) => {
res.status(201).json(status);
})
.catch(next);
});
// /**
// * SendEmailConfirmation sends a confirmation email to the user.
// * @param {Request} req express request object
// * @param {String} email user email address
// */
/**
* SendEmailConfirmation sends a confirmation email to the user.
* @param {ExpressApp} app the instance of the express app
* @param {String} userID the id for the user to send the email to
* @param {String} email the email for the user to send the email to
*/
const SendEmailConfirmation = (app, userID, email) => User
.createEmailConfirmToken(userID, email)
.then((token) => {
return mailer.sendSimple({
app, // needed to render the templates.
template: 'email/email-confirm', // needed to know which template to render!
locals: { // specifies the template locals.
token,
rootURL: process.env.TALK_ROOT_URL,
email
},
subject: 'Email Confirmation',
to: email
});
});
router.post('/', (req, res, next) => {
const {email, password, displayName} = req.body;
const {
email,
password,
displayName
} = req.body;
User
.createLocalUser(email, password, displayName)
.then(user => {
.then((user) => {
res.status(201).json(user);
// Get the settings from the database to find out if we need to send an
// email confirmation. The Front end will know about the
// requireEmailConfirmation as it's included in the settings get endpoint.
return Setting.retrieve().then(({requireEmailConfirmation = false}) => {
if (requireEmailConfirmation) {
SendEmailConfirmation(req.app, user.id, email)
.then(() => {
// Then send back the user.
res.status(201).json(user);
});
} else {
// We don't need to confirm the email, let's just send back the user!
res.status(201).json(user);
}
});
})
.catch(err => {
next(err);
});
});
const ErrPasswordTooShort = new Error('password must be at least 8 characters');
ErrPasswordTooShort.status = 400;
router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
/**
* expects 2 fields in the body of the request
* 1) the token that was in the url of the email link {String}
* 2) the new password {String}
*/
router.post('/update-password', (req, res, next) => {
const {token, password} = req.body;
if (!password || password.length < 8) {
return next(ErrPasswordTooShort);
}
User.verifyPasswordResetToken(token)
.then(user => {
return User.changePassword(user.id, password);
})
.then(() => {
res.status(204).end();
})
.catch(error => {
console.error(error);
next(authorization.ErrNotAuthorized);
});
});
/**
* this endpoint takes an email (username) and checks if it belongs to a User account
* if it does, create a JWT and send an email
*/
router.post('/request-password-reset', (req, res, next) => {
const {email} = req.body;
if (!email) {
return next('you must submit an email when requesting a password.');
}
User
.createPasswordResetToken(email)
.then(token => {
if (token === null) {
return Promise.resolve('the email was not found in the db.');
}
const options = {
subject: 'Password Reset Requested - Talk',
from: process.env.TALK_SMTP_FROM_ADDRESS,
to: email,
html: resetEmailTemplate({
token,
// probably more clear to explicitly pass this
rootURL: process.env.TALK_ROOT_URL
})
};
return mailer.sendSimple(options);
})
.then(() => {
// we want to send a 204 regardless of the user being found in the db
// if we fail on missing emails, it would reveal if people are registered or not.
res.status(204).end();
})
.catch((err) => {
next(err);
});
});
router.put('/:user_id/bio', (req, res, next) => {
const {user_id} = req.params;
const {bio} = req.body;
if (!bio) {
return next('You must submit a new bio');
}
User
.addBio(user_id, bio)
.then(user => {
res.json(user);
})
.catch((err) => {
next(err);
});
});
router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
const {
action_type,
metadata
@@ -175,4 +133,37 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
});
});
router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => {
const {
user_id
} = req.params;
User
.findById(user_id)
.then((user) => {
if (!user) {
res.status(404).end();
return;
}
// Find the first local profile.
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
// If there was no local profile for the user, error out.
if (!localProfile) {
res.status(404).end();
return;
}
// Send the email to the first local profile that was found.
return SendEmailConfirmation(req.app, user.id, localProfile.id)
.then(() => {
res.status(204).end();
});
})
.catch((err) => {
next(err);
});
});
module.exports = router;
+4 -4
View File
@@ -4,12 +4,12 @@
selenium-standalone install
# Creating Admin Test User
{ echo admin@test.com; echo test; echo test; echo Admin Test User; echo admin;} | dotenv ./bin/cli-users create
./bin/cli-users create --flag_mode --email "admin@test.com" --password "test" --name "Admin Test User" --role "admin"
# Creating Moderator Test User
{ echo moderator@test.com; echo test; echo test; echo Moderator Test User; echo moderator;} | dotenv ./bin/cli-users create
./bin/cli-users create --flag_mode --email "moderator@test.com" --password "test" --name "Moderator Test User" --role "moderator"
# Creating Commenter Test User
{ echo commenter@test.com; echo test; echo test; echo Commenter Test User; echo ;} | dotenv ./bin/cli-users create
./bin/cli-users create --flag_mode --email "commenter@test.com" --password "test" --name "commenter@test.com"
npm start
npm start &
+132 -9
View File
@@ -1,11 +1,134 @@
const kue = require('kue');
const debug = require('debug')('talk:services:kue');
const redis = require('./redis');
module.exports = {
queue: kue.createQueue({
redis: {
createClientFactory: () => redis.createClient()
}
}),
kue
};
module.exports = {};
const kue = module.exports.kue = require('kue');
// Note that unlike what the name createQueue suggests, it currently returns a
// singleton Queue instance. So you can configure and use only a single Queue
// object within your node.js process.
const Queue = module.exports.queue = kue.createQueue({
redis: {
createClientFactory: () => redis.createClient()
}
});
class Task {
constructor({name, attempts = 3, delay = 1000}) {
this.name = name;
this.attempts = attempts;
this.delay = delay;
}
/**
* Add a new job to the queue.
*/
create(data) {
debug(`Creating new job for Queue[${this.name}]`);
return new Promise((resolve, reject) => {
let job = Queue
.create(this.name, data)
.attempts(this.attempts)
.delay(this.delay)
.backoff({type: 'exponential'})
.save((err) => {
if (err) {
return reject(err);
}
debug(`Job[${job.id}] created on Queue[${this.name}]`);
return resolve(job);
});
});
}
/**
* Process jobs for the queue.
*/
process(callback) {
return Queue.process(this.name, callback);
}
/**
* Shutdown running jobs.
*/
static shutdown() {
debug('Shutting down the Queue');
return new Promise((resolve, reject) => {
// Shutdown and give the queue 5 seconds to shutdown before we start
// killing jobs.
Queue.shutdown(5000, (err) => {
if (err) {
return reject(err);
}
debug('Queue shut down.');
resolve();
});
});
}
}
/**
* Stores the tasks during testing.
* @type {Array}
*/
const TestQueue = [];
/**
* TestTask is a Task queue that is implemented for when the application is in
* test mode, and does not send the jobs to redis, instead it queues them in
* an array which can be inspected.
*/
class TestTask {
constructor({name}) {
this.name = name;
}
/**
* Push the task into the fake queue.
*/
create(task) {
let id = TestQueue.push({
name: this.name,
task
});
return Promise.resolve({id});
}
// This is a NO-OP action simply provided to match the Task interface.
process() { return null; }
/**
* Returns the current tasks for this queue.
* @return {Array} the tasks in the queue
*/
get tasks() {
return TestQueue
.filter((testTask) => testTask.name === this.name)
.map((testTask) => testTask.task);
}
static shutdown() {
return Task.shutdown();
}
}
if (process.env.NODE_ENV === 'test') {
module.exports.Task = TestTask;
module.exports.TestQueue = TestQueue;
} else {
module.exports.Task = Task;
}
+88 -25
View File
@@ -1,4 +1,6 @@
const debug = require('debug')('talk:services:mailer');
const nodemailer = require('nodemailer');
const kue = require('./kue');
const smtpRequiredProps = [
'TALK_SMTP_FROM_ADDRESS',
@@ -7,11 +9,9 @@ const smtpRequiredProps = [
'TALK_SMTP_HOST'
];
smtpRequiredProps.forEach(prop => {
if (!process.env[prop]) {
console.error(`process.env.${prop} should be defined if you would like to send password reset emails from Talk`);
}
});
if (smtpRequiredProps.some(prop => !process.env[prop])) {
console.error(`${smtpRequiredProps.join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`);
}
const options = {
host: process.env.TALK_SMTP_HOST,
@@ -29,29 +29,92 @@ if (process.env.TALK_SMTP_PORT) {
const defaultTransporter = nodemailer.createTransport(options);
const mailer = {
const mailer = module.exports = {
/**
* sendSimple
*
* @param {Object} {from, to, subject, text = '', html = ''}
* @returns
*/
sendSimple({from, to, subject, text = '', html = '', transporter = defaultTransporter}) {
return new Promise((resolve, reject) => {
if (!from) {
reject('sendSimple requires a from address');
}
if (!to) {
reject('sendSimple requires a comma-separated list of "to" addresses');
}
if (!subject) {
reject('sendSimple requires a subject for the email');
}
* Create the new Task kue.
*/
task: new kue.Task({
name: 'mailer'
}),
return resolve(transporter.sendMail({from, to, subject, text, html}));
/**
* Render renders the template with the given locals and returns the rendered
* html/text.
*/
render(app, template, locals = {}) {
return new Promise((resolve, reject) => {
// Render the template with the app.render method.
app.render(template, locals, (err, rendered) => {
if (err) {
return reject(err);
}
return resolve(rendered);
});
});
},
sendSimple({app, template, locals, to, subject}) {
if (!to) {
return Promise.reject('sendSimple requires a comma-separated list of "to" addresses');
}
if (!subject) {
return Promise.reject('sendSimple requires a subject for the email');
}
// Prefix the subject with `[Talk]`.
subject = `[Talk] ${subject}`;
return Promise.all([
// Render the HTML version of the email.
mailer.render(app, `${template}.ejs`, locals),
// Render the TEXT version of the email.
mailer.render(app, `${template}.txt.ejs`, locals)
])
.then(([html, text]) => {
// Create the job.
return mailer.task.create({
title: 'Mail',
message: {
to,
subject,
text,
html
}
});
});
},
/**
* Start the queue processor for the mailer job.
*/
process() {
debug(`Now processing ${mailer.task.name} jobs`);
return mailer.task.process(({id, data}, done) => {
debug(`Starting to send mail for Job[${id}]`);
// Set the `from` field.
data.message.from = process.env.TALK_SMTP_FROM_ADDRESS;
// Actually send the email.
defaultTransporter.sendMail(data.message, (err) => {
if (err) {
debug(`Failed to send mail for Job[${id}]:`, err);
return done(err);
}
debug(`Finished sending mail for Job[${id}]`);
return done();
});
});
}
};
module.exports = mailer;
};
+37 -5
View File
@@ -1,21 +1,53 @@
const mongoose = require('mongoose');
const debug = require('debug')('talk:db');
const queryDebuger = require('debug')('talk:db:query');
// Loading the formatter from Mongoose:
//
// https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182
//
// so we can wrap parameters.
const formatter = require('mongoose').Collection.prototype.$format;
// Provide a newly wrapped debugQuery function which wraps the `debug` package.
function debugQuery(name, i, ...args) {
let functionCall = ['db', name, i].join('.');
let _args = [];
for (let j = args.length - 1; j >= 0; --j) {
if (formatter(args[j]) || _args.length) {
_args.unshift(formatter(args[j]));
}
}
let params = `(${_args.join(', ')})`;
queryDebuger(functionCall + params);
}
const enabled = require('debug').enabled;
// Append '-test' to the db if node_env === 'test'
let url = process.env.TALK_MONGO_URL || 'mongodb://localhost/coral-talk';
// Pull the mongo url out of the environment.
let url = process.env.TALK_MONGO_URL;
if (process.env.NODE_ENV === 'test') {
url += '-test';
// Reset the mongo url in the event it hasn't been overrided and we are in a
// testing environment. Every new mongo instance comes with a test database by
// default, this is consistent with common testing and use case practices.
if (process.env.NODE_ENV === 'test' && !url) {
url = 'mongodb://localhost/test';
}
// Use native promises
mongoose.Promise = global.Promise;
// Check if debugging is enabled on the talk:db prefix.
if (enabled('talk:db')) {
mongoose.set('debug', true);
// Enable the mongoose debugger, here we wrap the similar print function
// provided by setting the debug parameter.
mongoose.set('debug', debugQuery);
}
// Connect to the Mongo instance.
mongoose.connect(url, (err) => {
if (err) {
throw err;
+41 -6
View File
@@ -1,5 +1,6 @@
const passport = require('passport');
const User = require('../models/user');
const Setting = require('../models/setting');
const LocalStrategy = require('passport-local').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
@@ -27,7 +28,7 @@ passport.deserializeUser((id, done) => {
* @param {User} user the user to be validated
* @param {Function} done the callback for the validation
*/
function ValidateUserLogin(user, done) {
function ValidateUserLogin(loginProfile, user, done) {
if (!user) {
return done(new Error('user not found'));
}
@@ -36,7 +37,36 @@ function ValidateUserLogin(user, done) {
return done(null, false, {message: 'Account disabled'});
}
return done(null, user);
// If the user isn't a local user (i.e., a social user).
if (loginProfile.provider !== 'local') {
return done(null, user);
}
// The user is a local user, check if we need email confirmation.
return Setting.retrieve().then(({requireEmailConfirmation = false}) => {
// If we have the requirement of checking that emails for users are
// verified, then we need to check the email address to ensure that it has
// been verified.
if (requireEmailConfirmation) {
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === loginProfile.id);
// This should never get to this point, if it does, don't let this past.
if (!profile) {
throw new Error('ID indicated by loginProfile is not on user object');
}
// If the profile doesn't have a metadata field, or it does not have a
// confirmed_at field, or that field is null, then send them back.
if (!profile.metadata || !profile.metadata.confirmed_at || profile.metadata.confirmed_at === null) {
return done(null, false, {message: `Email address ${loginProfile.id} not verified.`});
}
}
return done(null, user);
});
}
//==============================================================================
@@ -54,7 +84,12 @@ passport.use(new LocalStrategy({
return done(null, false, {message: 'Incorrect email/password combination'});
}
return ValidateUserLogin(user, done);
// Define the loginProfile being used to perform an additional
// verificaiton.
let loginProfile = {id: email, provider: 'local'};
// Validate the user login.
return ValidateUserLogin(loginProfile, user, done);
})
.catch((err) => {
done(err);
@@ -70,9 +105,9 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
}, (accessToken, refreshToken, profile, done) => {
User
.findOrCreateExternalUser(profile)
.then((user) =>
ValidateUserLogin(user, done)
)
.then((user) => {
return ValidateUserLogin(profile, user, done);
})
.catch((err) => {
done(err);
});
+21 -43
View File
@@ -1,7 +1,6 @@
const kue = require('./kue');
const debug = require('debug')('talk:services:scraper');
const Asset = require('../models/asset');
const JOB_NAME = 'scraper';
const metascraper = require('metascraper');
@@ -12,29 +11,27 @@ const metascraper = require('metascraper');
const scraper = {
/**
* creates a new scraper job and scrapes the url when it gets processed.
* Create the new Task kue.
*/
task: new kue.Task({
name: 'scraper'
}),
/**
* Creates a new scraper job and scrapes the url when it gets processed.
*/
create(asset) {
return new Promise((resolve, reject) => {
debug(`Creating job for Asset[${asset.id}]`);
let job = kue.queue
.create(JOB_NAME, {
title: `Scrape for asset ${asset.id}`,
asset_id: asset.id
})
.attempts(3)
.delay(1000)
.backoff({type: 'exponential'})
.save((err) => {
if (err) {
return reject(err);
}
debug(`Creating job for Asset[${asset.id}]`);
debug(`Created Job[${job.id}] for Asset[${asset.id}]`);
return scraper.task.create({
title: `Scrape for asset ${asset.id}`,
asset_id: asset.id
}).then((job) => {
return resolve(job);
});
debug(`Created Job[${job.id}] for Asset[${asset.id}]`);
return job;
});
},
@@ -48,6 +45,9 @@ const scraper = {
}));
},
/**
* Updates an Asset based on scraped asset metadata.
*/
update(id, meta) {
return Asset.update({id}, {
$set: {
@@ -68,10 +68,9 @@ const scraper = {
*/
process() {
debug(`Now processing ${JOB_NAME} jobs`);
debug(`Now processing ${scraper.task.name} jobs`);
// Process jobs with the processJob function.
kue.queue.process(JOB_NAME, (job, done) => {
scraper.task.process((job, done) => {
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
@@ -111,27 +110,6 @@ const scraper = {
done(err);
});
});
},
/**
* Shuts down the current queue to ensure that the application can shutdown
* cleanly.
*/
shutdown() {
return new Promise((resolve, reject) => {
// Shutdown and give the queue 5 seconds to shutdown before we start
// killing jobs.
kue.queue.shutdown(5000, (err) => {
if (err) {
return reject(err);
}
debug(`Processing for ${JOB_NAME} jobs stopped`);
resolve();
});
});
}
};
+25 -8
View File
@@ -3,6 +3,7 @@ const _ = require('lodash');
const natural = require('natural');
const tokenizer = new natural.WordTokenizer();
const Setting = require('../models/setting');
const Errors = require('../errors');
/**
* The root wordlist object.
@@ -143,7 +144,7 @@ class Wordlist {
if (this.match(this.lists.banned, phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`);
errors.banned = ErrContainsProfanity;
errors.banned = Errors.ErrContainsProfanity;
// Stop looping through the fields now, we discovered the worst possible
// situation (a banned word).
@@ -154,7 +155,7 @@ class Wordlist {
if (this.match(this.lists.suspect, phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`);
errors.suspect = ErrContainsProfanity;
errors.suspect = Errors.ErrContainsProfanity;
// Continue looping through the fields now, we discovered a possible bad
// word (suspect).
@@ -165,6 +166,28 @@ class Wordlist {
return errors;
}
/**
* check potential username for banned words, special characters
*/
static displayNameCheck(displayName) {
const wl = new Wordlist();
return wl.load()
.then(() => {
displayName = displayName.replace(/_/g, '');
// test each word, and fail if we find a match
const hasBadWords = wl.lists.banned.some(phrase => {
return displayName.indexOf(phrase.join('')) !== -1;
});
if (hasBadWords) {
throw Errors.ErrContainsProfanity;
} else {
return Promise.resolve(displayName);
}
});
}
/**
* Connect middleware for scanning request bodies for wordlisted words and
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
@@ -194,10 +217,4 @@ class Wordlist {
}
}
// 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;
module.exports = Wordlist;
module.exports.ErrContainsProfanity = ErrContainsProfanity;
-1
View File
@@ -9,7 +9,6 @@
],
"extends": "../.eslintrc.json",
"rules": {
"no-undef": [0],
"mocha/no-exclusive-tests": "warn"
}
}
@@ -15,6 +15,7 @@ describe('itemActions', () => {
beforeEach(() => {
store = mockStore(new Map({}));
fetchMock.restore();
});
describe('getStream', () => {
@@ -103,14 +104,15 @@ describe('itemActions', () => {
});
it('should handle an error', () => {
fetchMock.get('*', 404);
return actions.getItemsArray(ids, host)(store.dispatch)
return actions.getItemsArray(ids)(store.dispatch)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
describe('postItem', () => {
// NEED TO FIGURE OUT HOW TO TEST WITH CSRF TOKEN IN.
xdescribe('postItem', () => {
const item = {
type: 'comments',
data: {body: 'stuff'}
@@ -118,7 +120,7 @@ describe('itemActions', () => {
it ('should post an item, return an id, then dispatch that item to the store', () => {
fetchMock.post('*', {id: '123'});
return actions.postItem(item.data, item.type, undefined)(store.dispatch)
return actions.postItem(item.data, item.type, undefined)(store.dispatch, store.getState)
.then((id) => {
expect(fetchMock.calls().matched[0][1]).to.deep.equal(
{
@@ -145,21 +147,21 @@ describe('itemActions', () => {
});
it('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postItem(item)(store.dispatch)
return actions.postItem(item)(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
});
});
describe('postAction', () => {
xdescribe('postAction', () => {
it ('should post an action', () => {
fetchMock.post('*', {id: '456'});
const action = {
action_type: 'flag',
detail: 'Comment smells funny'
};
return actions.postAction('abc', 'comments', action)(store.dispatch)
return actions.postAction('abc', 'comments', action)(store.dispatch, store.getState)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions');
expect(response).to.deep.equal({id:'456'});
@@ -168,7 +170,7 @@ describe('itemActions', () => {
it('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postAction('abc', 'flag', '123')(store.dispatch)
return actions.postAction('abc', 'flag', '123')(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
@@ -185,9 +187,9 @@ describe('itemActions', () => {
});
});
it('should handle an error', () => {
xit('should handle an error', () => {
fetchMock.post('*', 404);
return actions.postAction('abc', 'flag', '123')(store.dispatch)
return actions.postAction('abc', 'flag', '123')(store.dispatch, store.getState)
.catch((err) => {
expect(err).to.be.truthy;
});
+11 -3
View File
@@ -1,4 +1,4 @@
const utils = require('../../utils/e2e-mongoose');
const mongoose = require('../../helpers/mongoose');
const mocks = require('../mocks');
const mockComment = 'This is a test comment.';
@@ -12,7 +12,11 @@ const mockUser = {
module.exports = {
'@tags': ['embed-stream', 'comment', 'premodoff', 'premodon'],
before: () => {
utils.before();
mongoose.waitTillConnect(function(err) {
if (err) {
console.error(err);
}
});
},
'User registers and posts a comment with premod off': client => {
client.perform((client, done) => {
@@ -171,7 +175,11 @@ module.exports = {
});
},
after: client => {
utils.after();
mongoose.disconnect(function(err) {
if (err) {
console.error(err);
}
});
client.end();
}
};
+3 -2
View File
@@ -1,3 +1,5 @@
const uuid = require('uuid');
module.exports = {
'@tags': ['signup', 'visitor'],
before: client => {
@@ -9,11 +11,10 @@ module.exports = {
},
'Visitor signs up': client => {
const embedStreamPage = client.page.embedStreamPage();
const hash = Math.floor(Math.random() * (999 - 0));
embedStreamPage
.signUp({
email: `visitor_${hash}@test.com`,
email: `visitor_${uuid.v4()}@test.com`,
displayName: 'Visitor',
pass: 'testtest'
});
+2
View File
@@ -1,3 +1,5 @@
/* eslint-env browser */
const jsdom = require('jsdom').jsdom;
const fs = require('fs');
const path = require('path');
+38
View File
@@ -0,0 +1,38 @@
const mongoose = require('../../services/mongoose');
module.exports = {};
module.exports.waitTillConnect = function(done) {
mongoose.connection.on('open', function(err) {
if (err) {
return done(err);
}
return done();
});
};
module.exports.clearDB = function(done) {
Promise.all(Object.keys(mongoose.connection.collections).map((collection) => {
return new Promise((resolve, reject) => {
mongoose.connection.collections[collection].remove(function(err) {
if (err) {
return reject(err);
}
return resolve();
});
});
}))
.then(() => {
done();
})
.catch((err) => {
done(err);
});
};
module.exports.disconnect = function(done) {
mongoose.disconnect();
return done();
};
+7
View File
@@ -0,0 +1,7 @@
const kue = require('../services/kue');
beforeEach(() => {
// Empty the test tasks before finishing.
kue.TestQueue.splice(0, kue.TestQueue.length);
});
+23 -18
View File
@@ -4,24 +4,29 @@ const expect = require('chai').expect;
describe('models.Action', () => {
let mockActions = [];
beforeEach(() => Action.create([{
action_type: 'flag',
item_id: '123',
item_type: 'comment',
user_id: 'flagginguserid'
}, {
action_type: 'flag',
item_id: '456',
item_type: 'comment'
}, {
action_type: 'flag',
item_id: '123',
item_type: 'comment'
}, {
action_type: 'like',
item_id: '123',
item_type: 'comment'
}]).then((actions) => {
beforeEach(() => Action.create([
{
action_type: 'flag',
item_id: '123',
item_type: 'comment',
user_id: 'flagginguserid'
},
{
action_type: 'flag',
item_id: '456',
item_type: 'comment'
},
{
action_type: 'flag',
item_id: '123',
item_type: 'comment'
},
{
action_type: 'like',
item_id: '123',
item_type: 'comment'
}
]).then((actions) => {
mockActions = actions;
}));
+12 -9
View File
@@ -3,7 +3,7 @@ const User = require('../../models/user');
const Action = require('../../models/action');
const Setting = require('../../models/setting');
const settings = {id: '1', moderation: 'pre'};
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
const expect = require('chai').expect;
@@ -63,11 +63,11 @@ describe('models.Comment', () => {
const users = [{
email: 'stampi@gmail.com',
displayName: 'Stampi',
password: '1Coral!'
password: '1Coral!!'
}, {
email: 'sockmonster@gmail.com',
displayName: 'Sockmonster',
password: '2Coral!'
password: '2Coral!!'
}];
const actions = [{
@@ -82,12 +82,15 @@ describe('models.Comment', () => {
user_id: '456'
}];
beforeEach(() => Promise.all([
Setting.init(settings),
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]));
beforeEach(() => {
return Setting.init(settings).then(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
});
});
describe('#publicCreate()', () => {
+92 -19
View File
@@ -1,25 +1,30 @@
const User = require('../../models/user');
const Comment = require('../../models/comment');
const Setting = require('../../models/setting');
const expect = require('chai').expect;
describe('models.User', () => {
let mockUsers;
beforeEach(() => {
return User.createLocalUsers([{
email: 'stampi@gmail.com',
displayName: 'Stampi',
password: '1Coral!'
}, {
email: 'sockmonster@gmail.com',
displayName: 'Sockmonster',
password: '2Coral!'
}, {
email: 'marvel@gmail.com',
displayName: 'Marvel',
password: '3Coral!'
}]).then((users) => {
mockUsers = users;
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
return Setting.init(settings).then(() => {
return User.createLocalUsers([{
email: 'stampi@gmail.com',
displayName: 'Stampi',
password: '1Coral!-'
}, {
email: 'sockmonster@gmail.com',
displayName: 'Sockmonster',
password: '2Coral!2'
}, {
email: 'marvel@gmail.com',
displayName: 'Marvel',
password: '3Coral!3'
}]).then((users) => {
mockUsers = users;
});
});
});
@@ -29,7 +34,7 @@ describe('models.User', () => {
.findById(mockUsers[0].id)
.then((user) => {
expect(user).to.have.property('displayName')
.and.to.equal('Stampi');
.and.to.equal('stampi');
});
});
});
@@ -54,7 +59,7 @@ describe('models.User', () => {
return 0;
});
expect(sorted[0]).to.have.property('displayName')
.and.to.equal('Marvel');
.and.to.equal('marvel');
});
});
});
@@ -63,16 +68,16 @@ describe('models.User', () => {
it('should find a user when we give the right credentials', () => {
return User
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!')
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-')
.then((user) => {
expect(user).to.have.property('displayName')
.and.to.equal(mockUsers[0].displayName);
.and.to.equal(mockUsers[0].displayName.toLowerCase());
});
});
it('should not find the user when we give the wrong credentials', () => {
return User
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!<nope>')
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-<nope>')
.then((user) => {
expect(user).to.equal(false);
});
@@ -80,6 +85,74 @@ describe('models.User', () => {
});
describe('#createEmailConfirmToken', () => {
it('should create a token for a valid user', () => {
return User
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
});
});
it('should not create a token for a user already verified', () => {
return User
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
return User.verifyEmailConfirmation(token);
})
.then(() => {
return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
})
.catch((err) => {
expect(err).to.have.property('message', 'email address already confirmed');
});
});
});
describe('#verifyEmailConfirmation', () => {
it('should correctly validate a valid token', () => {
return User
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
return User.verifyEmailConfirmation(token);
});
});
it('should correctly reject an invalid token', () => {
return User
.verifyEmailConfirmation('cats')
.catch((err) => {
expect(err).to.not.be.null;
});
});
it('should update the user model when verification is complete', () => {
return User
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
.then((token) => {
expect(token).to.not.be.null;
return User.verifyEmailConfirmation(token);
})
.then(() => {
return User.findById(mockUsers[0].id);
})
.then((user) => {
expect(user.profiles[0]).to.have.property('metadata');
expect(user.profiles[0].metadata).to.have.property('confirmed_at');
expect(user.profiles[0].metadata.confirmed_at).to.not.be.null;
});
});
});
describe('#setStatus', () => {
it('should set the status to active', () => {
return User
+10 -22
View File
@@ -1,27 +1,15 @@
const mongoose = require('../services/mongoose');
const mongoose = require('./helpers/mongoose');
beforeEach(function (done) {
function clearDB() {
for (let collection in mongoose.connection.collections) {
mongoose.connection.collections[collection].remove(function() {});
}
return done();
}
before(function(done) {
this.timeout(30000);
if (mongoose.connection.readyState === 0) {
mongoose.on('open', function() {
if (err) {
throw err;
}
return clearDB();
});
} else {
return clearDB();
}
mongoose.waitTillConnect(done);
});
after(function (done) {
mongoose.disconnect();
return done();
beforeEach(function(done) {
mongoose.clearDB(done);
});
after(function(done) {
mongoose.disconnect(done);
});
+67 -23
View File
@@ -13,40 +13,84 @@ describe('/api/v1/auth', () => {
.get('/api/v1/auth')
.then((res) => {
expect(res.status).to.be.equal(204);
expect(res.body).to.be.empty;
expect(res).to.not.have.a.body;
});
});
});
});
const Setting = require('../../../../models/setting');
describe('/api/v1/auth/local', () => {
let mockUser;
beforeEach(() => {
return User.createLocalUser('maria@gmail.com', 'password!', 'Maria');
});
describe('#post', () => {
it('should send back the user on a successful login', () => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'})
.catch((res) => {
expect(res).to.have.status(200);
expect(res).to.be.json;
expect(res.body).to.have.property('user');
expect(res.body.user).to.have.property('displayName', 'Maria');
const settings = {requireEmailConfirmation: false, wordlist: {banned: ['bad'], suspect: ['naughty']}};
return Setting.init(settings).then(() => {
return User.createLocalUser('maria@gmail.com', 'password!', 'Maria')
.then((user) => {
mockUser = user;
});
});
});
it('should not send back the user on a unsuccessful login', () => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!3'})
.catch((err) => {
expect(err).to.not.be.null;
expect(err.response).to.have.status(401);
expect(err.response.body).to.have.property('message', 'not authorized');
});
describe('email confirmation disabled', () => {
describe('#post', () => {
it('should send back the user on a successful login', () => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'})
.then((res2) => {
expect(res2).to.have.status(200);
expect(res2).to.be.json;
expect(res2.body).to.have.property('user');
expect(res2.body.user).to.have.property('displayName', 'maria');
});
});
it('should not send back the user on a unsuccessful login', () => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!3'})
.catch((err) => {
expect(err).to.not.be.null;
expect(err.response).to.have.status(401);
expect(err.response.body).to.have.property('message', 'not authorized');
});
});
});
});
describe('email confirmation enabled', () => {
beforeEach(() => Setting.init({requireEmailConfirmation: true}));
describe('#post', () => {
it('should not allow a login from a user that is not confirmed', () => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'})
.catch((err) => {
err.response.should.have.status(401);
return User.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
})
.then(User.verifyEmailConfirmation)
.then(() => {
return chai.request(app)
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'});
})
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.be.json;
expect(res.body).to.have.property('user');
expect(res.body.user).to.have.property('displayName', 'maria');
});
});
});
});
});
+34 -36
View File
@@ -48,11 +48,11 @@ describe('/api/v1/comments', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
const actions = [{
@@ -88,6 +88,7 @@ describe('/api/v1/comments', () => {
.then(res => {
expect(res).to.have.status(200);
expect(res.body.comments).to.have.length(2);
expect(res.body.comments[0]).to.have.property('author_id', '456');
expect(res.body.comments[1]).to.have.property('author_id', '456');
});
});
@@ -193,8 +194,7 @@ describe('/api/v1/comments', () => {
});
it('should create a comment with a rejected status if it contains a bad word', () => {
return chai.request(app)
.post('/api/v1/comments')
return chai.request(app).post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''})
.then((res) => {
@@ -205,15 +205,13 @@ 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')
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')
@@ -225,8 +223,9 @@ describe('/api/v1/comments', () => {
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.');
expect(action).to.have.property('metadata');
expect(action.metadata).to.have.property('field', 'body');
expect(action.metadata).to.have.property('details', 'Matched suspect word filters.');
});
});
@@ -239,8 +238,7 @@ describe('/api/v1/comments', () => {
.then(() => asset);
})
.then((asset) => {
return chai.request(app)
.post('/api/v1/comments')
return chai.request(app).post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
})
@@ -261,8 +259,7 @@ describe('/api/v1/comments', () => {
.then(() => asset);
})
.then((asset) => {
return chai.request(app)
.post('/api/v1/comments')
return chai.request(app).post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'This is way way way way way too long.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
})
@@ -280,8 +277,7 @@ describe('/api/v1/comments', () => {
closedMessage: 'tests said expired!'
})
.then((asset) => {
return chai.request(app)
.post('/api/v1/comments')
return chai.request(app).post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
})
@@ -301,8 +297,7 @@ describe('/api/v1/comments', () => {
closedMessage: 'tests said expired!'
})
.then((asset) => {
return chai.request(app)
.post('/api/v1/comments')
return chai.request(app).post('/api/v1/comments')
.set(passport.inject({roles: []}))
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''});
})
@@ -333,11 +328,11 @@ describe('/api/v1/comments/:comment_id', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
const actions = [{
@@ -351,11 +346,13 @@ describe('/api/v1/comments/:comment_id', () => {
}];
beforeEach(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
return Setting.init(settings).then(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
});
});
describe('#get', () => {
@@ -368,7 +365,6 @@ describe('/api/v1/comments/:comment_id', () => {
expect(res).to.have.status(200);
expect(res).to.have.property('body');
expect(res.body).to.have.property('body', 'comment 10');
});
});
});
@@ -380,7 +376,6 @@ describe('/api/v1/comments/:comment_id', () => {
.set(passport.inject({roles: ['admin']}))
.then((res) => {
expect(res).to.have.status(204);
return Comment.findById('abc');
})
.then((comment) => {
@@ -444,11 +439,11 @@ describe('/api/v1/comments/:comment_id/actions', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
const actions = [{
@@ -460,15 +455,17 @@ describe('/api/v1/comments/:comment_id/actions', () => {
}];
beforeEach(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
return Setting.init(settings).then(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
});
});
describe('#post', () => {
it('it should update actions', () => {
it('it should create an action', () => {
return chai.request(app)
.post('/api/v1/comments/abc/actions')
.set(passport.inject({id: '456', roles: ['admin']}))
@@ -476,9 +473,10 @@ describe('/api/v1/comments/:comment_id/actions', () => {
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('action_type', 'flag');
expect(res.body).to.have.property('metadata')
.and.to.deep.equal({'reason': 'Comment is too awesome.'});
expect(res.body).to.have.property('metadata');
expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'});
expect(res.body).to.have.property('item_id', 'abc');
});
});
+23 -23
View File
@@ -13,7 +13,7 @@ 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: ['banned'], suspect: ['suspect']}};
describe('/api/v1/queue', () => {
const comments = [{
@@ -44,11 +44,11 @@ describe('/api/v1/queue', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
const actions = [{
@@ -62,36 +62,36 @@ describe('/api/v1/queue', () => {
}];
beforeEach(() => {
return User.createLocalUsers(users)
.then((u) => {
comments[0].author_id = u[0].id;
comments[1].author_id = u[1].id;
comments[2].author_id = u[1].id;
return Setting.init(settings).then(() => {
return User.createLocalUsers(users)
.then((u) => {
comments[0].author_id = u[0].id;
comments[1].author_id = u[1].id;
comments[2].author_id = u[1].id;
return Comment.create(comments);
})
.then((c) => {
actions[0].item_id = c[0].id;
actions[1].item_id = c[1].id;
return Comment.create(comments);
})
.then((c) => {
actions[0].item_id = c[0].id;
actions[1].item_id = c[1].id;
return Promise.all([
Action.create(actions),
Setting.init(settings)
]);
});
return Promise.all([
Action.create(actions),
Setting.init(settings)
]);
});
});
});
it('should return all the pending comments, users and actions', function(done){
chai.request(app)
it('should return all the pending comments, users and actions', () => {
return chai.request(app)
.get('/api/v1/queue/comments/pending')
.set(passport.inject({roles: ['admin']}))
.end(function(err, res){
expect(err).to.be.null;
.then((res) => {
expect(res).to.have.status(200);
expect(res.body.comments[0]).to.have.property('body');
expect(res.body.users[0]).to.have.property('displayName');
expect(res.body.actions[0]).to.have.property('action_type');
done();
});
});
});
+35 -30
View File
@@ -17,7 +17,11 @@ describe('/api/v1/stream', () => {
describe('#get', () => {
const settings = {
id: '1',
moderation: 'post'
moderation: 'post',
wordlist: {
banned: ['banned'],
suspect: ['suspect']
}
};
const comments = [{
@@ -55,11 +59,11 @@ describe('/api/v1/stream', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
const actions = [{
@@ -71,34 +75,35 @@ describe('/api/v1/stream', () => {
}];
beforeEach(() => {
return Promise.all([
User.createLocalUsers(users),
Asset.findOrCreateByUrl('http://test.com'),
Asset
.findOrCreateByUrl('http://coralproject.net/asset2')
.then((asset) => {
return Asset
.overrideSettings(asset.id, {moderation: 'pre'})
.then(() => asset);
})
])
.then(([users, asset1, asset2]) => {
comments[0].author_id = users[0].id;
comments[1].author_id = users[1].id;
comments[2].author_id = users[0].id;
comments[3].author_id = users[1].id;
comments[0].asset_id = asset1.id;
comments[1].asset_id = asset1.id;
comments[2].asset_id = asset2.id;
comments[3].asset_id = asset2.id;
return Setting.init(settings).then(() => {
return Promise.all([
Comment.create(comments),
Action.create(actions),
Setting.init(settings)
]);
User.createLocalUsers(users),
Asset.findOrCreateByUrl('http://test.com'),
Asset
.findOrCreateByUrl('http://coralproject.net/asset2')
.then((asset) => {
return Asset
.overrideSettings(asset.id, {moderation: 'pre'})
.then(() => asset);
})
])
.then(([users, asset1, asset2]) => {
comments[0].author_id = users[0].id;
comments[1].author_id = users[1].id;
comments[2].author_id = users[0].id;
comments[3].author_id = users[1].id;
comments[0].asset_id = asset1.id;
comments[1].asset_id = asset1.id;
comments[2].asset_id = asset2.id;
comments[3].asset_id = asset2.id;
return Promise.all([
Comment.create(comments),
Action.create(actions)
]);
});
});
});
+44 -6
View File
@@ -1,29 +1,68 @@
const passport = require('../../../passport');
const app = require('../../../../app');
const mailer = require('../../../../services/mailer');
const chai = require('chai');
const expect = chai.expect;
const Setting = require('../../../../models/setting');
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
// Setup chai.
chai.should();
chai.use(require('chai-http'));
const User = require('../../../../models/user');
describe('/api/v1/users/:user_id/email/confirm', () => {
let mockUser;
beforeEach(() => User.createLocalUser('ana@gmail.com', '123', 'Ana').then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('should send an email when we hit the endpoint', () => {
expect(mailer.task.tasks).to.have.length(0);
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/email/confirm`)
.set(passport.inject({roles: ['admin']}))
.then((res) => {
expect(res).to.have.status(204);
expect(mailer.task.tasks).to.have.length(1);
});
});
it('should send a 404 on not matching a user', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/email/confirm`)
.set(passport.inject({roles: ['admin']}))
.then((res) => {
expect(res).to.have.status(204);
expect(mailer.task.tasks).to.have.length(1);
});
});
});
});
describe('/api/v1/users/:user_id/actions', () => {
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
password: '123456789'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
password: '123456789'
}];
beforeEach(() => {
return User.createLocalUsers(users);
return Setting.init(settings).then(() => {
return User.createLocalUsers(users);
});
});
describe('#post', () => {
@@ -31,7 +70,7 @@ describe('/api/v1/users/:user_id/actions', () => {
return chai.request(app)
.post('/api/v1/users/abc/actions')
.set(passport.inject({id: '456', roles: ['admin']}))
.send({'action_type': 'flag', 'metadata': {'reason': 'Bio is too awesome.'}})
.send({'action_type': 'flag', metadata: {reason: 'Bio is too awesome.'}})
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
@@ -39,8 +78,7 @@ describe('/api/v1/users/:user_id/actions', () => {
expect(res.body).to.have.property('metadata')
.and.to.deep.equal({'reason': 'Bio is too awesome.'});
expect(res.body).to.have.property('item_id', 'abc');
})
.catch(err => console.error(err.message));
});
});
});
});
+6 -2
View File
@@ -1,6 +1,7 @@
const expect = require('chai').expect;
const Errors = require('../../errors');
const Wordlist = require('../../services/wordlist');
const Setting = require('../../models/setting');
describe('wordlist: services', () => {
@@ -16,6 +17,9 @@ describe('wordlist: services', () => {
};
let wordlist = new Wordlist();
const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
beforeEach(() => Setting.init(settings));
describe('#init', () => {
@@ -67,7 +71,7 @@ describe('wordlist: services', () => {
content: 'how to do really bad things?'
}, 'content');
expect(errors).to.have.property('banned', Wordlist.ErrContainsProfanity);
expect(errors).to.have.property('banned', Errors.ErrContainsProfanity);
});
it('does not match on bodies not containing bad words', () => {
-36
View File
@@ -1,36 +0,0 @@
const mongoose = require('../../services/mongoose');
// Ensure the NODE_ENV is set to 'test',
// this is helpful when you would like to change behavior when testing.
function clearDB() {
// console.log('Clearing DB', mongoose.connection);
for (let i in mongoose.connection.collections) {
// console.log('Clearing', i);
mongoose.connection.collections[i].remove(function() {});
}
}
module.exports = {
before: () => {
clearDB();
},
beforeEach: () => {
if (mongoose.connection.readyState === 0) {
mongoose.on('open', function() {
if (err) {
throw err;
}
return clearDB();
});
} else {
return clearDB();
}
},
after: () => {
clearDB();
mongoose.disconnect();
}
};
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<meta property="csrf" content="<%= csrfToken %>">
<title>Talk - Coral Admin</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">
+3
View File
@@ -0,0 +1,3 @@
<p>A email confirmation has been requested for the following account: <b><%= email %></b>.</p>
<p>To confirm the account, please visit the following link: <a href="http://example.com/email/confirm/endpoint#<%= token %>">http://example.com/email/confirm/endpoint#<%= token %></a></p>
<p>If you did not request this, you can safely ignore this email.</p>
+9
View File
@@ -0,0 +1,9 @@
A email confirmation has been requested for the following account:
<%= email %>
To confirm the account, please visit the following link:
http://example.com/email/confirm/endpoint#<%= token %>
If you did not request this, you can safely ignore this email.
@@ -1,6 +1,2 @@
<!-- 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 (process.env.NODE_ENV !== 'production') { %>
<p style="color: red"><%= token %></p>
<% } %>
+5
View File
@@ -0,0 +1,5 @@
We received a request to reset your password, click here to reset your password:
<%= rootURL %>/admin/password-reset#<%= token %>
If you did not request this change, you can ignore this email.
+3 -3
View File
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta property="csrf" content="<%= csrfToken %>">
<link rel="stylesheet" type="text/css" href="/client/embed/stream/default.css">
<link href="https://fonts.googleapis.com/css?family=Lato|Open+Sans" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<div id="coralStream"></div>
<script src="/client/embed/stream/bundle.js"></script>
</body>
</html>
</html>
+4 -2
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<meta property="csrf" content="<%= csrfToken %>">
<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">
@@ -82,6 +83,7 @@
<body>
<div id="root">
<form id="reset-password-form">
<input type="hidden" name="_csrf" value={{csrfToken}}/>
<legend class="legend">Set new password</legend>
<label for="password">
New password
@@ -117,9 +119,9 @@
}
$.ajax({
url: '/api/v1/users/update-password',
url: '/api/v1/account/password/reset',
contentType: 'application/json',
method: 'POST',
method: 'PUT',
data: JSON.stringify({password: password, token: location.hash.replace('#', '')})
}).then(function (success) {
location.href = '<%= redirectUri %>';