diff --git a/.dockerignore b/.dockerignore index 3c3629e64..f05b1f265 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ node_modules +test diff --git a/.eslintrc.json b/.eslintrc.json index 8b737cbd2..34b46b83b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,7 +17,7 @@ "no-template-curly-in-string": [1], "no-unsafe-negation": [1], "array-callback-return": [1], - "eqeqeq": [2], + "eqeqeq": [2, "smart"], "no-eval": [2], "no-global-assign": [2], "no-implied-eval": [2], diff --git a/.gitignore b/.gitignore index 2ec55accd..777dcc7f9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,13 @@ npm-debug.log* dist !dist/coral-admin dist/coral-admin/bundle.js -tests/e2e/reports +test/e2e/reports .DS_Store *.iml *.swp dump.rdb .env -gaba.cfg +*.cfg .idea/ coverage/ yarn.lock diff --git a/.nodemon.json b/.nodemon.json index 834e6c054..4c48c707e 100644 --- a/.nodemon.json +++ b/.nodemon.json @@ -1,4 +1,5 @@ { "verbose": true, - "ignore": ["tests/*", "client/*", "dist/*"] + "ignore": ["test/*", "client/*", "dist/*"], + "ext": "js,json,graphql" } diff --git a/.travis-yml b/.travis-yml deleted file mode 100644 index 85242354a..000000000 --- a/.travis-yml +++ /dev/null @@ -1,3 +0,0 @@ -language: node_js -node_js: - - 4 diff --git a/app.js b/app.js index 9cdad4ea3..c04e0cb4a 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,8 @@ const RedisStore = require('connect-redis')(session); const redis = require('./services/redis'); const csrf = require('csurf'); const errors = require('./errors'); +const graph = require('./graph'); +const apollo = require('graphql-server-express'); const app = express(); @@ -49,7 +51,7 @@ const session_opts = { name: 'talk.sid', cookie: { secure: false, - maxAge: 18000000, // 30 minutes for expiry. + maxAge: 36000000, // 1 hour for expiry. }, store: new RedisStore({ ttl: 1800, @@ -77,6 +79,23 @@ app.use(session(session_opts)); app.use(passport.initialize()); app.use(passport.session()); +//============================================================================== +// GraphQL Router +//============================================================================== + +// GraphQL endpoint. +app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions)); + +// Only include the graphiql tool if we aren't in production mode. +if (app.get('env') !== 'production') { + + // Interactive graphiql interface. + app.use('/api/v1/graph/iql', apollo.graphiqlExpress({ + endpointURL: '/api/v1/graph/ql' + })); + +} + //============================================================================== // CSRF MIDDLEWARE //============================================================================== diff --git a/bin/cli-assets b/bin/cli-assets index 008460f9e..f7d6a0bbb 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -8,7 +8,7 @@ const program = require('commander'); const pkg = require('../package.json'); const parseDuration = require('parse-duration'); const Table = require('cli-table'); -const Asset = require('../models/asset'); +const AssetModel = require('../models/asset'); const mongoose = require('../services/mongoose'); const scraper = require('../services/scraper'); const util = require('../util'); @@ -22,7 +22,7 @@ util.onshutdown([ * Lists all the assets registered in the database. */ function listAssets() { - Asset + AssetModel .find({}) .sort({'created_at': 1}) .then((asset) => { @@ -56,7 +56,7 @@ function refreshAssets(ageString) { const ageMs = parseDuration(ageString); const age = new Date(now - ageMs); - Asset.find({ + AssetModel.find({ $or: [ { scraped: { diff --git a/bin/cli-settings b/bin/cli-settings index 5fdfbc38f..7854942c0 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -6,7 +6,7 @@ const program = require('commander'); const mongoose = require('../services/mongoose'); -const Setting = require('../models/setting'); +const SettingsService = require('../services/settings'); const util = require('../util'); // Register the shutdown criteria. @@ -22,10 +22,16 @@ program .command('init') .description('initilizes the talk settings') .action(() => { - const defaults = {id: '1', moderation: 'pre'}; + const defaults = { + moderation: 'PRE', + wordlist: { + banned: [], + suspect: [] + } + }; - Setting - .update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}) + SettingsService + .init(defaults) .then(() => { console.log('Created settings object.'); util.shutdown(); diff --git a/bin/cli-users b/bin/cli-users index fd9e30c7f..a7e9cdbda 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -7,7 +7,8 @@ const program = require('commander'); const pkg = require('../package.json'); const prompt = require('prompt'); -const User = require('../models/user'); +const UsersService = require('../services/users'); +const UserModel = require('../models/user'); const mongoose = require('../services/mongoose'); const util = require('../util'); const Table = require('cli-table'); @@ -76,16 +77,22 @@ function createUser(options) { }); }) .then((result) => { - return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) + return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) .then((user) => { console.log(`Created user ${user.id}.`); - if (result.role && result.role.length > 0) { - return User - .addRoleToUser(user.id, result.role.trim()) + let role = result.role ? result.role.trim() : null; + + if (role && role.length > 0) { + return UsersService + .addRoleToUser(user.id, role) .then(() => { console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(); }); } else { util.shutdown(); @@ -102,7 +109,7 @@ function createUser(options) { * Deletes a user. */ function deleteUser(userID) { - User + UserModel .findOneAndRemove({ id: userID }) @@ -148,7 +155,7 @@ function passwd(userID) { return; } - User + UsersService .changePassword(userID, result.password) .then(() => { console.log('Password changed.'); @@ -168,7 +175,7 @@ function updateUser(userID, options) { const updates = []; if (options.email && typeof options.email === 'string' && options.email.length > 0) { - let q = User.update({ + let q = UserModel.update({ 'id': userID, 'profiles.provider': 'local' }, { @@ -181,7 +188,7 @@ function updateUser(userID, options) { } if (options.name && typeof options.name === 'string' && options.name.length > 0) { - let q = User.update({ + let q = UserModel.update({ 'id': userID }, { $set: { @@ -208,7 +215,7 @@ function updateUser(userID, options) { * Lists all the users registered in the database. */ function listUsers() { - User + UsersService .all() .then((users) => { let table = new Table({ @@ -248,7 +255,7 @@ function listUsers() { * @param {String} srcUserID id of the user to which is the source of the merge */ function mergeUsers(dstUserID, srcUserID) { - User + UsersService .mergeUsers(dstUserID, srcUserID) .then(() => { console.log(`User ${srcUserID} was merged into user ${dstUserID}.`); @@ -266,7 +273,7 @@ function mergeUsers(dstUserID, srcUserID) { * @param {String} role the role to add */ function addRole(userID, role) { - User + UsersService .addRoleToUser(userID, role) .then(() => { console.log(`Added the ${role} role to User ${userID}.`); @@ -284,7 +291,7 @@ function addRole(userID, role) { * @param {String} role the role to remove */ function removeRole(userID, role) { - User + UsersService .removeRoleFromUser(userID, role) .then(() => { console.log(`Removed the ${role} role from User ${userID}.`); @@ -301,8 +308,8 @@ function removeRole(userID, role) { * @param {String} userID id of the user to ban */ function ban(userID) { - User - .setStatus(userID, 'banned', '') + UsersService + .setStatus(userID, 'BANNED') .then(() => { console.log(`Banned the User ${userID}.`); util.shutdown(); @@ -318,8 +325,8 @@ function ban(userID) { * @param {String} userUD id of the user to remove the role from */ function unban(userID) { - User - .setStatus(userID, 'active', '') + UsersService + .setStatus(userID, 'ACTIVE') .then(() => { console.log(`Unban the User ${userID}.`); util.shutdown(); @@ -335,7 +342,7 @@ function unban(userID) { * @param {String} userID the ID of a user to disable */ function disableUser(userID) { - User + UsersService .disableUser(userID) .then(() => { console.log(`User ${userID} was disabled.`); @@ -352,7 +359,7 @@ function disableUser(userID) { * @param {String} userID the ID of a user to enable */ function enableUser(userID) { - User + UsersService .enableUser(userID) .then(() => { console.log(`User ${userID} was enabled.`); diff --git a/circle.yml b/circle.yml index b9cea6fed..160bc7bb9 100644 --- a/circle.yml +++ b/circle.yml @@ -3,12 +3,24 @@ machine: version: 7 services: - docker + - redis + +dependencies: + post: + # Lint the project here, before tests are ran. + - npm run lint + +database: + post: + # Initialize the settings in the database, this will create indicies for the + # database. + - ./bin/cli settings init + - sleep 2 test: override: - - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter - - npm run lint - - npm run build + # Run the tests using the junit reporter. + - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml NPM_PACKAGE_CONFIG_MOCHA_REPORTER=mocha-junit-reporter npm run test deployment: release: diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index fb469588b..c3f9b1be1 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -11,7 +11,7 @@ export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); coralApi('/auth') .then(result => { - const isAdmin = !!result.user.roles.filter(i => i === 'admin').length; + const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); diff --git a/client/coral-admin/src/components/CommentList.js b/client/coral-admin/src/components/CommentList.js index b5eb40231..178d3cc02 100644 --- a/client/coral-admin/src/components/CommentList.js +++ b/client/coral-admin/src/components/CommentList.js @@ -6,10 +6,10 @@ import Comment from 'components/Comment'; // Each action has different meaning and configuration const modActions = { - 'reject': {status: 'rejected', icon: 'close', key: 'r'}, - 'approve': {status: 'accepted', icon: 'done', key: 't'}, - 'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'}, - 'ban': {status: 'banned', icon: 'not interested'} + 'reject': {status: 'REJECTED', icon: 'close', key: 'r'}, + 'approve': {status: 'ACCEPTED', icon: 'done', key: 't'}, + 'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, + 'ban': {status: 'BANNED', icon: 'not interested'} }; // Renders a comment list and allow performing actions @@ -21,7 +21,7 @@ export default class CommentList extends React.Component { comments: PropTypes.object.isRequired, users: PropTypes.object.isRequired, onClickAction: PropTypes.func, - + // list of actions (flags, etc) associated with the comments modActions: PropTypes.arrayOf(PropTypes.string).isRequired, loading: PropTypes.bool, diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index c146e564a..254550e74 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -55,8 +55,8 @@ class Table extends Component { className={styles.selectField} label={lang.t('community.status')} onChange={status => this.onCommenterStatusChange(row.id, status)}> - - + + @@ -65,8 +65,8 @@ class Table extends Component { label={lang.t('community.role')} onChange={role => this.onRoleChange(row.id, role)}> - - + + diff --git a/client/coral-admin/src/containers/Configure/CommentSettings.js b/client/coral-admin/src/containers/Configure/CommentSettings.js index 9fb3d75da..0ccb5f820 100644 --- a/client/coral-admin/src/containers/Configure/CommentSettings.js +++ b/client/coral-admin/src/containers/Configure/CommentSettings.js @@ -28,7 +28,7 @@ const updateCharCount = (updateSettings, settingsError) => (event) => { }; const updateModeration = (updateSettings, mod) => () => { - const moderation = mod === 'pre' ? 'post' : 'pre'; + const moderation = mod === 'PRE' ? 'POST' : 'PRE'; updateSettings({moderation}); }; @@ -70,15 +70,15 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError return (

{title}

- +
+ checked={settings.moderation === 'PRE'} />
{lang.t('configure.enable-pre-moderation')}
-

+

{lang.t('configure.enable-pre-moderation-text')}

diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 41988e216..1b23de89a 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -75,12 +75,12 @@ class ModerationContainer extends React.Component { 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 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 && - comments.byId[id].status !== 'rejected' && - comments.byId[id].status !== 'accepted' + comments.byId[id].status !== 'REJECTED' && + comments.byId[id].status !== 'ACCEPTED' ); return ( @@ -112,7 +112,7 @@ const mapDispatchToProps = dispatch => { fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()), showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), - banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => { + banUser: (userId, commentId) => dispatch(userStatusUpdate('BANNED', userId, commentId)).then(() => { dispatch(fetchModerationQueueComments()); }), updateStatus: (action, comment) => dispatch(updateStatus(action, comment)) diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js index 71da184ab..90835ab3f 100644 --- a/client/coral-configure/containers/ConfigureStreamContainer.js +++ b/client/coral-configure/containers/ConfigureStreamContainer.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {I18n} from '../../coral-framework'; -import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/config'; +import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/asset'; import CloseCommentsInfo from '../components/CloseCommentsInfo'; import ConfigureCommentStream from '../components/ConfigureCommentStream'; @@ -13,8 +13,10 @@ class ConfigureStreamContainer extends Component { constructor (props) { super(props); + console.log('moderation', props.asset.settings.moderation); + this.state = { - premod: props.config.moderation === 'pre', + premod: props.asset.settings.moderation === 'PRE', premodLinks: false }; @@ -26,7 +28,7 @@ class ConfigureStreamContainer extends Component { handleApply () { const {premod, changed} = this.state; const newConfig = { - moderation: premod ? 'pre' : 'post' + moderation: premod ? 'PRE' : 'POST' }; if (changed) { this.props.updateConfiguration(newConfig); @@ -47,18 +49,17 @@ class ConfigureStreamContainer extends Component { } toggleStatus () { - this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open'); + this.props.updateStatus(this.props.asset.closedAt === null ? 'closed' : 'open'); } getClosedIn () { - const {closedTimeout} = this.props.config; + const {closedTimeout} = this.props.asset.settings; const {created_at} = this.props.asset; return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout)); } render () { - const {status} = this.props; - + const status = this.props.asset.closedAt === null ? 'open' : 'closed'; return (
({ - config: state.config.toJS(), - asset: state.items - .get('assets') - .first() - .toJS() + asset: state.asset.toJS() }); const mapDispatchToProps = dispatch => ({ diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/client/coral-embed-stream/src/Comment.css @@ -0,0 +1 @@ + diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js new file mode 100644 index 000000000..f734bce0b --- /dev/null +++ b/client/coral-embed-stream/src/Comment.js @@ -0,0 +1,171 @@ +// this component will +// render its children +// render a like button +// render a permalink button +// render a reply button +// render a flag button +// translate things? + +import React, {PropTypes} from 'react'; +import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton'; + +import AuthorName from 'coral-plugin-author-name/AuthorName'; +import Content from 'coral-plugin-commentcontent/CommentContent'; +import PubDate from 'coral-plugin-pubdate/PubDate'; +import {ReplyBox, ReplyButton} from 'coral-plugin-replies'; +import FlagComment from 'coral-plugin-flags/FlagComment'; +import LikeButton from 'coral-plugin-likes/LikeButton'; + +const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0]; + +class Comment extends React.Component { + + constructor(props) { + super(props); + this.state = {replyBoxVisible: false}; + } + + static propTypes = { + refetch: PropTypes.func.isRequired, + showSignInDialog: PropTypes.func.isRequired, + postAction: PropTypes.func.isRequired, + deleteAction: PropTypes.func.isRequired, + parentId: PropTypes.string, + addNotification: PropTypes.func.isRequired, + postItem: PropTypes.func.isRequired, + depth: PropTypes.number.isRequired, + asset: PropTypes.shape({ + id: PropTypes.string, + title: PropTypes.string, + url: PropTypes.string + }).isRequired, + currentUser: PropTypes.shape({ + id: PropTypes.string.isRequired + }), + comment: PropTypes.shape({ + depth: PropTypes.number, + actions: PropTypes.array.isRequired, + body: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + replies: PropTypes.arrayOf( + PropTypes.shape({ + body: PropTypes.string.isRequired, + id: PropTypes.string.isRequired + }) + ), + user: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired + }).isRequired + } + + onReplyBoxClick = () => { + if (this.props.currentUser) { + this.setState({replyBoxVisible: !this.state.replyBoxVisible}); + } else { + const offset = document.getElementById(`c_${this.props.comment.id}`).getBoundingClientRect().top - 75; + this.props.showSignInDialog(offset); + } + } + + render () { + const { + comment, + parentId, + currentUser, + asset, + depth, + postItem, + refetch, + addNotification, + showSignInDialog, + postAction, + deleteAction + } = this.props; + + const like = getAction('LIKE', comment); + const flag = getAction('FLAG', comment); + + return ( +
+
+ + + +
+ + +
+
+ + +
+ { + this.state.replyBoxVisible + ? { + console.log('replyPostedHandler'); + this.setState({replyBoxVisible: false}); + refetch(); + }} + parentId={parentId || comment.id} + addNotification={addNotification} + authorId={currentUser.id} + postItem={postItem} + assetId={asset.id} /> + : null + } + { + comment.replies && + comment.replies.map(reply => { + return ; + }) + } + +
+ ); + } +} + +export default Comment; diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js deleted file mode 100644 index bd60f8c32..000000000 --- a/client/coral-embed-stream/src/CommentStream.js +++ /dev/null @@ -1,331 +0,0 @@ -import React, {Component, PropTypes} from 'react'; -import Pym from 'pym.js'; -import {connect} from 'react-redux'; - -import { - itemActions, - Notification, - notificationActions, - authActions -} from '../../coral-framework'; - -import CommentBox from '../../coral-plugin-commentbox/CommentBox'; -import InfoBox from '../../coral-plugin-infobox/InfoBox'; -import Content from '../../coral-plugin-commentcontent/CommentContent'; -import PubDate from '../../coral-plugin-pubdate/PubDate'; -import Count from '../../coral-plugin-comment-count/CommentCount'; -import AuthorName from '../../coral-plugin-author-name/AuthorName'; -import {ReplyBox, ReplyButton} from '../../coral-plugin-replies'; -import FlagComment from '../../coral-plugin-flags/FlagComment'; -import LikeButton from '../../coral-plugin-likes/LikeButton'; -import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton'; -import SignInContainer from '../../coral-sign-in/containers/SignInContainer'; -import UserBox from '../../coral-sign-in/components/UserBox'; - -import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui'; -import SettingsContainer from '../../coral-settings/containers/SettingsContainer'; -import RestrictedContent from '../../coral-framework/components/RestrictedContent'; -import SuspendedAccount from '../../coral-framework/components/SuspendedAccount'; - -import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer'; - -const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions; -const {addNotification, clearNotification} = notificationActions; -const {logout, showSignInDialog} = authActions; - -class CommentStream extends Component { - - constructor (props) { - super(props); - - this.state = { - activeTab: 0 - }; - - this.changeTab = this.changeTab.bind(this); - } - - changeTab (tab) { - this.setState({ - activeTab: tab - }); - } - - static propTypes = { - items: PropTypes.object.isRequired, - addItem: PropTypes.func.isRequired, - updateItem: PropTypes.func.isRequired - } - - componentDidMount () { - - // Set up messaging between embedded Iframe an parent component - this.pym = new Pym.Child({polling: 100}); - - let path = this.pym.parentUrl.split('#')[0]; - - if (!path) { - path = window.location.href.split('#')[0]; - } - - this.props.getStream(path || window.location); - this.path = path; - - this.pym.sendMessage('childReady'); - - this.pym.onMessage('DOMContentLoaded', hash => { - const commentId = hash.replace('#', 'c_'); - let count = 0; - const interval = setInterval(() => { - if (document.getElementById(commentId)) { - window.clearInterval(interval); - this.pym.scrollParentToChildEl(commentId); - } - - if (++count > 100) { // ~10 seconds - // give up waiting for the comments to load. - // it would be weird for the page to jump after that long. - window.clearInterval(interval); - } - }, 100); - }); - } - - render () { - const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0]; - const rootItem = this.props.items.assets && this.props.items.assets[rootItemId]; - const {actions, users, comments} = this.props.items; - const {status, moderation, closedMessage, charCount, charCountEnable} = this.props.config; - const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth; - const {activeTab} = this.state; - const banned = (this.props.userData.status === 'banned'); - - const expandForLogin = showSignInDialog ? { - minHeight: document.body.scrollHeight + 150 - } : {}; - return
- { - rootItem - ?
- - - Settings - Configure Stream - - {loggedIn && } - - { - status === 'open' - ?
- - }> - - -
- :

{closedMessage}

- } - {!loggedIn && } - { - rootItem.comments && rootItem.comments.map((commentId) => { - const comment = comments[commentId]; - return
-
- - - -
- - -
-
- - -
- - { - comment.children && - comment.children.map((replyId) => { - let reply = this.props.items.comments[replyId]; - return
-
- - - -
- - -
-
- - -
- -
; - }) - } -
; - }) - } - -
- - - - - - - - - -
- : - - } -
; - } -} - -const mapStateToProps = state => ({ - config: state.config.toJS(), - items: state.items.toJS(), - notification: state.notification.toJS(), - auth: state.auth.toJS(), - userData: state.user.toJS() -}); - -const mapDispatchToProps = (dispatch) => ({ - addItem: (item, item_id) => dispatch(addItem(item, item_id)), - updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)), - postItem: (data, type, id) => dispatch(postItem(data, type, id)), - getStream: (rootId) => dispatch(getStream(rootId)), - addNotification: (type, text) => dispatch(addNotification(type, text)), - clearNotification: () => dispatch(clearNotification()), - postAction: (item, itemType, action) => dispatch(postAction(item, itemType, action)), - showSignInDialog: (offset) => dispatch(showSignInDialog(offset)), - deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)), - appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)), - handleSignInDialog: () => dispatch(authActions.showSignInDialog()), - logout: () => dispatch(logout()) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(CommentStream); diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js new file mode 100644 index 000000000..5922266fa --- /dev/null +++ b/client/coral-embed-stream/src/Embed.js @@ -0,0 +1,205 @@ +import React, {Component} from 'react'; +import {compose} from 'react-apollo'; +import {connect} from 'react-redux'; +import {isEqual} from 'lodash'; + +import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui'; + +const {logout, showSignInDialog} = authActions; +const {addNotification, clearNotification} = notificationActions; +const {fetchAssetSuccess} = assetActions; + +import {queryStream} from './graphql/queries'; +import {postComment, postAction, deleteAction} from './graphql/mutations'; +import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework'; + +import Stream from './Stream'; +import InfoBox from 'coral-plugin-infobox/InfoBox'; +import Count from 'coral-plugin-comment-count/CommentCount'; +import CommentBox from 'coral-plugin-commentbox/CommentBox'; +import UserBox from '../../coral-sign-in/components/UserBox'; +import SignInContainer from '../../coral-sign-in/containers/SignInContainer'; +import SuspendedAccount from '../../coral-framework/components/SuspendedAccount'; +import SettingsContainer from '../../coral-settings/containers/SettingsContainer'; +import RestrictedContent from '../../coral-framework/components/RestrictedContent'; +import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer'; + +class Embed extends Component { + + constructor (props) { + super(props); + + this.state = { + activeTab: 0, + showSignInDialog: false + }; + + this.changeTab = this.changeTab.bind(this); + } + + changeTab (tab) { + this.setState({ + activeTab: tab + }); + } + + static propTypes = { + data: React.PropTypes.shape({ + loading: React.PropTypes.bool, + error: React.PropTypes.object + }).isRequired + } + + componentDidMount () { + + // stream id, logged in user, settings + + // Set up messaging between embedded Iframe an parent component + + // this.props.getStream(path || window.location); + // this.path = window.location.href.split('#')[0]; + // + + pym.sendMessage('childReady'); + + pym.onMessage('DOMContentLoaded', hash => { + const commentId = hash.replace('#', 'c_'); + let count = 0; + const interval = setInterval(() => { + if (document.getElementById(commentId)) { + window.clearInterval(interval); + pym.scrollParentToChildEl(commentId); + } + + if (++count > 100) { // ~10 seconds + // give up waiting for the comments to load. + // it would be weird for the page to jump after that long. + window.clearInterval(interval); + } + }, 100); + }); + + } + + componentWillReceiveProps (nextProps) { + const {loadAsset} = this.props; + if(!isEqual(nextProps.data.asset, this.props.data.asset)) { + loadAsset(nextProps.data.asset); + } + } + + render () { + const {activeTab} = this.state; + const {loading, asset, refetch} = this.props.data; + const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth; + + const expandForLogin = showSignInDialog ? { + minHeight: document.body.scrollHeight + 200 + } : {}; + + return
+ { + loading ? + :
+ + + Settings + Configure Stream + + {loggedIn && } + + { + asset.closedAt === null + ?
+ + }> + { + user + ? + : null + } + +
+ :

{asset.settings.closedMessage}

+ } + {!loggedIn && } + + +
+ + + + + + + + + +
+ } +
; + } +} + +const mapStateToProps = state => ({ + items: state.items.toJS(), + notification: state.notification.toJS(), + auth: state.auth.toJS(), + userData: state.user.toJS() +}); + +const mapDispatchToProps = dispatch => ({ + loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)), + addNotification: (type, text) => dispatch(addNotification(type, text)), + clearNotification: () => dispatch(clearNotification()), + showSignInDialog: (offset) => dispatch(showSignInDialog(offset)), + logout: () => dispatch(logout()), + dispatch: d => dispatch(d) +}); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + postComment, + postAction, + deleteAction, + queryStream +)(Embed); diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js new file mode 100644 index 000000000..73842a947 --- /dev/null +++ b/client/coral-embed-stream/src/Stream.js @@ -0,0 +1,49 @@ +import React, {PropTypes} from 'react'; +import Comment from './Comment'; + +const Stream = ({ + comments, + currentUser, + asset, + postItem, + addNotification, + postAction, + deleteAction, + showSignInDialog, + refetch +}) => { + return ( +
+ { + comments.map(comment => { + return ; + }) + } +
+ ); +}; + +Stream.propTypes = { + refetch: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, + postItem: PropTypes.func.isRequired, + asset: PropTypes.object.isRequired, + comments: PropTypes.array.isRequired, + currentUser: PropTypes.shape({ + displayName: PropTypes.string, + id: PropTypes.string + }) +}; + +export default Stream; diff --git a/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql b/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql new file mode 100644 index 000000000..bfce8cf6a --- /dev/null +++ b/client/coral-embed-stream/src/graphql/mutations/deleteAction.graphql @@ -0,0 +1,3 @@ +mutation deleteAction ($id: ID!) { + deleteAction(id:$id) +} diff --git a/client/coral-embed-stream/src/graphql/mutations/index.js b/client/coral-embed-stream/src/graphql/mutations/index.js new file mode 100644 index 000000000..383862455 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/mutations/index.js @@ -0,0 +1,39 @@ +import {graphql} from 'react-apollo'; +import POST_COMMENT from './postComment.graphql'; +import POST_ACTION from './postAction.graphql'; +import DELETE_ACTION from './deleteAction.graphql'; + +export const postComment = graphql(POST_COMMENT, { + props: ({mutate}) => ({ + postItem: ({asset_id, body, parent_id} /* , type */ ) => { + return mutate({ + variables: { + asset_id, + body, + parent_id + } + }); + }}), +}); + +export const postAction = graphql(POST_ACTION, { + props: ({mutate}) => ({ + postAction: (action) => { + return mutate({ + variables: { + action + } + }); + }}), +}); + +export const deleteAction = graphql(DELETE_ACTION, { + props: ({mutate}) => ({ + deleteAction: (id) => { + return mutate({ + variables: { + id + } + }); + }}), +}); diff --git a/client/coral-embed-stream/src/graphql/mutations/postAction.graphql b/client/coral-embed-stream/src/graphql/mutations/postAction.graphql new file mode 100644 index 000000000..fff737fa8 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/mutations/postAction.graphql @@ -0,0 +1,5 @@ +mutation CreateAction ($action: CreateActionInput!) { + createAction(action:$action) { + id + } +} diff --git a/client/coral-embed-stream/src/graphql/mutations/postComment.graphql b/client/coral-embed-stream/src/graphql/mutations/postComment.graphql new file mode 100644 index 000000000..a9496f1fa --- /dev/null +++ b/client/coral-embed-stream/src/graphql/mutations/postComment.graphql @@ -0,0 +1,23 @@ + + fragment commentView on Comment { + id + body + status + user { + name: displayName + } + actions { + type: action_type + count + current: current_user { + id + created_at + } + } + } + +mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) { + createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) { + ...commentView + } +} diff --git a/client/coral-embed-stream/src/graphql/queries/index.js b/client/coral-embed-stream/src/graphql/queries/index.js new file mode 100644 index 000000000..ae01ea9f4 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/queries/index.js @@ -0,0 +1,9 @@ +import {graphql} from 'react-apollo'; +import STREAM_QUERY from './streamQuery.graphql'; +import pym from 'coral-framework/PymConnection'; + +let url = pym.parentUrl.split('#')[0] || 'http://localhost:3000/'; + +export const queryStream = graphql(STREAM_QUERY, { + options: {variables: {asset_url: url}} +}); diff --git a/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql b/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql new file mode 100644 index 000000000..64bb46b47 --- /dev/null +++ b/client/coral-embed-stream/src/graphql/queries/streamQuery.graphql @@ -0,0 +1,47 @@ + +fragment commentView on Comment { + id + body + created_at + user { + id + name: displayName + settings { + bio + } + } + actions { + type: action_type + count + current: current_user { + id + created_at + } + } +} + +query AssetQuery($asset_url: String!) { + asset(url: $asset_url) { + id + title + url + closedAt + created_at + settings { + moderation + infoBoxEnable + infoBoxContent + closeTimeout + closedMessage + charCountEnable + charCount + requireEmailConfirmation + } + comments { + ...commentView + replies { + ...commentView + } + } + } +} diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index de14edbc7..f75146f0a 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -1,11 +1,15 @@ import React from 'react'; import {render} from 'react-dom'; -import CommentStream from './CommentStream'; -import {Provider} from 'react-redux'; -import {store} from '../../coral-framework'; +import {ApolloProvider} from 'react-apollo'; + +import {client} from 'coral-framework/client'; +import store from 'coral-framework/store'; + +import Embed from './Embed'; render( - - - - , document.querySelector('#coralStream')); + + + + , document.querySelector('#coralStream') +); diff --git a/client/coral-framework/PymConnection.js b/client/coral-framework/PymConnection.js new file mode 100644 index 000000000..ca592b824 --- /dev/null +++ b/client/coral-framework/PymConnection.js @@ -0,0 +1,9 @@ +import Pym from '../../node_modules/pym.js'; + +const pym = new Pym.Child({polling: 100}); +export default pym; + +export const link = (url) => (e) => { + e.preventDefault(); + pym.sendMessage('navigate', url); +}; diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/asset.js similarity index 80% rename from client/coral-framework/actions/config.js rename to client/coral-framework/actions/asset.js index 77e7f5af5..b8474857c 100644 --- a/client/coral-framework/actions/config.js +++ b/client/coral-framework/actions/asset.js @@ -1,20 +1,21 @@ +import * as actions from '../constants/asset'; import coralApi from '../helpers/response'; -import * as actions from '../constants/config'; import {addNotification} from '../actions/notification'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from './../translations'; const lang = new I18n(translations); +export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST}); +export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset}); +export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error}); + const updateConfigRequest = () => ({type: actions.UPDATE_CONFIG_REQUEST}); const updateConfigSuccess = config => ({type: actions.UPDATE_CONFIG_SUCCESS, config}); const updateConfigFailure = () => ({type: actions.UPDATE_CONFIG_FAILURE}); export const updateConfiguration = newConfig => (dispatch, getState) => { - const assetId = getState().items.get('assets') - .keySeq() - .toArray()[0]; - + const assetId = getState().asset.toJS().id; dispatch(updateConfigRequest()); coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig}) .then(() => { @@ -25,12 +26,8 @@ export const updateConfiguration = newConfig => (dispatch, getState) => { }; export const updateOpenStream = closedBody => (dispatch, getState) => { - const assetId = getState().items.get('assets') - .keySeq() - .toArray()[0]; - + const assetId = getState().asset.toJS().id; dispatch(updateConfigRequest()); - coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody}) .then(() => { dispatch(addNotification('success', lang.t('successUpdateSettings'))); diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 0f65e14c2..e815fd5d0 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -27,7 +27,7 @@ export const fetchSignIn = (formData) => (dispatch) => { dispatch(signInRequest()); coralApi('/auth/local', {method: 'POST', body: formData}) .then(({user}) => { - const isAdmin = !!user.roles.filter(i => i === 'admin').length; + const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; dispatch(signInSuccess(user, isAdmin)); dispatch(hideSignInDialog()); dispatch(addItem(user, 'users')); @@ -132,7 +132,7 @@ export const checkLogin = () => dispatch => { throw new Error('Not logged in'); } - const isAdmin = !!result.user.roles.filter(i => i === 'admin').length; + const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); diff --git a/client/coral-framework/actions/items.js b/client/coral-framework/actions/items.js index 48418b2a2..e289e9f60 100644 --- a/client/coral-framework/actions/items.js +++ b/client/coral-framework/actions/items.js @@ -189,17 +189,34 @@ export function getItemsArray (ids) { * The newly put item to the item store */ -export function postItem (item, type, id) { - return (dispatch) => { - if (id) { - item.id = id; +export function postItem (item, type, id, mutate) { + console.log( + item, + type, + id, + mutate + ); + mutate({ + variables: { + asset_id: id, + body: item, + parent_id: null } - return coralApi(`/${type}`, {method: 'POST', body: item}) - .then((json) => { - dispatch(addItem({...item, id:json.id}, type)); - return json; - }); - }; + }).then(({data}) => { + console.log('it workt'); + console.log(data); + }); + + // return (dispatch) => { + // if (id) { + // item.id = id; + // } + // return coralApi(`/${type}`, {method: 'POST', body: item}) + // .then((json) => { + // dispatch(addItem({...item, id:json.id}, type)); + // return json; + // }); + // }; } /* diff --git a/client/coral-framework/client.js b/client/coral-framework/client.js new file mode 100644 index 000000000..40a539634 --- /dev/null +++ b/client/coral-framework/client.js @@ -0,0 +1,13 @@ +import ApolloClient, {addTypename} from 'apollo-client'; +import getNetworkInterface from './transport'; + +export const client = new ApolloClient({ + queryTransformer: addTypename, + dataIdFromObject: (result) => { + if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle + return result.__typename + result.id; // eslint-disable-line no-underscore-dangle + } + return null; + }, + networkInterface: getNetworkInterface() +}); diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js new file mode 100644 index 000000000..bfeb98441 --- /dev/null +++ b/client/coral-framework/constants/asset.js @@ -0,0 +1,13 @@ +export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST'; +export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE'; +export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS'; + +export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST'; +export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS'; +export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE'; + +export const UPDATE_CONFIG = 'UPDATE_CONFIG'; + +export const OPEN_COMMENTS = 'OPEN_COMMENTS'; +export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; +export const ADD_ITEM = 'ADD_ITEM'; diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js index 6e5db3662..b267fb7c0 100644 --- a/client/coral-framework/helpers/validate.js +++ b/client/coral-framework/helpers/validate.js @@ -1,5 +1,5 @@ export default { - email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), + 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 => (/^[a-zA-Z0-9_]+$/.test(displayName)) diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index 9a679d969..c21136d27 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -4,7 +4,8 @@ import * as itemActions from './actions/items'; import I18n from './modules/i18n/i18n'; import * as notificationActions from './actions/notification'; import * as authActions from './actions/auth'; -import * as configActions from './actions/config'; +import * as assetActions from './actions/asset'; +import pym from './PymConnection'; export { Notification, @@ -13,5 +14,6 @@ export { I18n, notificationActions, authActions, - configActions + assetActions, + pym }; diff --git a/client/coral-framework/reducers/asset.js b/client/coral-framework/reducers/asset.js new file mode 100644 index 000000000..495307aeb --- /dev/null +++ b/client/coral-framework/reducers/asset.js @@ -0,0 +1,36 @@ +import {Map} from 'immutable'; +import * as actions from '../constants/asset'; + +const initialState = Map({ + closedAt: null, + settings: null, + title: null, + url: null, + features: Map({}), + status: 'open', + moderation: null +}); + +export default function asset (state = initialState, action) { + switch (action.type) { + case actions.FETCH_ASSET_SUCCESS : + return state + .merge(action.asset); + case actions.UPDATE_CONFIG: + return state + .merge(action.config); + case actions.UPDATE_CONFIG_SUCCESS: + return state + .merge(action.config); + case actions.OPEN_COMMENTS: + return state + .set('status', 'open') + .set('closedAt', null); + case actions.CLOSE_COMMENTS: + return state + .set('status', 'closed') + .set('closedAt', Date.now()); + default: + return state; + } +} diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js deleted file mode 100644 index 362608d6d..000000000 --- a/client/coral-framework/reducers/config.js +++ /dev/null @@ -1,29 +0,0 @@ -import {Map} from 'immutable'; -import * as actions from '../constants/config'; - -const initialState = Map({ - features: Map({}), - status: 'open', - moderation: null -}); - -export default (state = initialState, action) => { - switch(action.type) { - case actions.UPDATE_CONFIG: - return state - .merge(Map(action.config)); - case actions.UPDATE_CONFIG_SUCCESS: - return state - .merge(Map(action.config)); - case actions.OPEN_COMMENTS: - return state - .set('status', 'open'); - case actions.CLOSE_COMMENTS: - return state - .set('status', 'closed'); - case actions.ADD_ITEM: - return action.item_type === 'assets' ? state.set('status', (action.item && action.item.closedAt && new Date(action.item.closedAt).getTime() <= new Date().getTime()) ? 'closed' : 'open') : state; - default: - return state; - } -}; diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js index a2f439028..8c1457bfe 100644 --- a/client/coral-framework/reducers/index.js +++ b/client/coral-framework/reducers/index.js @@ -1,20 +1,13 @@ -/* @flow */ - -import {combineReducers} from 'redux'; -import config from './config'; -import items from './items'; -import notification from './notification'; import auth from './auth'; import user from './user'; +import asset from './asset'; +import items from './items'; +import notification from './notification'; -/** - * Expose the combined main reducer - */ - -export default combineReducers({ - config, - items, - notification, +export default { auth, - user -}); + user, + asset, + items, + notification +}; diff --git a/client/coral-framework/store.js b/client/coral-framework/store.js index 8c3f9edc1..439569ee1 100644 --- a/client/coral-framework/store.js +++ b/client/coral-framework/store.js @@ -1,9 +1,24 @@ -import {createStore, applyMiddleware} from 'redux'; +import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; import thunk from 'redux-thunk'; import mainReducer from './reducers'; +import {client} from './client'; + +const middlewares = [ + applyMiddleware(client.middleware()), + applyMiddleware(thunk) +]; + +if (window.devToolsExtension) { + + // we can't have the last argument of compose() be undefined + middlewares.push(window.devToolsExtension()); +} export default createStore( - mainReducer, - window.devToolsExtension && window.devToolsExtension(), - applyMiddleware(thunk) + combineReducers({ + ...mainReducer, + apollo: client.reducer() + }), + {}, + compose(...middlewares) ); diff --git a/client/coral-framework/subscriptions.js b/client/coral-framework/subscriptions.js new file mode 100644 index 000000000..f514ea18b --- /dev/null +++ b/client/coral-framework/subscriptions.js @@ -0,0 +1,14 @@ +import {print} from 'graphql-tag/printer'; + +// quick way to add the subscribe and unsubscribe functions to the network interface +const addGraphQLSubscriptions = (networkInterface, wsClient) => Object.assign(networkInterface, { + subscribe: (request, handler) => wsClient.subscribe({ + query: print(request.query), + variables: request.variables, + }, handler), + unsubscribe: (id) => { + wsClient.unsubscribe(id); + }, +}); + +export default addGraphQLSubscriptions; diff --git a/client/coral-framework/transport.js b/client/coral-framework/transport.js new file mode 100644 index 000000000..2bd6ac636 --- /dev/null +++ b/client/coral-framework/transport.js @@ -0,0 +1,11 @@ +import {createNetworkInterface} from 'apollo-client'; + +export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) { + return new createNetworkInterface({ + uri: apiUrl, + opts: { + credentials: 'same-origin', + headers, + }, + }); +} diff --git a/client/coral-plugin-author-name/AuthorName.js b/client/coral-plugin-author-name/AuthorName.js index bf970b3c4..fcf464403 100644 --- a/client/coral-plugin-author-name/AuthorName.js +++ b/client/coral-plugin-author-name/AuthorName.js @@ -36,8 +36,8 @@ export default class AuthorName extends Component { onMouseOver={this.handleMouseOver} onMouseLeave={this.handleMouseLeave} > - {author && author.displayName} - { showTooltip && + {author && author.name} + { showTooltip && author.settings.bio &&
{author.settings.bio}
diff --git a/client/coral-plugin-comment-count/CommentCount.js b/client/coral-plugin-comment-count/CommentCount.js index 87f656c22..764984634 100644 --- a/client/coral-plugin-comment-count/CommentCount.js +++ b/client/coral-plugin-comment-count/CommentCount.js @@ -1,29 +1,18 @@ -import React from 'react'; +import React, {PropTypes} from 'react'; import {I18n} from '../coral-framework'; import translations from './translations.json'; -import has from 'lodash/has'; -import reduce from 'lodash/reduce'; const name = 'coral-plugin-comment-count'; -const CommentCount = ({items, id}) => { - let count = 0; - if (has(items, `assets.${id}.comments`)) { - count += items.assets[id].comments.length; - } - - // lodash reduce works on {} - count += reduce(items.comments, (accum, comment) => { - if (comment.children) { - accum += comment.children.length; - } - return accum; - }, 0); - +const CommentCount = ({count}) => { return
{`${count} ${count === 1 ? lang.t('comment') : lang.t('comment-plural')}`}
; }; +CommentCount.propTypes = { + count: PropTypes.number.isRequired +}; + export default CommentCount; const lang = new I18n(translations); diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index fe67b22e0..f368953a7 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -8,11 +8,15 @@ const name = 'coral-plugin-commentbox'; class CommentBox extends Component { static propTypes = { - postItem: PropTypes.func, - updateItem: PropTypes.func, - id: PropTypes.string, - comments: PropTypes.array, - reply: PropTypes.bool, + + // updateItem: PropTypes.func, + // comments: PropTypes.array, + commentPostedHandler: PropTypes.func, + postItem: PropTypes.func.isRequired, + assetId: PropTypes.string.isRequired, + parentId: PropTypes.string, + authorId: PropTypes.string.isRequired, + isReply: PropTypes.bool.isRequired, canPost: PropTypes.bool, currentUser: PropTypes.object } @@ -24,73 +28,83 @@ class CommentBox extends Component { postComment = () => { const { + + // child_id, + // updateItem, + // appendItemArray, + commentPostedHandler, postItem, - updateItem, - id, - parent_id, - child_id, + assetId, + parentId, addNotification, - appendItemArray, - premod, - author + authorId } = this.props; let comment = { body: this.state.body, - asset_id: id, - author_id: author.id + asset_id: assetId, + author_id: authorId, + parent_id: parentId }; - let related; - let parent_type; - if (parent_id) { - comment.parent_id = parent_id; - related = 'children'; - parent_type = 'comments'; - } else { - related = 'comments'; - parent_type = 'assets'; - } - if (child_id || parent_id) { - updateItem(child_id || parent_id, 'showReply', false, 'comments'); - } + + // let related; + // let parent_type; + // if (parent_id) { + // comment.parent_id = parent_id; + // related = 'children'; + // parent_type = 'comments'; + // } else { + // related = 'comments'; + // parent_type = 'assets'; + // } + // if (child_id || parent_id) { + // updateItem(child_id || parent_id, 'showReply', false, 'comments'); + // } if (this.props.charCount && this.state.body.length > this.props.charCount) { return; } postItem(comment, 'comments') - .then((postedComment) => { - const commentId = postedComment.id; - if (postedComment.status === 'rejected') { + .then(({data}) => { + const postedComment = data.createComment; + + // const commentId = postedComment.id; + if (postedComment.status === 'REJECTED') { addNotification('error', lang.t('comment-post-banned-word')); - } else if (premod === 'pre') { + } else if (postedComment.status === 'PREMOD') { addNotification('success', lang.t('comment-post-notif-premod')); } else { - appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type); + + // appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type); addNotification('success', 'Your comment has been posted.'); } + + if (commentPostedHandler) { + commentPostedHandler(); + } }) .catch((err) => console.error(err)); this.setState({body: ''}); } render () { - const {styles, reply, author, charCount} = this.props; + const {styles, isReply, authorId, charCount} = this.props; const length = this.state.body.length; return
+ +
; + } +} + +const postComment = gql` + fragment commentView on Comment { + id + body + user { + name: displayName + } + actions { + type: action_type + count + current: current_user { + id + created_at + } + } + } + + mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) { + createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) { + ...commentView + } + } +`; + +const RileysAwesomeCommentBoxWithData = graphql( + postComment +)(RileysAwesomeCommentBox); + +export default RileysAwesomeCommentBoxWithData; diff --git a/client/coral-plugin-stream/Stream.js b/client/coral-plugin-stream/Stream.js new file mode 100644 index 000000000..2ec84af24 --- /dev/null +++ b/client/coral-plugin-stream/Stream.js @@ -0,0 +1,97 @@ +import React, {Component} from 'react'; +import {graphql} from 'react-apollo'; +import gql from 'graphql-tag'; +import {fetchSignIn} from 'coral-framework/actions/auth'; +import RileysAwesomeCommentBox from 'coral-plugin-stream/RileysAwesomeCommentBox'; + +const assetID = '6187a94b-0b6d-4a96-ac6b-62b529cd8410'; + +// MyComponent is a "presentational" or apollo-unaware component, +// It could be a simple React class: +class Stream extends Component { + + constructor(props) { + super(props); + } + + logMeIn() { + fetchSignIn({email: 'your@example.com', password: 'dfasidfaisdufoiausdfoiuaspdoifas'})(() => {}); + } + + render() { + console.log(this.props); + const {data} = this.props; + return
+ + { + data.loading + ? 'loading!' + :
+ +

Asset ID: {data.asset.id}

+
    + { + data.asset.comments.map(comment => { + return
  • + {comment.body} [{comment.id}] +
      + { + comment.replies.map(reply => { + return
    • {reply.body}
    • ; + }) + } +
    +
  • ; + }) + } +
+
+ } +
; + } +} + +// Initialize GraphQL queries or mutations with the `gql` tag +const StreamQuery = gql`fragment commentView on Comment { + id + body + user { + name: displayName + } + actions { + type: action_type + count + current: current_user { + id + created_at + } + } +} + +query AssetQuery($asset_id: ID!) { + asset(id: $asset_id) { + id + title + url + comments { + ...commentView + replies { + ...commentView + } + } + } +}`; + +// We then can use `graphql` to pass the query results returned by MyQuery +// to MyComponent as a prop (and update them as the results change) +const StreamWithData = graphql( + StreamQuery, { + options: { + variables: { + asset_id: assetID + } + } + } +)(Stream); + +export default StreamWithData; diff --git a/client/coral-settings/components/NotLoggedIn.js b/client/coral-settings/components/NotLoggedIn.js index d7bf467e8..8266a745e 100644 --- a/client/coral-settings/components/NotLoggedIn.js +++ b/client/coral-settings/components/NotLoggedIn.js @@ -1,10 +1,15 @@ import React from 'react'; import styles from './NotLoggedIn.css'; +import SignInContainer from '../../coral-sign-in/containers/SignInContainer'; export default ({showSignInDialog}) => (
+
From the Settings Page you can diff --git a/client/coral-settings/components/SettingsHeader.js b/client/coral-settings/components/SettingsHeader.js index b55f008ad..8ed13713f 100644 --- a/client/coral-settings/components/SettingsHeader.js +++ b/client/coral-settings/components/SettingsHeader.js @@ -4,7 +4,11 @@ import styles from './SettingsHeader.css'; export default ({userData}) => (

{userData.displayName}

-

{userData.profiles.map(profile => profile.id)}

+ + { + + // Hiding display of users ID unless there's a use case for it. + //

{userData.profiles.map(profile => profile.id)}

+ }
); - diff --git a/client/coral-settings/containers/SettingsContainer.js b/client/coral-settings/containers/SettingsContainer.js index e7b930255..001190f1d 100644 --- a/client/coral-settings/containers/SettingsContainer.js +++ b/client/coral-settings/containers/SettingsContainer.js @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user'; +import {link} from 'coral-framework/PymConnection'; import BioContainer from './BioContainer'; import NotLoggedIn from '../components/NotLoggedIn'; import {TabBar, Tab, TabContent} from '../../coral-ui'; @@ -62,6 +63,7 @@ class SettingsContainer extends Component { user.myComments.length && user.myAssets.length ? items.assets[id])} /> :

{lang.t('user-no-comment')}

} diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 554afb29e..6f57f3918 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -916,7 +916,7 @@ definitions: type: array items: type: string - description: Roles occupied by the user (e.g. 'admin', 'moderator', etc.) + description: Roles occupied by the user (e.g. 'ADMIN', 'MODERATOR', etc.) status: type: string description: The current status of the user in the system. diff --git a/graph/context.js b/graph/context.js new file mode 100644 index 000000000..ce8f5eb83 --- /dev/null +++ b/graph/context.js @@ -0,0 +1,23 @@ +const loaders = require('./loaders'); +const mutators = require('./mutators'); + +/** + * Stores the request context. + */ +class Context { + constructor({user = null}) { + + // Load the current logged in user to `user`, otherwise this'll be null. + if (user) { + this.user = user; + } + + // Create the loaders. + this.loaders = loaders(this); + + // Create the mutators. + this.mutators = mutators(this); + } +} + +module.exports = Context; diff --git a/graph/index.js b/graph/index.js new file mode 100644 index 000000000..7fddce2eb --- /dev/null +++ b/graph/index.js @@ -0,0 +1,14 @@ +const schema = require('./schema'); +const Context = require('./context'); + +module.exports = { + createGraphOptions: (req) => ({ + + // Schema is created already, so just include it. + schema, + + // Load in the new context here, this'll create the loaders + mutators for + // the lifespan of this request. + context: new Context(req) + }) +}; diff --git a/graph/loaders/actions.js b/graph/loaders/actions.js new file mode 100644 index 000000000..cc7d43a12 --- /dev/null +++ b/graph/loaders/actions.js @@ -0,0 +1,28 @@ +const DataLoader = require('dataloader'); + +const util = require('./util'); + +const ActionsService = require('../../services/actions'); + +/** + * Looks up actions based on the requested id's all bounded by the user. + * @param {Object} context the context of the request + * @param {Array} ids array of id's to get + * @return {Promise} resolves to the promises of the requested actions + */ +const genActionSummariessByItemID = ({user = {}}, item_ids) => { + return ActionsService + .getActionSummaries(item_ids, user.id) + .then(util.arrayJoinBy(item_ids, 'item_id')); +}; + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Actions: { + getByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)), + } +}); diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js new file mode 100644 index 000000000..a07a18a3f --- /dev/null +++ b/graph/loaders/assets.js @@ -0,0 +1,64 @@ +const DataLoader = require('dataloader'); +const url = require('url'); + +const errors = require('../../errors'); +const scraper = require('../../services/scraper'); +const util = require('./util'); + +const AssetModel = require('../../models/asset'); +const AssetsService = require('../../services/assets'); + +/** + * Retrieves assets by an array of ids. + * @param {Object} context the context of the request + * @param {Array} ids array of ids to lookup + */ +const genAssetsByID = (context, ids) => AssetModel.find({ + id: { + $in: ids + } +}).then(util.singleJoinBy(ids, 'id')); + +/** + * This endpoint find or creates an asset at the given url when it is loaded. + * @param {Object} context the context of the request + * @param {String} asset_url the url passed in from the query + * @returns {Promise} resolves to the asset + */ +const findOrCreateAssetByURL = (context, asset_url) => { + + // Verify that the asset_url is parsable. + let parsed_asset_url = url.parse(asset_url); + if (!parsed_asset_url.protocol) { + return Promise.reject(errors.ErrInvalidAssetURL); + } + + return AssetsService.findOrCreateByUrl(asset_url) + .then((asset) => { + + // If the asset wasn't scraped before, scrape it! Otherwise just return + // the asset. + if (!asset.scraped) { + return scraper.create(asset).then(() => asset); + } + + return asset; + }); +}; + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Assets: { + + // TODO: decide whether we want to move these to mutators or not, as in fact + // this operation create a new asset if one isn't found. + getByURL: (url) => findOrCreateAssetByURL(context, url), + + getByID: new DataLoader((ids) => genAssetsByID(context, ids)), + getAll: new util.SingletonResolver(() => AssetModel.find({})) + } +}); diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js new file mode 100644 index 000000000..9a67bb621 --- /dev/null +++ b/graph/loaders/comments.js @@ -0,0 +1,98 @@ +const DataLoader = require('dataloader'); + +const util = require('./util'); + +const ActionModel = require('../../models/action'); +const CommentModel = require('../../models/comment'); +const CommentsService = require('../../services/comments'); + +/** + * Retrieves comments by an array of asset id's, results are returned in reverse + * chronological order. + * @param {Array} ids array of ids to lookup + */ +const genCommentsByAssetID = (context, ids) => { + return CommentModel.find({ + asset_id: { + $in: ids + }, + parent_id: null, + status: { + $in: [null, 'ACCEPTED'] + } + }) + .sort({created_at: -1}) + .then(util.arrayJoinBy(ids, 'asset_id')); +}; + +/** + * Retrieves comments by an array of parent ids, results are returned in + * chronological order. + * @param {Array} ids array of ids to lookup + */ +const genCommentsByParentID = (context, ids) => { + return CommentModel.find({ + parent_id: { + $in: ids + }, + status: { + $in: [null, 'ACCEPTED'] + } + }) + .sort({created_at: 1}) + .then(util.arrayJoinBy(ids, 'parent_id')); +}; + +const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null}) => { + + // TODO: remove when we move the enum over to the uppercase. + if (status) { + status = status.toLowerCase(); + } + + return CommentsService.moderationQueue(status, asset_id); +}; + +const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => { + return ActionModel.find({ + action_type, + item_type: 'COMMENTS' + }).then((actions) => { + let comments = CommentModel.find({ + id: { + $in: actions.map((action) => action.item_id) + } + }).sort({created_at: 1}); + + if (asset_id) { + comments = comments.where({asset_id}); + } + + return comments; + }); +}; + +const genCommentsByAuthorID = (context, authorIDs) => { + return CommentModel.find({ + author_id: { + $in: authorIDs + } + }) + .sort({created_at: -1}) + .then(util.arrayJoinBy(authorIDs, 'author_id')); +}; + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Comments: { + getByParentID: new DataLoader((ids) => genCommentsByParentID(context, ids)), + getByAssetID: new DataLoader((ids) => genCommentsByAssetID(context, ids)), + getByStatusAndAssetID: (query) => getCommentsByStatusAndAssetID(context, query), + getByActionTypeAndAssetID: (query) => getCommentsByActionTypeAndAssetID(context, query), + getByAuthorID: new DataLoader((authorIDs) => genCommentsByAuthorID(context, authorIDs)) + } +}); diff --git a/graph/loaders/index.js b/graph/loaders/index.js new file mode 100644 index 000000000..536e40fa9 --- /dev/null +++ b/graph/loaders/index.js @@ -0,0 +1,28 @@ +const _ = require('lodash'); + +const Actions = require('./actions'); +const Assets = require('./assets'); +const Comments = require('./comments'); +const Settings = require('./settings'); +const Users = require('./users'); + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => { + + // We need to return an object to be accessed. + return _.merge(...[ + Actions, + Assets, + Comments, + Settings, + Users + ].map((loaders) => { + + // Each loader is a function which takes the context. + return loaders(context); + })); +}; diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js new file mode 100644 index 000000000..77ece165f --- /dev/null +++ b/graph/loaders/settings.js @@ -0,0 +1,12 @@ +const SettingsService = require('../../services/settings'); + +const util = require('./util'); + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = () => ({ + Settings: new util.SingletonResolver(() => SettingsService.retrieve()) +}); diff --git a/graph/loaders/users.js b/graph/loaders/users.js new file mode 100644 index 000000000..90c661f71 --- /dev/null +++ b/graph/loaders/users.js @@ -0,0 +1,20 @@ +const DataLoader = require('dataloader'); + +const util = require('./util'); + +const UsersService = require('../../services/users'); + +const genUserByIDs = (context, ids) => UsersService + .findByIdArray(ids) + .then(util.singleJoinBy(ids, 'id')); + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Users: { + getByID: new DataLoader((ids) => genUserByIDs(context, ids)) + } +}); diff --git a/graph/loaders/util.js b/graph/loaders/util.js new file mode 100644 index 000000000..e947f2fa6 --- /dev/null +++ b/graph/loaders/util.js @@ -0,0 +1,68 @@ +const _ = require('lodash'); + +/** + * SingletonResolver is a cached loader for a single result. + */ +class SingletonResolver { + constructor(resolver) { + this._cache = null; + this._resolver = resolver; + } + + load() { + if (this._cache) { + return this._cache; + } + + let promise = this._resolver(arguments).then((result) => { + return result; + }); + + // Set the promise on the cache. + this._cache = promise; + + return promise; + } +} + +/** + * This joins a set of results with a specific keys and sets an empty array in + * place if it was not found. + * @param {Array} ids ids to locate + * @param {String} key key to group by + * @return {Array} array of results + */ +const arrayJoinBy = (ids, key) => (items) => { + const itemsByKey = _.groupBy(items, key); + return ids.map((id) => { + if (id in itemsByKey) { + return itemsByKey[id]; + } + + return []; + }); +}; + +/** + * This joins a set of results with a specific keys and sets null in place if it + * was not found. + * @param {Array} ids ids to locate + * @param {String} key key to group by + * @return {Array} array of results + */ +const singleJoinBy = (ids, key) => (items) => { + const itemsByKey = _.groupBy(items, key); + return ids.map((id) => { + if (id in itemsByKey) { + return itemsByKey[id][0]; + } + + return null; + }); +}; + +module.exports = { + singleJoinBy, + arrayJoinBy, + SingletonResolver +}; diff --git a/graph/mutators/action.js b/graph/mutators/action.js new file mode 100644 index 000000000..ab51c9a20 --- /dev/null +++ b/graph/mutators/action.js @@ -0,0 +1,55 @@ +const ActionModel = require('../../models/action'); +const ActionsService = require('../../services/actions'); + +/** + * Creates an action on a item. + * @param {Object} user the user performing the request + * @param {String} item_id id of the item to add the action to + * @param {String} item_type type of the item + * @param {String} action_type type of the action + * @return {Promise} resolves to the action created + */ +const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => { + return ActionsService.insertUserAction({ + item_id, + item_type, + user_id: user.id, + action_type, + metadata + }); +}; + +/** + * Deletes an action based on the user id if the user owns that action. + * @param {Object} user the user performing the request + * @param {String} id the id of the action to delete + * @return {Promise} resolves when the action is deleted + */ +const deleteAction = ({user}, {id}) => { + return ActionModel.remove({ + id, + user_id: user.id + }); +}; + +module.exports = (context) => { + + // TODO: refactor to something that'll return an error in the event an attempt + // is made to mutate state while not logged in. There's got to be a better way + // to do this. + if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) { + return { + Action: { + create: (action) => createAction(context, action), + delete: (action) => deleteAction(context, action) + } + }; + } + + return { + Action: { + create: () => {}, + delete: () => {} + } + }; +}; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js new file mode 100644 index 000000000..cc62e48df --- /dev/null +++ b/graph/mutators/comment.js @@ -0,0 +1,160 @@ +const errors = require('../../errors'); + +const AssetsService = require('../../services/assets'); +const CommentsService = require('../../services/comments'); + +const Wordlist = require('../../services/wordlist'); + +/** + * Creates a new comment. + * @param {Object} user the user performing the request + * @param {String} body body of the comment + * @param {String} asset_id asset for the comment + * @param {String} parent_id optional parent of the comment + * @param {String} [status=null] the status of the new comment + * @return {Promise} resolves to the created comment + */ +const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => { + return CommentsService.publicCreate({ + body, + asset_id, + parent_id, + status, + author_id: user.id + }); +}; + +/** + * Filters the comment object and outputs wordlist results. + * @param {Object} context graphql context + * @param {String} body body of a comment + * @return {Object} resolves to the wordlist results + */ +const filterNewComment = (context, {body}) => { + + // Create a new instance of the Wordlist. + const wl = new Wordlist(); + + // Load the wordlist and filter the comment content. + return wl.load().then(() => wl.scan('body', body)); +}; + +/** + * This resolves a given comment's status to take into account moderator actions + * are applied. + * @param {Object} context graphql context + * @param {String} body body of the comment + * @param {String} asset_id asset for the comment + * @param {Object} [wordlist={}] the results of the wordlist scan + * @return {Promise} resolves to the comment's status + */ +const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => { + + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + let status; + + if (wordlist.banned) { + status = Promise.resolve('REJECTED'); + } else { + status = AssetsService + .rectifySettings(AssetsService.findById(asset_id).then((asset) => { + if (!asset) { + return Promise.reject(errors.ErrNotFound); + } + + // Check to see if the asset has closed commenting... + if (asset.isClosed) { + + // They have, ensure that we send back an error. + return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage)); + } + + return asset; + })) + + // Return `premod` if pre-moderation is enabled and an empty "new" status + // in the event that it is not in pre-moderation mode. + .then(({moderation, charCountEnable, charCount}) => { + + // Reject if the comment is too long + if (charCountEnable && body.length > charCount) { + return 'REJECTED'; + } + return moderation === 'PRE' ? 'PREMOD' : null; + }); + } + + return status; +}; + +/** + * createPublicComment is designed to create a comment from a public source. It + * validates the comment, and performs some automated moderator actions based on + * the settings. + * @param {Object} context the graphql context + * @param {Object} commentInput the new comment to be created + * @return {Promise} resolves to a new comment + */ +const createPublicComment = (context, commentInput) => { + + // First we filter the comment contents to ensure that we note any validation + // issues. + return filterNewComment(context, commentInput) + + // We then take the wordlist and the comment into consideration when + // considering what status to assign the new comment, and resolve the new + // status to set the comment to. + .then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist) + + // Then we actually create the comment with the new status. + .then((status) => createComment(context, commentInput, status)) + .then((comment) => { + + // If the comment was flagged as being suspect, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + + // TODO: Check why the wordlist is undefined + if (wordlist != null) { + + // TODO: this is kind of fragile, we should refactor this to resolve + // all these const's that we're using like 'comments', 'flag' to be + // defined in a checkable schema. + return context.mutators.Action.createAction(null, { + item_id: comment.id, + item_type: 'COMMENTS', + action_type: 'FLAG', + metadata: { + field: 'body', + details: 'Matched suspect word filters.' + } + }).then(() => comment); + } + + // Finally, we return the comment. + return comment; + })); +}; + +module.exports = (context) => { + + // TODO: refactor to something that'll return an error in the event an attempt + // is made to mutate state while not logged in. There's got to be a better way + // to do this. + if (context.user && context.user.can('mutation:createComment')) { + return { + Comment: { + create: (comment) => createPublicComment(context, comment) + } + }; + } + + return { + Comment: { + create: () => {} + } + }; +}; diff --git a/graph/mutators/index.js b/graph/mutators/index.js new file mode 100644 index 000000000..b799cf83d --- /dev/null +++ b/graph/mutators/index.js @@ -0,0 +1,19 @@ +const _ = require('lodash'); + +const Comment = require('./comment'); +const Action = require('./action'); +const User = require('./user'); + +module.exports = (context) => { + + // We need to return an object to be accessed. + return _.merge(...[ + Comment, + Action, + User, + ].map((mutators) => { + + // Each set of mutators is a function which takes the context. + return mutators(context); + })); +}; diff --git a/graph/mutators/user.js b/graph/mutators/user.js new file mode 100644 index 000000000..96049bbab --- /dev/null +++ b/graph/mutators/user.js @@ -0,0 +1,31 @@ +const UsersService = require('../../services/users'); + +/** + * Updates a users settings. + * @param {Object} user the user performing the request + * @param {String} bio the new user bio + * @return {Promise} + */ +const updateUserSettings = ({user}, {bio}) => { + return UsersService.updateSettings(user.id, {bio}); +}; + +module.exports = (context) => { + + // TODO: refactor to something that'll return an error in the event an attempt + // is made to mutate state while not logged in. There's got to be a better way + // to do this. + if (context.user && context.user.can('mutation:updateUserSettings')) { + return { + User: { + updateSettings: (settings) => updateUserSettings(context, settings) + } + }; + } + + return { + User: { + updateSettings: () => {} + } + }; +}; diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js new file mode 100644 index 000000000..e6886653e --- /dev/null +++ b/graph/resolvers/action.js @@ -0,0 +1,12 @@ +const Action = { + + // This will load the user for the specific action. We'll limit this to the + // admin users only. + user({user_id}, _, {loaders, user}) { + if (user.hasRole('ADMIN')) { + return loaders.Users.getByID.load(user_id); + } + } +}; + +module.exports = Action; diff --git a/graph/resolvers/action_summary.js b/graph/resolvers/action_summary.js new file mode 100644 index 000000000..48eff50c7 --- /dev/null +++ b/graph/resolvers/action_summary.js @@ -0,0 +1,3 @@ +const ActionSummary = {}; + +module.exports = ActionSummary; diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js new file mode 100644 index 000000000..dcf6e1a75 --- /dev/null +++ b/graph/resolvers/asset.js @@ -0,0 +1,18 @@ +const Asset = { + comments({id}, _, {loaders}) { + return loaders.Comments.getByAssetID.load(id); + }, + settings({settings = null}, _, {loaders}) { + return loaders.Settings.load() + .then((globalSettings) => { + if (settings) { + settings = Object.assign({}, globalSettings.toObject(), settings); + } else { + settings = globalSettings.toObject(); + } + return settings; + }); + } +}; + +module.exports = Asset; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js new file mode 100644 index 000000000..ce2283cda --- /dev/null +++ b/graph/resolvers/comment.js @@ -0,0 +1,16 @@ +const Comment = { + user({author_id}, _, {loaders}) { + return loaders.Users.getByID.load(author_id); + }, + replies({id}, _, {loaders}) { + return loaders.Comments.getByParentID.load(id); + }, + actions({id}, _, {loaders}) { + return loaders.Actions.getByItemID.load(id); + }, + asset({asset_id}, _, {loaders}) { + return loaders.Assets.getByID.load(asset_id); + } +}; + +module.exports = Comment; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js new file mode 100644 index 000000000..064b1e664 --- /dev/null +++ b/graph/resolvers/index.js @@ -0,0 +1,19 @@ +const Action = require('./action'); +const ActionSummary = require('./action_summary'); +const Asset = require('./asset'); +const Comment = require('./comment'); +const RootMutation = require('./root_mutation'); +const RootQuery = require('./root_query'); +const Settings = require('./settings'); +const User = require('./user'); + +module.exports = { + Action, + ActionSummary, + Asset, + Comment, + RootMutation, + RootQuery, + Settings, + User +}; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js new file mode 100644 index 000000000..bf108671e --- /dev/null +++ b/graph/resolvers/root_mutation.js @@ -0,0 +1,16 @@ +const RootMutation = { + createComment(_, {asset_id, parent_id, body}, {mutators}) { + return mutators.Comment.create({asset_id, parent_id, body}); + }, + createAction(_, {action}, {mutators}) { + return mutators.Action.create(action); + }, + deleteAction(_, {id}, {mutators}) { + return mutators.Action.delete({id}); + }, + updateUserSettings(_, {settings}, {mutators}) { + return mutators.User.updateSettings(settings); + } +}; + +module.exports = RootMutation; diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js new file mode 100644 index 000000000..5676b4f48 --- /dev/null +++ b/graph/resolvers/root_query.js @@ -0,0 +1,50 @@ +const RootQuery = { + assets(_, args, {loaders, user}) { + if (user == null || !user.hasRoles('ADMIN')) { + return null; + } + + return loaders.Assets.getAll.load(); + }, + asset(_, query, {loaders}) { + if (query.id) { + + // TODO: we may not always have a comment stream here, therefore, when we + // load it, we may also need to create with the url. This may also have to + // move the logic over to the mutators function as an upsert operation + // possibly. + return loaders.Assets.getByID.load(query.id); + } + + return loaders.Assets.getByURL(query.url); + }, + settings(_, args, {loaders}) { + return loaders.Settings.load(); + }, + + // This endpoint is used for loading moderation queues, so hide it in the + // event that we aren't an admin. + comments(_, {query}, {loaders, user}) { + if (user == null || !user.hasRoles('ADMIN')) { + return null; + } + + if (query.action_type) { + return loaders.Comments.getByActionTypeAndAssetID(query); + } else { + return loaders.Comments.getByStatusAndAssetID(query); + } + }, + + // This returns the current user, ensure that if we aren't logged in, we + // return null. + me(_, args, {user}) { + if (user == null) { + return null; + } + + return user; + } +}; + +module.exports = RootQuery; diff --git a/graph/resolvers/settings.js b/graph/resolvers/settings.js new file mode 100644 index 000000000..72608a9c4 --- /dev/null +++ b/graph/resolvers/settings.js @@ -0,0 +1,3 @@ +const Settings = {}; + +module.exports = Settings; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js new file mode 100644 index 000000000..65882feee --- /dev/null +++ b/graph/resolvers/user.js @@ -0,0 +1,17 @@ +const User = { + actions({id}, _, {loaders}) { + return loaders.Actions.getByID.load(id); + }, + comments({id}, _, {loaders, user}) { + + // If the user is not an admin, only return comment list for the owner of + // the comments. + if (!user.hasRoles('ADMIN') || user.id !== id) { + return null; + } + + return loaders.Comments.getByAuthorID.load(id); + } +}; + +module.exports = User; diff --git a/graph/schema.js b/graph/schema.js new file mode 100644 index 000000000..b4b42b809 --- /dev/null +++ b/graph/schema.js @@ -0,0 +1,8 @@ +const tools = require('graphql-tools'); + +const resolvers = require('./resolvers'); +const typeDefs = require('./typeDefs'); + +const schema = tools.makeExecutableSchema({typeDefs, resolvers}); + +module.exports = schema; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql new file mode 100644 index 000000000..167f75f4f --- /dev/null +++ b/graph/typeDefs.graphql @@ -0,0 +1,177 @@ +interface ActionableItem { + id: ID! +} + +type UserSettings { + # bio of the user. + bio: String +} + +input CommentsInput { + # current status of a comment. + status: COMMENT_STATUS + + # asset that a comment is on. + asset_id: ID + + # action type to find comments that have an action with. + action_type: ACTION_TYPE +} + +# Any person who can author comments, create actions, and view comments on a +# stream. +type User { + id: ID! + + # display name of a user. + displayName: String! + + # actions against a specific user. + actions: [ActionSummary] + + # settings for a user. + settings: UserSettings + + # returns all comments based on a query. + comments(query: CommentsInput): [Comment] +} + +type Comment { + id: ID! + + # the actual comment data. + body: String! + + # the user who authored the comment. + user: User + + # the replies that were made to the comment. + replies(limit: Int = 3): [Comment] + + # the actions made against a comment. + actions: [ActionSummary] + + # the asset that a comment was made on. + asset: Asset + + # the current status of a comment. + status: COMMENT_STATUS + + # the time when the comment was created + created_at: String! +} + +enum ITEM_TYPE { + ASSETS + COMMENTS + USERS +} + +enum ACTION_TYPE { + LIKE + FLAG +} + +type Action { + id: ID! + action_type: ACTION_TYPE! + + item_id: ID! + item_type: ITEM_TYPE! + item: ActionableItem + + user: User! + updated_at: String + created_at: String +} + +type ActionSummary { + action_type: ACTION_TYPE! + item_type: ITEM_TYPE! + count: Int + current_user: Action +} + +enum MODERATION_MODE { + PRE + POST +} + +type Settings { + moderation: MODERATION_MODE! + infoBoxEnable: Boolean + infoBoxContent: String + closeTimeout: Int + closedMessage: String + charCountEnable: Boolean + charCount: Int + requireEmailConfirmation: Boolean +} + +type Asset { + id: ID! + title: String + url: String + comments: [Comment] + settings: Settings! + closedAt: String + created_at: String +} + +enum COMMENT_STATUS { + ACCEPTED + REJECTED + PREMOD +} + +type RootQuery { + # retrieves site wide settings and defaults. + settings: Settings + + # retrieves all assets. + assets: [Asset] + + # retrieves a specific asset. + asset(id: ID, url: String): Asset + + # retrieves comments based on the input query. + comments(query: CommentsInput): [Comment] + + # retrieves the current logged in user. + me: User +} + +input CreateActionInput { + # the type of action. + action_type: ACTION_TYPE! + + # the type of the item. + item_type: ITEM_TYPE! + + # the id of the item that is related to the action. + item_id: ID! +} + +input UpdateUserSettingsInput { + # user bio + bio: String! +} + +type RootMutation { + # creates a comment on the asset. + createComment(asset_id: ID!, parent_id: ID, body: String!): Comment + + # creates an action based on an input. + createAction(action: CreateActionInput!): Action + + # delete an action based on the action id. + deleteAction(id: ID!): Boolean + + # updates a user's settings, it will return if the query was successful. + updateUserSettings(settings: UpdateUserSettingsInput!): Boolean +} + +schema { + query: RootQuery + mutation: RootMutation +} diff --git a/graph/typeDefs.js b/graph/typeDefs.js new file mode 100644 index 000000000..7ac982269 --- /dev/null +++ b/graph/typeDefs.js @@ -0,0 +1,11 @@ +// TODO: Adjust `RootQuery.asset(id: ID, url: String)` to instead be +// `RootQuery.asset(id: ID, url: String!)` because we'll always need the url, if +// this change is done now everything will likely break on the front end. + +const fs = require('fs'); +const path = require('path'); + +// Load the typeDefs from the graphql file. +const typeDefs = fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8'); + +module.exports = typeDefs; diff --git a/init.js b/init.js index c29684b23..eb17963a6 100644 --- a/init.js +++ b/init.js @@ -1,7 +1,13 @@ -const Setting = require('./models/setting'); +const SettingsService = require('./services/settings'); module.exports = () => Promise.all([ // Upsert the settings object. - Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}}) + SettingsService.init({ + moderation: 'PRE', + wordlist: { + banned: [], + suspect: [] + } + }) ]); diff --git a/middleware/authorization.js b/middleware/authorization.js index efe4b1034..d93913109 100644 --- a/middleware/authorization.js +++ b/middleware/authorization.js @@ -17,7 +17,11 @@ const ErrNotAuthorized = require('../errors').ErrNotAuthorized; * @return {Boolean} true if the user has all the roles required, false * otherwise */ -authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf(role) >= 0); +authorization.has = (user, ...roles) => roles.every((role) => { + + // TODO: remove toUpperCase once we've migrated over the roles. + return user.roles.indexOf(role.toUpperCase()) >= 0; +}); /** * needed is a connect middleware layer that ensures that all requests coming diff --git a/middleware/payload-filter.js b/middleware/payload-filter.js deleted file mode 100644 index b4cf90021..000000000 --- a/middleware/payload-filter.js +++ /dev/null @@ -1,74 +0,0 @@ -// The maximum depth to recurse into nested objects checking for mongoose -// objects. -const maxRecursion = 3; - -/** - * Middleware to wrap the `res.json` function to ensure that we can filter the - * payload response first based on user and role. - */ -module.exports = (req, res, next) => { - - /** - * Updates the original document based on filtering out for roles. - * @param {Mixed} o original object to be modified - * @param {Integer} l current level of depth in the first object - * @return {Mixed} (possibly) modified object - */ - const wrap = (o, d = 0) => { - if (d > maxRecursion) { - return o; - } - - // If this is an array, we need to walk over all the object's elements. - if (Array.isArray(o)) { - - // Map each of the array elements. - return o.map((ob) => wrap(ob, d + 1)); - - } else if (o && o.constructor && o.constructor.name === 'model') { - - // The object here is definitly a mongoose model. - - // Check to see if it has a `filterForUser` method. - if (typeof o.filterForUser === 'function') { - - // The object here actually has the `filterForUser` function, so filter - // the object! - o = o.filterForUser(req.user); - } - - } else if (typeof o === 'object') { - - // Iterate over the props, find only properties owned by the object. - for (let prop in o) { - - // If and only if the object owns the property do we actually pull the - // property out. - if (typeof o.hasOwnProperty === 'function' && o.hasOwnProperty(prop)) { - - // Wrap the property with one more layer down. - o[prop] = wrap(o[prop], d + 1); - } - } - - } - - return o; - }; - - // Save a reference to the original json function. - const json = res.json; - - // Override the original json function. - res.json = (payload) => { - - // Restore the old pointer. - res.json = json; - - // Send it down the pipe after we've filtered it. - res.json(wrap(payload)); - }; - - // Now that we've overridden the `res.json`, let's hand it down. - next(); -}; diff --git a/models/SETTINGS_ROADMAP.md b/models/SETTINGS_ROADMAP.md deleted file mode 100644 index b49a0f8a9..000000000 --- a/models/SETTINGS_ROADMAP.md +++ /dev/null @@ -1,30 +0,0 @@ -These are some settings we're planning on implementing in the future. -I'm keeping them in this file for reference - -```javascript -anonymous_users: {type: Boolean, default: false}, -block_mute_enabled: {type: Boolean, default: false}, -comment_count: {type: Boolean, default: false}, -comment_editing_enabled: {type: Boolean, default: false}, -comments_hidden: {type: Boolean, default: false}, -community_guidelines: {type: Boolean, default: false}, -detailed_flags: {type: Boolean, default: false}, -emojis_enabled: {type: Boolean, default: false}, -following: {type: Boolean, default: false}, -likes_enabled: {type: Boolean, default: false}, -mentions: {type: Boolean, default: false}, -nested_replies: {type: Boolean, default: false}, -notification_timeout: {type: Number, default: 4500}, -permalinks: {type: Boolean, default: false}, -post_button_text: {type: String, default: 'Post'}, -pseudonyms: {type: Boolean, default: false}, -public_profile: {type: Boolean, default: false}, -reactions_enabled: {type: Boolean, default: false}, -reply_button_text: {type: String, default: 'Reply'}, -rich_content: {type: Boolean, default: false}, -show_staff_picks: {type: Boolean, default: false}, -up_down_voting: {type: Boolean, default: false}, -user_badges: {type: Boolean, default: false}, -user_mods_enabled: {type: Boolean, default: false}, -user_stats_enabled: {type: Boolean, default: false} -``` diff --git a/models/action.js b/models/action.js index 8e6634072..ed871f56b 100644 --- a/models/action.js +++ b/models/action.js @@ -1,16 +1,32 @@ const mongoose = require('../services/mongoose'); const uuid = require('uuid'); -const _ = require('lodash'); const Schema = mongoose.Schema; +const ACTION_TYPES = [ + 'LIKE', + 'FLAG' +]; + +const ITEM_TYPES = [ + 'ASSETS', + 'COMMENTS', + 'USERS' +]; + const ActionSchema = new Schema({ id: { type: String, default: uuid.v4, unique: true }, - action_type: String, - item_type: String, + action_type: { + type: String, + enum: ACTION_TYPES + }, + item_type: { + type: String, + enum: ITEM_TYPES + }, item_id: String, user_id: String, metadata: Schema.Types.Mixed @@ -21,179 +37,6 @@ const ActionSchema = new Schema({ } }); -/** - * Finds an action by the id. - * @param {String} id identifier of the action (uuid) -*/ -ActionSchema.statics.findById = function(id) { - return Action.findOne({id}); -}; - -/** - * Add an action. - * @param {String} item_id identifier of the comment (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the comment - * @return {Promise} - */ -ActionSchema.statics.insertUserAction = (action) => { - - // Actions are made unique by using a query that can be reproducable, i.e., - // not containing user inputable values. - let query = { - action_type: action.action_type, - item_type: action.item_type, - item_id: action.item_id, - user_id: action.user_id - }; - - // Create/Update the action. - return Action.findOneAndUpdate(query, action, { - - // Ensure that if it's new, we return the new object created. - new: true, - - // Perform an upsert in the event that this doesn't exist. - upsert: true, - - // Set the default values if not provided based on the mongoose models. - setDefaultsOnInsert: true - }); -}; - -/** - * Finds actions in an array of ids. - * @param {String} ids array of user identifiers (uuid) -*/ -ActionSchema.statics.findByItemIdArray = function(item_ids) { - return Action.find({ - 'item_id': {$in: item_ids} - }); -}; - -/** - * Fetches the action summaries for the given asset, and comments around the - * given user id. - * @param {[type]} asset_id [description] - * @param {[type]} comments [description] - * @param {String} [current_user_id=''] [description] - * @return {[type]} [description] - */ -ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => { - - // Get the user id's from the author id's as a unique array that gets - // sorted. - let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); - - // Fetch the actions for pretty much everything at this point. - return Action.getActionSummaries(_.uniq([ - - // Actions can be on assets... - asset_id, - - // Comments... - ...comments.map((comment) => comment.id), - - // Or Authors... - ...userIDs - ].filter((e) => e)), current_user_id); -}; - -/** - * Returns summaries of actions for an array of ids - * @param {String} ids array of user identifiers (uuid) -*/ -ActionSchema.statics.getActionSummaries = function(item_ids, current_user_id = '') { - return Action.aggregate([ - { - - // only grab items that match the specified item id's - $match: { - item_id: { - $in: item_ids - } - } - }, - { - $group: { - - // group unique documents by these properties, we are leveraging the - // fact that each uuid is completly unique. - _id: { - item_id: '$item_id', - action_type: '$action_type' - }, - - // and sum up all actions matching the above grouping criteria - count: { - $sum: 1 - }, - - // we are leveraging the fact that each uuid is completly unique and - // just grabbing the last instance of the item type here. - item_type: { - $last: '$item_type' - }, - - current_user: { - $max: { - $cond: { - if: { - $eq: ['$user_id', current_user_id], - }, - then: '$$CURRENT', - else: null - } - } - } - } - }, - { - $project: { - - // suppress the _id field - _id: false, - - // map the fields from the _id grouping down a level - item_id: '$_id.item_id', - action_type: '$_id.action_type', - - // map the field directly - count: '$count', - item_type: '$item_type', - - // set the current user to false here - current_user: '$current_user' - } - } - ]) - .exec(); -}; - -/* - * Finds all comments for a specific action. - * @param {String} action_type type of action - * @param {String} item_type type of item the action is on -*/ -ActionSchema.statics.findByType = function(action_type, item_type) { - return Action.find({ - 'action_type': action_type, - 'item_type': item_type - }); -}; - -/** - * Finds all comments ids for a specific action. - * @param {String} action_type type of action - * @param {String} item_type type of item the action is on -*/ -ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_type) { - return Action.find({ - 'action_type': action_type, - 'item_type': item_type - }, 'item_id'); -}; - const Action = mongoose.model('Action', ActionSchema); module.exports = Action; diff --git a/models/asset.js b/models/asset.js index ac87e006f..f0046aef0 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,8 +1,5 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; - -const Setting = require('./setting'); - const uuid = require('uuid'); const AssetSchema = new Schema({ @@ -68,18 +65,6 @@ AssetSchema.index({ background: true }); -/** - * Finds an asset by its id. - * @param {String} id identifier of the asset (uuid). - */ -AssetSchema.statics.findById = (id) => Asset.findOne({id}); - -/** - * Finds a asset by its url. - * @param {String} url identifier of the asset (uuid). - */ -AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); - /** * Returns true if the asset is closed, false else. */ @@ -87,85 +72,6 @@ AssetSchema.virtual('isClosed').get(function() { return this.closedAt && this.closedAt.getTime() <= new Date().getTime(); }); -/** - * Retrieves the settings given an asset query and rectifies it against the - * global settings. - * @param {Promise} assetQuery an asset query that returns a single asset. - * @return {Promise} - */ -AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ - Setting.retrieve(), - assetQuery -]).then(([settings, asset]) => { - - // If the asset exists and has settings then return the merged object. - if (asset && asset.settings) { - settings.merge(asset.settings); - } - - return settings; -}); - -/** - * Finds a asset by its url. - * - * NOTE: This function has scalability concerns regarding mongoose's decision - * always write {updated_at: new Date()} on every call to findOneAndUpdate - * even though the update document exactly matches the query document... In - * the future this function should never update, only findOneAndCreate but this - * is not possible with the mongoose driver. - * - * @param {String} url identifier of the asset (uuid). - * @return {Promise} - */ -AssetSchema.statics.findOrCreateByUrl = (url) => Asset.findOneAndUpdate({url}, {url}, { - - // Ensure that if it's new, we return the new object created. - new: true, - - // Perform an upsert in the event that this doesn't exist. - upsert: true, - - // Set the default values if not provided based on the mongoose models. - setDefaultsOnInsert: true -}); - -/** - * Updates the settings for the asset. - * @param {[type]} id [description] - * @param {[type]} settings [description] - * @return {[type]} [description] - */ -AssetSchema.statics.overrideSettings = (id, settings) => Asset.findOneAndUpdate({id}, { - $set: { - settings - } -}, { - new: true -}); - -/** - * Finds assets matching keywords on the model. If `value` is an empty string, - * then it will not even perform a text search query. - * @param {String} value string to search by. - * @return {Promise} - */ -AssetSchema.statics.search = (value) => value.length === 0 ? Asset.find({}) : Asset.find({ - $text: { - $search: value - } -}); - -/** - * Finds multiple assets with matching ids - * @param {Array} ids an array of Strings of asset_id - * @return {Promise} resolves to list of Assets - */ -AssetSchema.statics.findMultipleById = function (ids) { - const query = ids.map(id => ({id})); - return Asset.find(query); -}; - const Asset = mongoose.model('Asset', AssetSchema); module.exports = Asset; diff --git a/models/comment.js b/models/comment.js index 99eaec069..b656f9238 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,8 +1,13 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; -const _ = require('lodash'); const uuid = require('uuid'); -const Action = require('./action'); + +const STATUSES = [ + 'ACCEPTED', + 'REJECTED', + 'PREMOD', + null +]; /** * The Mongo schema for a Comment Status. @@ -11,11 +16,7 @@ const Action = require('./action'); const StatusSchema = new Schema({ type: { type: String, - enum: [ - 'accepted', - 'rejected', - 'premod', - ], + enum: STATUSES, }, // The User ID of the user that assigned the status. @@ -56,228 +57,6 @@ const CommentSchema = new Schema({ } }); -/** - * toJSON overrides to remove fields from the json - * output. - */ -CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.virtuals = true; -CommentSchema.options.toJSON.hide = '_id'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * Filters the object for the given user only allowing those with the allowed - * roles/permissions to access particular parameters. - */ -CommentSchema.method('filterForUser', function(user = false) { - if (!user || !user.roles.includes('admin')) { - return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']); - } - - return this.toJSON(); -}); - -/** - * Creates a new Comment that came from a public source. - * @param {Mixed} comment either a single comment or an array of comments. - * @return {Promise} - */ -CommentSchema.statics.publicCreate = (comment) => { - - // Check to see if this is an array of comments, if so map it out. - if (Array.isArray(comment)) { - return Promise.all(comment.map(Comment.publicCreate)); - } - - const { - body, - asset_id, - parent_id, - status = null, - author_id - } = comment; - - comment = new Comment({ - body, - asset_id, - parent_id, - status_history: status ? [{ - type: status, - created_at: new Date() - }] : [], - status, - author_id - }); - - return comment.save(); -}; - -/** - * Finds a comment by the id. - * @param {String} id identifier of comment (uuid) - * @return {Promise} - */ -CommentSchema.statics.findById = (id) => Comment.findOne({id}); - -/** - * Finds ALL the comments by the asset_id. - * @param {String} asset_id identifier of the asset which owns this comment (uuid) - * @return {Promise} - */ -CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ - asset_id -}); - -/** - * findByAssetIdWithStatuses finds all the comments where the asset id matches - * what's provided and the status is one of the ones listed in the statuses - * array. - * @param {String} asset_id the asset id to search by - * @param {Array} [statuses=[]] the array of statuses to search by - * @return {Promise} resolves to an array of comments - */ -CommentSchema.statics.findByAssetIdWithStatuses = (asset_id, statuses = []) => Comment.find({ - asset_id, - status: { - $in: statuses - } -}); - -/** - * Find comments by an action that was performed on them. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ -CommentSchema.statics.findByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => Comment.find({ - id: { - $in: actions.map((a) => a.item_id) - } - })); - -/** - * Find comment id's where the action type matches the argument. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ -CommentSchema.statics.findIdsByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comments') - .then((actions) => actions.map(a => a.item_id)); - -/** - * Find comments by current status - * @param {String} status status of the comment to search for - * @return {Promise} resovles to comment array - */ -CommentSchema.statics.findByStatus = (status = null) => { - return Comment.find({status}); -}; - -/** - * Find comments that need to be moderated (aka moderation queue). - * @param {String} asset_id - * @return {Promise} - */ -CommentSchema.statics.moderationQueue = (status, asset_id = null) => { - - // Fetch the comments with statuses. - let comments = Comment.findByStatus(status); - - if (asset_id) { - comments = comments.where('asset_id', asset_id); - } - - return comments; -}; - -/** - * Pushes a new status in for the user. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment - * @param {String} assigned_by the user id for the user who performed the - * moderation action - * @return {Promise} - */ -CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { - $push: { - status_history: { - type: status, - created_at: new Date(), - assigned_by - } - }, - $set: {status} -}); - -/** - * Add an action to the comment. - * @param {String} item_id identifier of the comment (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the comment - * @return {Promise} - */ -CommentSchema.statics.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({ - item_id, - item_type: 'comments', - user_id, - action_type, - metadata -}); - -/** - * Change the status of a comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment - * @return {Promise} - */ -CommentSchema.statics.removeById = (id) => Comment.remove({id}); - -/** - * Remove an action from the comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} action_type the type of the action to be removed - * @param {String} user_id the id of the user performing the action - * @return {Promise} - */ -CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({ - action_type, - item_type: 'comment', - item_id, - user_id -}); - -/** - * Returns all the comments in the collection. - * @return {Promise} - */ -CommentSchema.statics.all = () => Comment.find(); - -/** - * Returns all the comments by user - * probably to be paginated at some point in the future - * @return {Promise} array resolves to an array of comments by that user - */ -CommentSchema.statics.findByUserId = function (author_id, admin = false) { - - // do not return un-published comments for non-admins - let query = {author_id}; - - if (!admin) { - query.$nor = [{status: 'premod'}, {status: 'rejected'}]; - } - - return Comment.find(query); -}; - // Comment model. const Comment = mongoose.model('Comment', CommentSchema); diff --git a/models/setting.js b/models/setting.js index ed41acfa6..3d478b2d9 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,6 +1,5 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; -const _ = require('lodash'); const WordlistSchema = new Schema({ banned: [String], @@ -21,10 +20,10 @@ const SettingSchema = new Schema({ moderation: { type: String, enum: [ - 'pre', - 'post' + 'PRE', + 'POST' ], - default: 'pre' + default: 'PRE' }, infoBoxEnable: { type: Boolean, @@ -64,12 +63,6 @@ const SettingSchema = new Schema({ } }); -/** - * toJSON provides settings overrides to this object's serialization methods. - */ -SettingSchema.options.toJSON = {}; -SettingSchema.options.toJSON.virtuals = true; - /** * Merges two settings objects. */ @@ -88,72 +81,9 @@ SettingSchema.method('merge', function(src) { }); }); -/** - * Filters the object for the given user only allowing those with the allowed - * roles/permissions to access particular parameters. - */ -SettingSchema.method('filterForUser', function(user = false) { - if (!user || !user.roles.includes('admin')) { - return _.pick(this.toJSON(), [ - 'moderation', - 'infoBoxEnable', - 'infoBoxContent', - 'closeTimeout', - 'closedMessage', - 'charCountEnable', - 'charCount', - 'requireEmailConfirmation' - ]); - } - - return this.toJSON(); -}); - /** * The Mongo Mongoose object. */ const Setting = mongoose.model('Setting', SettingSchema); -/** - * The Setting Service object exposing the Setting model. - * @type {Object} - */ -const SettingService = module.exports = {}; - -/** - * The selector used to uniquely identify the settings document. - */ -const selector = {id: '1'}; - -/** - * Gets the entire settings record and sends it back - * @return {Promise} settings the whole settings record - */ -SettingService.retrieve = () => Setting.findOne(selector); - -/** - * This will update the settings object with whatever you pass in - * @param {object} setting a hash of whatever settings you want to update - * @return {Promise} settings Promise that resolves to the entire (updated) settings object. - */ -SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { - $set: settings -}, { - upsert: true, - new: true, - setDefaultsOnInsert: true -}); - -/** - * This is run once when the app starts to ensure settings are populated. - * @return {Promise} null initialize the global settings object - */ -SettingService.init = (defaults) => { - - // Inject the defaults on top of the passed in defaults to ensure that the new - // settings conform to the required selector. - defaults = Object.assign({}, defaults, selector); - - // Actually update the settings collection. - return SettingService.update(defaults); -}; +module.exports = Setting; diff --git a/models/user.js b/models/user.js index a0ca34d7a..9c447fa5d 100644 --- a/models/user.js +++ b/models/user.js @@ -1,40 +1,18 @@ const mongoose = require('../services/mongoose'); const uuid = require('uuid'); -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. -const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ - 'admin', - 'moderator' + 'ADMIN', + 'MODERATOR' ]; -// USER_STATUSES is the list of statuses that are permitted for the user status. +// USER_STATUS is the list of statuses that are permitted for the user status. const USER_STATUS = [ - 'active', - 'banned' + 'ACTIVE', + 'BANNED' ]; -// In the event that the TALK_SESSION_SECRET is missing but we are testing, then -// set the process.env.TALK_SESSION_SECRET. -if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { - process.env.TALK_SESSION_SECRET = 'keyboard cat'; -} else if (!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({ @@ -103,11 +81,18 @@ const UserSchema = new mongoose.Schema({ // Roles provides an array of roles (as strings) that is associated with a // user. - roles: [String], + roles: [{ + type: String, + enum: USER_ROLES + }], // Status provides a string that says in which state the account is. // When the account is banned, the user login is disabled. - status: {type: String, enum: USER_STATUS, default: 'active'}, + status: { + type: String, + enum: USER_STATUS, + default: 'ACTIVE' + }, // User's settings settings: { @@ -135,619 +120,46 @@ UserSchema.index({ }); /** - * toJSON overrides to remove the password field from the json - * output. + * Returns true if the user has all the roles specified. */ -UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = '_id password'; -UserSchema.options.toJSON.virtuals = true; -UserSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } +UserSchema.method('hasRoles', function(...roles) { + return roles.every((role) => { - return ret; -}; + // TODO: remove toUpperCase() once we've migrated usage. + return this.roles.indexOf(role.toUpperCase()) >= 0; + }); +}); /** - * Filters the object for the given user only allowing those with the allowed - * roles/permissions to access particular parameters. + * All the graph operations that are available for a user. + * @type {Array} */ -UserSchema.method('filterForUser', function(user = false) { - if (!user || !user.roles.includes('admin')) { - let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at']; +const USER_GRAPH_OPERATIONS = [ + 'mutation:createComment', + 'mutation:createAction', + 'mutation:deleteAction', + 'mutation:updateUserSettings' +]; - if (user && user.id === this.id) { - allowed.push('roles'); - } - - return _.pick(this.toJSON(), allowed); +/** + * Can returns true if the user is allowed to perform a specific graph + * operation. + */ +UserSchema.method('can', function(...actions) { + if (actions.some((action) => USER_GRAPH_OPERATIONS.indexOf(action) === -1)) { + throw new Error(`invalid actions: ${actions}`); } - return this.toJSON(); + if (this.status === 'BANNED') { + return false; + } + + return true; }); // Create the User model. const UserModel = mongoose.model('User', UserSchema); -// UserService is the interface for the application to interact with the -// UserModel through. -const UserService = module.exports = {}; - -/** - * Finds a user given their email address that we have for them in the system - * and ensures that the retuned user matches the password passed in as well. - * @param {string} email - email to look up the user by - * @param {string} password - password to match against the found user - * @param {Function} done [description] - */ -UserService.findLocalUser = (email, password) => { - if (!email || typeof email !== 'string') { - return Promise.reject('email is required for findLocalUser'); - } - - return UserModel.findOne({ - profiles: { - $elemMatch: { - id: email.toLowerCase(), - provider: 'local' - } - } - }) - .then((user) => { - if (!user) { - return false; - } - - return new Promise((resolve, reject) => { - bcrypt.compare(password, user.password, (err, res) => { - if (err) { - return reject(err); - } - - if (!res) { - return resolve(false); - } - - return resolve(user); - }); - }); - }); -}; - -/** - * Merges two users together by taking all the profiles on a given user and - * pushing them into the source user followed by deleting the destination user's - * user account. This will not merge the roles associated with the source user. - * @param {String} dstUserID id of the user to which is the target of the merge - * @param {String} srcUserID id of the user to which is the source of the merge - * @return {Promise} resolves when the users are merged - */ -UserService.mergeUsers = (dstUserID, srcUserID) => { - let srcUser, dstUser; - - return Promise - .all([ - UserModel.findOne({id: dstUserID}).exec(), - UserModel.findOne({id: srcUserID}).exec() - ]) - .then((users) => { - dstUser = users[0]; - srcUser = users[1]; - - srcUser.profiles.forEach((profile) => { - dstUser.profiles.push(profile); - }); - - return srcUser.remove(); - }) - .then(() => dstUser.save()); -}; - -/** - * Finds a user given a social profile and if the user does not exist, creates - * them. - * @param {Object} profile - User social/external profile - * @param {Function} done [description] - */ -UserService.findOrCreateExternalUser = (profile) => { - return UserModel - .findOne({ - profiles: { - $elemMatch: { - id: profile.id, - provider: profile.provider - } - } - }) - .then((user) => { - if (user) { - return user; - } - - // The user was not found, lets create them! - user = new UserModel({ - displayName: profile.displayName, - roles: [], - profiles: [ - { - id: profile.id, - provider: profile.provider - } - ] - }); - - return user.save(); - }); -}; - -UserService.changePassword = (id, password) => { - return new Promise((resolve, reject) => { - bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { - if (err) { - return reject(err); - } - - resolve(hashedPassword); - }); - }) - .then((hashedPassword) => { - return UserModel.update({id}, { - $inc: {__v: 1}, - $set: { - password: hashedPassword - } - }); - }); -}; - -/** - * Creates local users. - * @param {Array} users Users to create - * @return {Promise} Resolves with the users that were created - */ -UserService.createLocalUsers = (users) => { - return Promise.all(users.map((user) => { - return UserService - .createLocalUser(user.email, user.password, user.displayName); - })); -}; - -/** - * 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 - * @param {String} password plaintext password of the new user - * @param {String} displayName name of the display user - * @param {Function} done callback - */ -UserService.createLocalUser = (email, password, displayName) => { - - if (!email) { - return Promise.reject(errors.ErrMissingEmail); - } - - email = email.toLowerCase().trim(); - displayName = displayName.toLowerCase().trim(); - - if (!password) { - return Promise.reject(errors.ErrMissingPassword); - } - - if (password.length < 8) { - return Promise.reject(errors.ErrPasswordTooShort); - } - - return isValidDisplayName(displayName) - .then(() => { // displayName is valid - 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' - } - ] - }); - - user.save((err) => { - if (err) { - if (err.code === 11000) { - if (err.message.match('displayName')) { - return reject(errors.ErrDisplayTaken); - } - return reject(errors.ErrEmailTaken); - } - return reject(err); - } - return resolve(user); - }); - }); - }); - }); -}; - -/** - * Disables a given user account. - * @param {String} id id of a user - * @param {Function} done callback after the operation is complete - */ -UserService.disableUser = (id) => { - return UserModel.update({ - id: id - }, { - $set: { - disabled: true - } - }); -}; - -/** - * Enables a given user account. - * @param {String} id id of a user - * @param {Function} done callback after the operation is complete - */ -UserService.enableUser = (id) => { - return UserModel.update({ - id: id - }, { - $set: { - disabled: false - } - }); -}; - -/** - * Adds a role to a user. - * @param {String} id id of a user - * @param {String} role role to add - * @param {Function} done callback after the operation is complete - */ -UserService.addRoleToUser = (id, role) => { - - // Check to see if the user role is in the allowable set of roles. - if (USER_ROLES.indexOf(role) === -1) { - - // User role is not supported! Error out here. - return Promise.reject(new Error(`role ${role} is not supported`)); - } - - return UserModel.update({ - id: id - }, { - $addToSet: { - roles: role - } - }); -}; - -/** - * Removes a role from a user. - * @param {String} id id of a user - * @param {String} role role to remove - * @param {Function} done callback after the operation is complete - */ -UserService.removeRoleFromUser = (id, role) => { - return UserModel.update({ - id: id - }, { - $pull: { - roles: role - } - }); -}; - -/** - * Set status of a user. - * @param {String} id id of a user - * @param {String} status status to set - * @param {String} comment_id id of the comment that the user was ban for. - * @param {Function} done callback after the operation is complete - */ -UserService.setStatus = (id, status, comment_id) => { - - // Check to see if the user status is in the allowable set of roles. - if (USER_STATUS.indexOf(status) === -1) { - - // User status is not supported! Error out here. - return Promise.reject(new Error(`status ${status} is not supported`)); - } - - // If ban then reject the comment and update status - if (status === 'banned') { - return UserModel.update({ - id: id - }, { - $set: { - status: status - } - }) - .then(() => { - return Comment.pushStatus(comment_id, 'rejected', id); - }); - } - - if (status === 'active') { - return UserModel.update({ - id: id - }, { - $set: { - status: status - } - }); - } -}; - -/** - * Finds a user with the id. - * @param {String} id user id (uuid) -*/ -UserService.findById = (id) => { - return UserModel.findOne({id}); -}; - -/** - * Finds users in an array of ids. - * @param {Array} ids array of user identifiers (uuid) -*/ -UserService.findByIdArray = (ids) => { - return UserModel.find({ - 'id': {$in: ids} - }); -}; - -/** - * Finds public user information by an array of ids. - * @param {Array} ids array of user identifiers (uuid) -*/ -UserService.findPublicByIdArray = (ids) => { - return UserModel.find({ - 'id': {$in: ids} - }, 'id displayName'); -}; - -/** - * Creates a JWT from a user email. Only works for local accounts. - * @param {String} email of the local user - */ -UserService.createPasswordResetToken = function (email) { - if (!email || typeof email !== 'string') { - return Promise.reject('email is required when creating a JWT for resetting passord'); - } - - email = email.toLowerCase(); - - return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) - .then((user) => { - if (!user) { - - // 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 = { - jti: uuid.v4(), - email, - userId: user.id, - version: user.__v - }; - - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { - algorithm: 'HS256', - expiresIn: '1d', - subject: PASSWORD_RESET_JWT_SUBJECT - }); - }); -}; - -/** - * Verifies that the token was indeed signed by the session secret. - * @param {String} token JWT token from the client - * @return {Promise} - */ -UserService.verifyToken = (token, options = {}) => { - return new Promise((resolve, reject) => { - - // 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); - }); - }); -}; - -/** - * 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. - * @param {String} value value to search by - * @return {Promise} - */ -UserService.search = (value) => { - return UserModel.find({ - $or: [ - - // Search by a prefix match on the displayName. - { - 'displayName': { - $regex: new RegExp(`^${value}`), - $options: 'i' - } - }, - - // Search by a prefix match on the email address. - { - 'profiles': { - $elemMatch: { - id: { - $regex: new RegExp(`^${value}`), - $options: 'i' - }, - provider: 'local' - } - } - } - ] - }); -}; - -/** - * Returns a count of the current users. - * @return {Promise} - */ -UserService.count = () => UserModel.count(); - -/** - * Returns all the users. - * @return {Promise} - */ -UserService.all = () => UserModel.find(); - -/** - * Updates the user's settings. - * @return {Promise} - */ -UserService.updateSettings = (id, settings) => UserModel.update({ - id -}, { - $set: { - settings - } -}); - -/** - * Add an action to the user. - * @param {String} item_id identifier of the user (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the user - * @return {Promise} - */ -UserService.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({ - item_id, - item_type: 'users', - user_id, - 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() - } - }); - }); -}; +module.exports = UserModel; +module.exports.USER_ROLES = USER_ROLES; +module.exports.USER_STATUS = USER_STATUS; diff --git a/package.json b/package.json index 26cd2d5d4..41b11e848 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint bin/* . --fix", - "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", + "test": "TEST_MODE=unit NODE_ENV=test mocha -R ${NPM_PACKAGE_CONFIG_MOCHA_REPORTER:-spec}", + "test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec", "pree2e": "NODE_ENV=test scripts/pree2e.sh", "e2e": "NODE_ENV=test nightwatch", "poste2e": "NODE_ENV=test scripts/poste2e.sh", @@ -49,18 +49,24 @@ }, "homepage": "https://github.com/coralproject/talk#readme", "dependencies": { + "apollo-client": "^0.7.3", "bcrypt": "^0.8.7", "body-parser": "^1.15.2", "cli-table": "^0.3.1", "commander": "^2.9.0", "connect-redis": "^3.1.0", "csurf": "^1.9.0", + "dataloader": "^1.2.0", "debug": "^2.2.0", "dotenv": "^4.0.0", "ejs": "^2.5.2", "env-rewrite": "^1.0.2", "express": "^4.14.0", "express-session": "^1.14.2", + "graphql": "^0.8.2", + "graphql-server-express": "^0.5.0", + "graphql-tag": "^1.2.3", + "graphql-tools": "^0.9.0", "helmet": "^3.1.0", "jsonwebtoken": "^7.1.9", "kue": "^0.11.5", @@ -75,6 +81,7 @@ "passport-facebook": "^2.1.1", "passport-local": "^1.0.0", "prompt": "^1.0.0", + "react-apollo": "^0.8.1", "redis": "^2.6.3", "uuid": "^2.0.3" }, @@ -116,6 +123,7 @@ "ignore-styles": "^5.0.1", "immutable": "^3.8.1", "imports-loader": "^0.6.5", + "istanbul": "^1.1.0-alpha.1", "jsdom": "^9.8.3", "json-loader": "^0.5.4", "keymaster": "^1.6.2", diff --git a/routes/admin/index.js b/routes/admin/index.js index 6241dcedb..2a37e8f0f 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -3,13 +3,17 @@ const router = express.Router(); // Get /password-reset expects a signed token (JWT) in the hash. // Links to this endpoint are generated by /views/password-reset-email.ejs. -router.get('/password-reset', (req, res, next) => { +router.get('/password-reset', (req, res) => { // TODO: store the redirect uri in the token or something fancy. // admins and regular users should probably be redirected to different places. res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL}); }); +router.get('/login', (req, res, next) => { + res.render('admin/login'); +}); + router.get('*', (req, res) => { res.render('admin', {basePath: '/client/coral-admin'}); }); diff --git a/routes/api/account/index.js b/routes/api/account/index.js index 3f699a51d..57647a89d 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const User = require('../../../models/user'); +const UsersService = require('../../../services/users'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); const errors = require('../../../errors'); @@ -26,7 +26,7 @@ router.post('/email/confirm', (req, res, next) => { return next(errors.ErrMissingToken); } - User + UsersService .verifyEmailConfirmation(token) .then(() => { res.status(204).end(); @@ -47,7 +47,7 @@ router.post('/password/reset', (req, res, next) => { return next('you must submit an email when requesting a password.'); } - User + UsersService .createPasswordResetToken(email) .then((token) => { @@ -100,9 +100,10 @@ router.put('/password/reset', (req, res, next) => { return next(errors.ErrPasswordTooShort); } - User.verifyPasswordResetToken(token) + UsersService + .verifyPasswordResetToken(token) .then(user => { - return User.changePassword(user.id, password); + return UsersService.changePassword(user.id, password); }) .then(() => { res.status(204).end(); @@ -120,7 +121,7 @@ router.put('/settings', authorization.needed(), (req, res, next) => { bio } = req.body; - User + UsersService .updateSettings(req.user.id, {bio}) .then(() => { res.status(204).end(); diff --git a/routes/api/actions/index.js b/routes/api/actions/index.js index 9724f5a73..20316d93b 100644 --- a/routes/api/actions/index.js +++ b/routes/api/actions/index.js @@ -1,10 +1,10 @@ const express = require('express'); -const Action = require('../../../models/action'); +const ActionsService = require('../../../services/actions'); const router = express.Router(); router.delete('/:action_id', (req, res, next) => { - Action + ActionsService .findOneAndRemove({ id: req.params.action_id, user_id: req.user.id diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index f5a4afe44..0b6762de8 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -1,8 +1,10 @@ const express = require('express'); const router = express.Router(); -const Asset = require('../../../models/asset'); const scraper = require('../../../services/scraper'); +const AssetsService = require('../../../services/assets'); + +const AssetModel = require('../../../models/asset'); // List assets. router.get('/', (req, res, next) => { @@ -46,13 +48,13 @@ router.get('/', (req, res, next) => { Promise.all([ // Find the actuall assets. - FilterOpenAssets(Asset.search(search), filter) + FilterOpenAssets(AssetsService.search(search), filter) .sort({[field]: (sort === 'asc') ? 1 : -1}) .skip(parseInt(skip)) .limit(parseInt(limit)), // Get the count of actual assets. - FilterOpenAssets(Asset.search(search), filter) + FilterOpenAssets(AssetsService.search(search), filter) .count() ]) .then(([result, count]) => { @@ -73,7 +75,7 @@ router.get('/', (req, res, next) => { router.get('/:asset_id', (req, res, next) => { // Send back the asset. - Asset + AssetsService .findById(req.params.asset_id) .then((asset) => { if (!asset) { @@ -91,7 +93,7 @@ router.get('/:asset_id', (req, res, next) => { router.post('/:asset_id/scrape', (req, res, next) => { // Create a new asset scrape job. - Asset + AssetsService .findById(req.params.asset_id) .then((asset) => { if (!asset) { @@ -113,7 +115,7 @@ router.post('/:asset_id/scrape', (req, res, next) => { router.put('/:asset_id/settings', (req, res, next) => { // Override the settings for the asset. - Asset + AssetsService .overrideSettings(req.params.asset_id, req.body) .then(() => res.status(204).end()) .catch((err) => next(err)); @@ -128,7 +130,7 @@ router.put('/:asset_id/status', (req, res, next) => { closedMessage } = req.body; - Asset + AssetModel .update({id}, { $set: { closedAt, diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 1d5d73ea0..19346274d 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,10 +1,12 @@ const express = require('express'); const errors = require('../../../errors'); -const Comment = require('../../../models/comment'); -const Asset = require('../../../models/asset'); -const User = require('../../../models/user'); -const Action = require('../../../models/action'); -const wordlist = require('../../../services/wordlist'); + +const CommentModel = require('../../../models/comment'); +const CommentsService = require('../../../services/comments'); +const AssetsService = require('../../../services/assets'); +const UsersService = require('../../../services/users'); +const ActionsService = require('../../../services/actions'); + const authorization = require('../../../middleware/authorization'); const _ = require('lodash'); @@ -20,13 +22,13 @@ router.get('/', (req, res, next) => { } = req.query; // everything on this route requires admin privileges besides listing comments for owner of said comments - if (!authorization.has(req.user, 'admin') && !user_id) { + if (!authorization.has(req.user, 'ADMIN') && !user_id) { next(errors.ErrNotAuthorized); return; } // if the user is not an admin, only return comment list for the owner of the comments - if (req.user.id !== user_id && !authorization.has(req.user, 'admin')) { + if (req.user.id !== user_id && !authorization.has(req.user, 'ADMIN')) { next(errors.ErrNotAuthorized); return; } @@ -48,27 +50,27 @@ router.get('/', (req, res, next) => { // otherwise this will be a vulnerability if you pass user_id and something else, // the app will return admin-level data without the proper checks if (user_id) { - query = Comment.findByUserId(user_id, authorization.has(req.user, 'admin')); + query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN')); } else if (status) { - query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status)); + query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status)); } else if (action_type) { - query = Comment + query = CommentsService .findIdsByActionType(action_type) - .then((ids) => assetIDWrap(Comment.find({ + .then((ids) => assetIDWrap(CommentModel.find({ id: { $in: ids } }))); } else { - query = assetIDWrap(Comment.all()); + query = assetIDWrap(CommentsService.all()); } query.then((comments) => { return Promise.all([ comments, - Asset.findMultipleById(comments.map(comment => comment.asset_id)), - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false) + AssetsService.findMultipleById(comments.map(comment => comment.asset_id)), + UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + ActionsService.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false) ]); }) .then(([comments, assets, users, actions]) => @@ -83,79 +85,8 @@ router.get('/', (req, res, next) => { }); }); -router.post('/', wordlist.filter('body'), (req, res, next) => { - - const { - body, - asset_id, - parent_id - } = req.body; - - // Decide the status based on whether or not the current asset/settings - // has pre-mod enabled or not. If the comment was rejected based on the - // wordlist, then reject it, otherwise if the moderation setting is - // premod, set it to `premod`. - let status; - - if (req.wordlist.banned) { - status = Promise.resolve('rejected'); - } else { - status = Asset - .rectifySettings(Asset.findById(asset_id).then((asset) => { - if (!asset) { - return Promise.reject(errors.ErrNotFound); - } - - // Check to see if the asset has closed commenting... - if (asset.isClosed) { - - // They have, ensure that we send back an error. - return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage)); - } - - return asset; - })) - - // Return `premod` if pre-moderation is enabled and an empty "new" status - // in the event that it is not in pre-moderation mode. - .then(({moderation, charCountEnable, charCount}) => { - - // Reject if the comment is too long - if (charCountEnable && body.length > charCount) { - return 'rejected'; - } - return moderation === 'pre' ? 'premod' : null; - }); - } - - status.then((status) => Comment.publicCreate({ - body, - asset_id, - parent_id, - status, - author_id: req.user.id - })) - .then((comment) => { - if (req.wordlist.suspect) { - return Comment - .addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'}) - .then(() => comment); - } - - return comment; - }) - .then((comment) => { - - // The comment was created! Send back the created comment. - res.status(201).json(comment); - }) - .catch((err) => { - next(err); - }); -}); - -router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { - Comment +router.get('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => { + CommentsService .findById(req.params.comment_id) .then(comment => { if (!comment) { @@ -170,8 +101,8 @@ router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { }); }); -router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => { - Comment +router.delete('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => { + CommentsService .removeById(req.params.comment_id) .then(() => { res.status(204).end(); @@ -181,12 +112,12 @@ router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => }); }); -router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next) => { +router.put('/:comment_id/status', authorization.needed('ADMIN'), (req, res, next) => { const { status } = req.body; - Comment + CommentsService .pushStatus(req.params.comment_id, status, req.user.id) .then(() => { res.status(204).end(); @@ -203,7 +134,7 @@ router.post('/:comment_id/actions', (req, res, next) => { metadata } = req.body; - Comment + CommentsService .addAction(req.params.comment_id, req.user.id, action_type, metadata) .then((action) => { res.status(201).json(action); diff --git a/routes/api/index.js b/routes/api/index.js index 120fd47ec..c40c428b4 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,25 +1,20 @@ const express = require('express'); const authorization = require('../../middleware/authorization'); -const payloadFilter = require('../../middleware/payload-filter'); const router = express.Router(); -// Filter all content going down the pipe based on user roles. -router.use(payloadFilter); - -router.use('/assets', authorization.needed('admin'), require('./assets')); -router.use('/settings', authorization.needed('admin'), require('./settings')); -router.use('/queue', authorization.needed('admin'), require('./queue')); +router.use('/assets', authorization.needed('ADMIN'), require('./assets')); +router.use('/settings', authorization.needed('ADMIN'), require('./settings')); +router.use('/queue', authorization.needed('ADMIN'), require('./queue')); router.use('/comments', authorization.needed(), require('./comments')); 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); +router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app); module.exports = router; diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index 34902557c..c106f17f7 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -1,7 +1,8 @@ const express = require('express'); -const Comment = require('../../../models/comment'); -const User = require('../../../models/user'); -const Action = require('../../../models/action'); +const CommentsService = require('../../../services/comments'); +const CommentModel = require('../../../models/comment'); +const UsersService = require('../../../services/users'); +const ActionsService = require('../../../services/actions'); const authorization = require('../../../middleware/authorization'); const _ = require('lodash'); @@ -10,8 +11,8 @@ const router = express.Router(); function gatherActionsAndUsers (comments) { return Promise.all([ comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ + UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + ActionsService.getActionSummaries(_.uniq([ ...comments.map((comment) => comment.id), ...comments.map((comment) => comment.author_id) ])) @@ -26,11 +27,11 @@ function gatherActionsAndUsers (comments) { // depending on the settings. The :moderation overwrites this settings. // Pre-moderation: New comments are shown in the moderator queues immediately. // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. -router.get('/comments/pending', authorization.needed('admin'), (req, res, next) => { +router.get('/comments/pending', authorization.needed('ADMIN'), (req, res, next) => { const {asset_id} = req.query; - Comment.moderationQueue('premod', asset_id) + CommentsService.moderationQueue('PREMOD', asset_id) .then(gatherActionsAndUsers) .then(([comments, users, actions]) => { res.json({comments, users, actions}); @@ -40,10 +41,10 @@ router.get('/comments/pending', authorization.needed('admin'), (req, res, next) }); }); -router.get('/comments/rejected', authorization.needed('admin'), (req, res, next) => { +router.get('/comments/rejected', authorization.needed('ADMIN'), (req, res, next) => { const {asset_id} = req.query; - Comment.moderationQueue('rejected', asset_id) + CommentsService.moderationQueue('REJECTED', asset_id) .then(gatherActionsAndUsers) .then(([comments, users, actions]) => { res.json({comments, users, actions}); @@ -53,7 +54,7 @@ router.get('/comments/rejected', authorization.needed('admin'), (req, res, next) }); }); -router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) => { +router.get('/comments/flagged', authorization.needed('ADMIN'), (req, res, next) => { const {asset_id} = req.query; const assetIDWrap = (query) => { @@ -64,8 +65,8 @@ router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) return query; }; - Comment.findIdsByActionType('flag') - .then(ids => assetIDWrap(Comment.find({ + CommentsService.findIdsByActionType('FLAG') + .then(ids => assetIDWrap(CommentModel.find({ id: {$in: ids} }))) .then(gatherActionsAndUsers) diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 76d16c7ec..f1a5fe9f7 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -1,24 +1,26 @@ const express = require('express'); -const Setting = require('../../../models/setting'); +const SettingsService = require('../../../services/settings'); const router = express.Router(); router.get('/', (req, res, next) => { - Setting.retrieve().then((settings) => { - res.json(settings); - }) - .catch((err) => { - next(err); - }); + SettingsService + .retrieve().then((settings) => { + res.json(settings); + }) + .catch((err) => { + next(err); + }); }); router.put('/', (req, res, next) => { - Setting.update(req.body).then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); + SettingsService + .update(req.body).then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js deleted file mode 100644 index 06845a21a..000000000 --- a/routes/api/stream/index.js +++ /dev/null @@ -1,112 +0,0 @@ -const express = require('express'); -const _ = require('lodash'); -const scraper = require('../../../services/scraper'); -const errors = require('../../../errors'); -const url = require('url'); - -const Comment = require('../../../models/comment'); -const User = require('../../../models/user'); -const Action = require('../../../models/action'); -const Asset = require('../../../models/asset'); -const Setting = require('../../../models/setting'); - -const router = express.Router(); - -router.get('/', (req, res, next) => { - - let asset_url = decodeURIComponent(req.query.asset_url); - - // Verify that the asset_url is parsable. - let parsed_asset_url = url.parse(asset_url); - if (!parsed_asset_url.protocol) { - return next(errors.ErrInvalidAssetURL); - } - - // Get the asset_id for this url (or create it if it doesn't exist) - Promise.all([ - - // Find or create the asset by url. - Asset.findOrCreateByUrl(asset_url) - - // Add the found asset to the scraper if it's not already scraped. - .then((asset) => { - if (!asset.scraped) { - return scraper.create(asset).then(() => asset); - } - - return asset; - }), - - // Get the moderation setting from the settings. - Setting.retrieve() - ]) - .then(([asset, settings]) => { - - // Merge the asset specific settings with the returned settings object in - // the event that the asset that was returned also had settings. - if (asset && asset.settings) { - settings.merge(asset.settings); - } - - return Promise.all([ - - // This is the promised component... Fetch the comments based on the - // moderation settings. - Comment.findByAssetIdWithStatuses(asset.id, [null, 'accepted']), - - // Send back the reference to the asset. - asset, - - // Send back the settings to the stream. - settings - ]); - }) - - // Get all the users and actions for those comments. - .then(([comments, asset, settings]) => { - - // Get the user id's from the author id's as a unique array that gets - // sorted. - let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); - - // Fetch the users for which there is a comment available for them. - let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; - - // Fetch the actions for pretty much everything at this point. - let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false); - - return Promise.all([ - - // Pass back the asset that we loaded... - asset, - - // It's comments... - comments, - - // The users who wrote those comments - users, - - // And all actions about the asset, comments, and users. - actions, - - // Pass back the settings that we loaded. - settings - ]); - }) - .then(([asset, comments, users, actions, settings]) => { - - // Send back the payload containing all this data. - res.json({ - assets: [asset], - comments, - users, - actions, - settings - }); - }) - .catch(error => { - next(error); - }); -}); - -module.exports = router; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 756293c16..3ea3f3213 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -1,11 +1,12 @@ const express = require('express'); const router = express.Router(); -const User = require('../../../models/user'); -const Setting = require('../../../models/setting'); +const UsersService = require('../../../services/users'); +const SettingsService = require('../../../services/settings'); +const CommentsService = require('../../../services/comments'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); -router.get('/', authorization.needed('admin'), (req, res, next) => { +router.get('/', authorization.needed('ADMIN'), (req, res, next) => { const { value = '', field = 'created_at', @@ -15,12 +16,12 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { } = req.query; Promise.all([ - User + UsersService .search(value) .sort({[field]: (asc === 'true') ? 1 : -1}) .skip((page - 1) * limit) .limit(limit), - User.count() + UsersService.count() ]) .then(([result, count]) => { res.json({ @@ -34,8 +35,8 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { .catch(next); }); -router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { - User +router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) => { + UsersService .addRoleToUser(req.params.user_id, req.body.role) .then(() => { res.status(204).end(); @@ -43,11 +44,15 @@ router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => .catch(next); }); -router.post('/:user_id/status', (req, res, next) => { - User - .setStatus(req.params.user_id, req.body.status, req.body.comment_id) +router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => { + UsersService + .setStatus(req.params.user_id, req.body.status) .then((status) => { res.status(201).json(status); + + if (status === 'BANNED' && req.body.comment_id) { + return CommentsService.pushStatus(req.body.comment_id, 'rejected', req.params.user_id); + } }) .catch(next); }); @@ -64,7 +69,7 @@ router.post('/:user_id/status', (req, res, next) => { * @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 +const SendEmailConfirmation = (app, userID, email) => UsersService .createEmailConfirmToken(userID, email) .then((token) => { return mailer.sendSimple({ @@ -87,14 +92,14 @@ router.post('/', (req, res, next) => { displayName } = req.body; - User + UsersService .createLocalUser(email, password, displayName) .then((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}) => { + return SettingsService.retrieve().then(({requireEmailConfirmation = false}) => { if (requireEmailConfirmation) { @@ -123,7 +128,7 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { metadata } = req.body; - User + UsersService .addAction(req.params.user_id, req.user.id, action_type, metadata) .then((action) => { res.status(201).json(action); @@ -133,12 +138,12 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { }); }); -router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => { +router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), (req, res, next) => { const { user_id } = req.params; - User + UsersService .findById(user_id) .then((user) => { if (!user) { diff --git a/services/actions.js b/services/actions.js new file mode 100644 index 000000000..c336dd974 --- /dev/null +++ b/services/actions.js @@ -0,0 +1,177 @@ +const ActionModel = require('../models/action'); +const _ = require('lodash'); + +module.exports = class ActionsService { + + /** + * Finds an action by the id. + * @param {String} id identifier of the action (uuid) + */ + static findById(id) { + return ActionModel.findOne({id}); + } + + /** + * Add an action. + * @param {String} item_id identifier of the comment (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the comment + * @return {Promise} + */ + static insertUserAction(action) { + + // Actions are made unique by using a query that can be reproducable, i.e., + // not containing user inputable values. + let query = { + action_type: action.action_type, + item_type: action.item_type, + item_id: action.item_id, + user_id: action.user_id + }; + + // Create/Update the action. + return ActionModel.findOneAndUpdate(query, action, { + + // Ensure that if it's new, we return the new object created. + new: true, + + // Perform an upsert in the event that this doesn't exist. + upsert: true, + + // Set the default values if not provided based on the mongoose models. + setDefaultsOnInsert: true + }); + } + + /** + * Finds actions in an array of ids. + * @param {String} ids array of user identifiers (uuid) + */ + static findByItemIdArray(item_ids) { + return ActionModel.find({ + 'item_id': {$in: item_ids} + }); + } + + /** + * Fetches the action summaries for the given asset, and comments around the + * given user id. + * @param {[type]} asset_id [description] + * @param {[type]} comments [description] + * @param {String} [current_user_id=''] [description] + * @return {[type]} [description] + */ + static getActionSummariesFromComments(asset_id = '', comments, current_user_id = '') { + + // Get the user id's from the author id's as a unique array that gets + // sorted. + let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); + + // Fetch the actions for pretty much everything at this point. + return ActionsService.getActionSummaries(_.uniq([ + + // Actions can be on assets... + asset_id, + + // Comments... + ...comments.map((comment) => comment.id), + + // Or Authors... + ...userIDs + ].filter((e) => e)), current_user_id); + } + + /** + * Returns summaries of actions for an array of ids + * @param {String} ids array of user identifiers (uuid) + */ + static getActionSummaries(item_ids, current_user_id = '') { + return ActionModel.aggregate([ + { + + // only grab items that match the specified item id's + $match: { + item_id: { + $in: item_ids + } + } + }, + { + $group: { + + // group unique documents by these properties, we are leveraging the + // fact that each uuid is completly unique. + _id: { + item_id: '$item_id', + action_type: '$action_type' + }, + + // and sum up all actions matching the above grouping criteria + count: { + $sum: 1 + }, + + // we are leveraging the fact that each uuid is completly unique and + // just grabbing the last instance of the item type here. + item_type: { + $last: '$item_type' + }, + + current_user: { + $max: { + $cond: { + if: { + $eq: ['$user_id', current_user_id], + }, + then: '$$CURRENT', + else: null + } + } + } + } + }, + { + $project: { + + // suppress the _id field + _id: false, + + // map the fields from the _id grouping down a level + item_id: '$_id.item_id', + action_type: '$_id.action_type', + + // map the field directly + count: '$count', + item_type: '$item_type', + + // set the current user to false here + current_user: '$current_user' + } + } + ]); + } + + /* + * Finds all comments for a specific action. + * @param {String} action_type type of action + * @param {String} item_type type of item the action is on + */ + static findByType(action_type, item_type) { + return ActionModel.find({ + 'action_type': action_type, + 'item_type': item_type + }); + } + + /** + * Finds all comments ids for a specific action. + * @param {String} action_type type of action + * @param {String} item_type type of item the action is on + */ + static findCommentsIdByActionType(action_type, item_type) { + return ActionModel.find({ + 'action_type': action_type, + 'item_type': item_type + }, 'item_id'); + } +}; diff --git a/services/assets.js b/services/assets.js new file mode 100644 index 000000000..569c7ee2d --- /dev/null +++ b/services/assets.js @@ -0,0 +1,116 @@ +const AssetModel = require('../models/asset'); +const SettingsService = require('./settings'); + +module.exports = class AssetsService { + + /** + * Finds an asset by its id. + * @param {String} id identifier of the asset (uuid). + */ + static findById(id) { + return AssetModel.findOne({id}); + } + + /** + * Finds a asset by its url. + * @param {String} url identifier of the asset (uuid). + */ + static findByUrl(url) { + return AssetModel.findOne({url}); + } + + /** + * Retrieves the settings given an asset query and rectifies it against the + * global settings. + * @param {Promise} assetQuery an asset query that returns a single asset. + * @return {Promise} + */ + static rectifySettings(assetQuery) { + return Promise.all([ + SettingsService.retrieve(), + assetQuery + ]).then(([settings, asset]) => { + + // If the asset exists and has settings then return the merged object. + if (asset && asset.settings) { + settings.merge(asset.settings); + } + + return settings; + }); + } + + /** + * Finds a asset by its url. + * + * NOTE: This function has scalability concerns regarding mongoose's decision + * always write {updated_at: new Date()} on every call to findOneAndUpdate + * even though the update document exactly matches the query document... In + * the future this function should never update, only findOneAndCreate but this + * is not possible with the mongoose driver. + * + * @param {String} url identifier of the asset (uuid). + * @return {Promise} + */ + static findOrCreateByUrl(url) { + return AssetModel.findOneAndUpdate({url}, {url}, { + + // Ensure that if it's new, we return the new object created. + new: true, + + // Perform an upsert in the event that this doesn't exist. + upsert: true, + + // Set the default values if not provided based on the mongoose models. + setDefaultsOnInsert: true + }); + } + + /** + * Updates the settings for the asset. + * @param {[type]} id [description] + * @param {[type]} settings [description] + * @return {[type]} [description] + */ + static overrideSettings(id, settings) { + return AssetModel.findOneAndUpdate({id}, { + $set: { + settings + } + }, { + new: true + }); + } + + /** + * Finds assets matching keywords on the model. If `value` is an empty string, + * then it will not even perform a text search query. + * @param {String} value string to search by. + * @return {Promise} + */ + static search(value) { + if (value.length === 0) { + return AssetsService.all(); + } else { + return AssetModel.find({ + $text: { + $search: value + } + }); + } + } + + /** + * Finds multiple assets with matching ids + * @param {Array} ids an array of Strings of asset_id + * @return {Promise} resolves to list of Assets + */ + static findMultipleById(ids) { + const query = ids.map(id => ({id})); + return AssetModel.find(query); + } + + static all() { + return AssetModel.find({}); + } +}; diff --git a/services/comments.js b/services/comments.js new file mode 100644 index 000000000..5d39a129e --- /dev/null +++ b/services/comments.js @@ -0,0 +1,220 @@ +const CommentModel = require('../models/comment'); + +const ActionModel = require('../models/action'); +const ActionsService = require('./actions'); + +module.exports = class CommentsService { + + /** + * Creates a new Comment that came from a public source. + * @param {Mixed} comment either a single comment or an array of comments. + * @return {Promise} + */ + static publicCreate(comment) { + + // Check to see if this is an array of comments, if so map it out. + if (Array.isArray(comment)) { + return Promise.all(comment.map(CommentsService.publicCreate)); + } + + const { + body, + asset_id, + parent_id, + status = null, + author_id + } = comment; + + comment = new CommentModel({ + body, + asset_id, + parent_id, + status_history: status ? [{ + type: status, + created_at: new Date() + }] : [], + status, + author_id + }); + + return comment.save(); + } + + /** + * Finds a comment by the id. + * @param {String} id identifier of comment (uuid) + * @return {Promise} + */ + static findById(id) { + return CommentModel.findOne({id}); + } + + /** + * Finds ALL the comments by the asset_id. + * @param {String} asset_id identifier of the asset which owns this comment (uuid) + * @return {Promise} + */ + static findByAssetId(asset_id) { + return CommentModel.find({ + asset_id + }); + } + + /** + * findByAssetIdWithStatuses finds all the comments where the asset id matches + * what's provided and the status is one of the ones listed in the statuses + * array. + * @param {String} asset_id the asset id to search by + * @param {Array} [statuses=[]] the array of statuses to search by + * @return {Promise} resolves to an array of comments + */ + static findByAssetIdWithStatuses(asset_id, statuses = []) { + return CommentModel.find({ + asset_id, + status: { + $in: statuses + } + }); + } + + /** + * Find comments by an action that was performed on them. + * @param {String} action_type the type of action that was performed on the comment + * @return {Promise} + */ + static findByActionType(action_type) { + return ActionsService + .findCommentsIdByActionType(action_type, 'COMMENTS') + .then((actions) => CommentModel.find({ + id: { + $in: actions.map((a) => a.item_id) + } + })); + } + + /** + * Find comment id's where the action type matches the argument. + * @param {String} action_type the type of action that was performed on the comment + * @return {Promise} + */ + static findIdsByActionType(action_type) { + return ActionsService + .findCommentsIdByActionType(action_type, 'COMMENTS') + .then((actions) => actions.map(a => a.item_id)); + } + + /** + * Find comments by current status + * @param {String} status status of the comment to search for + * @return {Promise} resovles to comment array + */ + static findByStatus(status = null) { + return CommentModel.find({status}); + } + + /** + * Find comments that need to be moderated (aka moderation queue). + * @param {String} asset_id + * @return {Promise} + */ + static moderationQueue(status = null, asset_id = null) { + + // Fetch the comments with statuses. + let comments = CommentModel.find({status}); + + if (asset_id) { + comments = comments.where({asset_id}); + } + + return comments; + } + + /** + * Pushes a new status in for the user. + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + * @param {String} assigned_by the user id for the user who performed the + * moderation action + * @return {Promise} + */ + static pushStatus(id, status, assigned_by = null) { + return CommentModel.update({id}, { + $push: { + status_history: { + type: status, + created_at: new Date(), + assigned_by + } + }, + $set: {status} + }); + } + + /** + * Add an action to the comment. + * @param {String} item_id identifier of the comment (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the comment + * @return {Promise} + */ + static addAction(item_id, user_id, action_type, metadata) { + return ActionsService.insertUserAction({ + item_id, + item_type: 'COMMENTS', + user_id, + action_type, + metadata + }); + } + + /** + * Change the status of a comment. + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + * @return {Promise} + */ + static removeById(id) { + return CommentModel.remove({id}); + } + + /** + * Remove an action from the comment. + * @param {String} id identifier of the comment (uuid) + * @param {String} action_type the type of the action to be removed + * @param {String} user_id the id of the user performing the action + * @return {Promise} + */ + static removeAction(item_id, user_id, action_type) { + return ActionModel.remove({ + action_type, + item_type: 'COMMENTS', + item_id, + user_id + }); + } + + /** + * Returns all the comments in the collection. + * @return {Promise} + */ + static all() { + return CommentModel.find({}); + } + + /** + * Returns all the comments by user + * probably to be paginated at some point in the future + * @return {Promise} array resolves to an array of comments by that user + */ + static findByUserId(author_id, admin = false) { + + // do not return un-published comments for non-admins + let query = {author_id}; + + if (!admin) { + query.$nor = [{status: 'PREMOD'}, {status: 'REJECTED'}]; + } + + return CommentModel.find(query); + } +}; diff --git a/services/mongoose.js b/services/mongoose.js index 1b9097760..c7092326f 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -57,3 +57,12 @@ mongoose.connect(url, (err) => { }); module.exports = mongoose; + +// Here we include all the models that mongoose is used for, this ensures that +// when we import mongoose that we also start up all the indexing opreations +// here. +require('../models/action'); +require('../models/asset'); +require('../models/comment'); +require('../models/setting'); +require('../models/user'); diff --git a/services/passport.js b/services/passport.js index 130de234d..f24d12165 100644 --- a/services/passport.js +++ b/services/passport.js @@ -1,6 +1,6 @@ const passport = require('passport'); -const User = require('../models/user'); -const Setting = require('../models/setting'); +const UsersService = require('./users'); +const SettingsService = require('./settings'); const LocalStrategy = require('passport-local').Strategy; const FacebookStrategy = require('passport-facebook').Strategy; @@ -13,7 +13,7 @@ passport.serializeUser((user, done) => { }); passport.deserializeUser((id, done) => { - User + UsersService .findById(id) .then((user) => { done(null, user); @@ -43,7 +43,7 @@ function ValidateUserLogin(loginProfile, user, done) { } // The user is a local user, check if we need email confirmation. - return Setting.retrieve().then(({requireEmailConfirmation = false}) => { + return SettingsService.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 @@ -77,7 +77,7 @@ passport.use(new LocalStrategy({ usernameField: 'email', passwordField: 'password' }, (email, password, done) => { - User + UsersService .findLocalUser(email, password) .then((user) => { if (!user) { @@ -103,7 +103,7 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, profileFields: ['id', 'displayName', 'picture.type(large)'] }, (accessToken, refreshToken, profile, done) => { - User + UsersService .findOrCreateExternalUser(profile) .then((user) => { return ValidateUserLogin(profile, user, done); diff --git a/services/scraper.js b/services/scraper.js index 6a3260757..aa56bf61b 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -1,6 +1,7 @@ const kue = require('./kue'); const debug = require('debug')('talk:services:scraper'); -const Asset = require('../models/asset'); +const AssetModel = require('../models/asset'); +const AssetsService = require('./assets'); const metascraper = require('metascraper'); @@ -49,7 +50,7 @@ const scraper = { * Updates an Asset based on scraped asset metadata. */ update(id, meta) { - return Asset.update({id}, { + return AssetModel.update({id}, { $set: { title: meta.title || '', description: meta.description || '', @@ -74,7 +75,7 @@ const scraper = { debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`); - Asset + AssetsService // Find the asset, or complain that it doesn't exist. .findById(job.data.asset_id) diff --git a/services/settings.js b/services/settings.js new file mode 100644 index 000000000..d82fb91a5 --- /dev/null +++ b/services/settings.js @@ -0,0 +1,49 @@ +const SettingModel = require('../models/setting'); + +/** + * The selector used to uniquely identify the settings document. + */ +const selector = {id: '1'}; + +/** + * The Setting Service object exposing the Setting model. + */ +module.exports = class SettingsService { + + /** + * Gets the entire settings record and sends it back + * @return {Promise} settings the whole settings record + */ + static retrieve() { + return SettingModel.findOne(selector); + } + + /** + * This will update the settings object with whatever you pass in + * @param {object} setting a hash of whatever settings you want to update + * @return {Promise} settings Promise that resolves to the entire (updated) settings object. + */ + static update(settings) { + return SettingModel.findOneAndUpdate(selector, { + $set: settings + }, { + upsert: true, + new: true, + setDefaultsOnInsert: true + }); + } + + /** + * This is run once when the app starts to ensure settings are populated. + * @return {Promise} null initialize the global settings object + */ + static init(defaults) { + + // Inject the defaults on top of the passed in defaults to ensure that the new + // settings conform to the required selector. + defaults = Object.assign({}, defaults, selector); + + // Actually update the settings collection. + return SettingsService.update(defaults); + } +}; diff --git a/services/users.js b/services/users.js new file mode 100644 index 000000000..0bf22e9e0 --- /dev/null +++ b/services/users.js @@ -0,0 +1,601 @@ +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const Wordlist = require('./wordlist'); +const errors = require('../errors'); +const uuid = require('uuid'); + +const UserModel = require('../models/user'); +const USER_STATUS = require('../models/user').USER_STATUS; +const USER_ROLES = require('../models/user').USER_ROLES; + +const ActionsService = require('./actions'); + +// In the event that the TALK_SESSION_SECRET is missing but we are testing, then +// set the process.env.TALK_SESSION_SECRET. +if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { + process.env.TALK_SESSION_SECRET = 'keyboard cat'; +} else if (!process.env.TALK_SESSION_SECRET) { + throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); +} + +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. +const SALT_ROUNDS = 10; + +// UsersService is the interface for the application to interact with the +// UserModel through. +module.exports = class UsersService { + + /** + * Finds a user given their email address that we have for them in the system + * and ensures that the retuned user matches the password passed in as well. + * @param {string} email - email to look up the user by + * @param {string} password - password to match against the found user + * @param {Function} done [description] + */ + static findLocalUser(email, password) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required for findLocalUser'); + } + + return UserModel.findOne({ + profiles: { + $elemMatch: { + id: email.toLowerCase(), + provider: 'local' + } + } + }) + .then((user) => { + if (!user) { + return false; + } + + return new Promise((resolve, reject) => { + bcrypt.compare(password, user.password, (err, res) => { + if (err) { + return reject(err); + } + + if (!res) { + return resolve(false); + } + + return resolve(user); + }); + }); + }); + } + + /** + * Merges two users together by taking all the profiles on a given user and + * pushing them into the source user followed by deleting the destination user's + * user account. This will not merge the roles associated with the source user. + * @param {String} dstUserID id of the user to which is the target of the merge + * @param {String} srcUserID id of the user to which is the source of the merge + * @return {Promise} resolves when the users are merged + */ + static mergeUsers(dstUserID, srcUserID) { + let srcUser, dstUser; + + return Promise + .all([ + UserModel.findOne({id: dstUserID}).exec(), + UserModel.findOne({id: srcUserID}).exec() + ]) + .then((users) => { + dstUser = users[0]; + srcUser = users[1]; + + srcUser.profiles.forEach((profile) => { + dstUser.profiles.push(profile); + }); + + return srcUser.remove(); + }) + .then(() => dstUser.save()); + } + + /** + * Finds a user given a social profile and if the user does not exist, creates + * them. + * @param {Object} profile - User social/external profile + * @param {Function} done [description] + */ + static findOrCreateExternalUser(profile) { + return UserModel + .findOne({ + profiles: { + $elemMatch: { + id: profile.id, + provider: profile.provider + } + } + }) + .then((user) => { + if (user) { + return user; + } + + // The user was not found, lets create them! + user = new UserModel({ + displayName: profile.displayName, + roles: [], + profiles: [ + { + id: profile.id, + provider: profile.provider + } + ] + }); + + return user.save(); + }); + } + + static changePassword(id, password) { + return new Promise((resolve, reject) => { + bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { + if (err) { + return reject(err); + } + + resolve(hashedPassword); + }); + }) + .then((hashedPassword) => { + return UserModel.update({id}, { + $inc: {__v: 1}, + $set: { + password: hashedPassword + } + }); + }); + } + + /** + * Creates local users. + * @param {Array} users Users to create + * @return {Promise} Resolves with the users that were created + */ + static createLocalUsers(users) { + return Promise.all(users.map((user) => { + return UsersService + .createLocalUser(user.email, user.password, user.displayName); + })); + } + + /** + * 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 + */ + static 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 + * @param {String} password plaintext password of the new user + * @param {String} displayName name of the display user + * @param {Function} done callback + */ + static createLocalUser(email, password, displayName) { + + if (!email) { + return Promise.reject(errors.ErrMissingEmail); + } + + email = email.toLowerCase().trim(); + displayName = displayName.toLowerCase().trim(); + + if (!password) { + return Promise.reject(errors.ErrMissingPassword); + } + + if (password.length < 8) { + return Promise.reject(errors.ErrPasswordTooShort); + } + + return UsersService.isValidDisplayName(displayName) + .then(() => { // displayName is valid + 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' + } + ] + }); + + user.save((err) => { + if (err) { + if (err.code === 11000) { + if (err.message.match('displayName')) { + return reject(errors.ErrDisplayTaken); + } + return reject(errors.ErrEmailTaken); + } + return reject(err); + } + return resolve(user); + }); + }); + }); + }); + } + + /** + * Disables a given user account. + * @param {String} id id of a user + * @param {Function} done callback after the operation is complete + */ + static disableUser(id) { + return UserModel.update({ + id: id + }, { + $set: { + disabled: true + } + }); + } + + /** + * Enables a given user account. + * @param {String} id id of a user + * @param {Function} done callback after the operation is complete + */ + static enableUser(id) { + return UserModel.update({ + id: id + }, { + $set: { + disabled: false + } + }); + } + + /** + * Adds a role to a user. + * @param {String} id id of a user + * @param {String} role role to add + * @param {Function} done callback after the operation is complete + */ + static addRoleToUser(id, role) { + + // Check to see if the user role is in the allowable set of roles. + if (USER_ROLES.indexOf(role) === -1) { + + // User role is not supported! Error out here. + return Promise.reject(new Error(`role ${role} is not supported`)); + } + + return UserModel.update({ + id: id + }, { + $addToSet: { + roles: role + } + }); + } + + /** + * Removes a role from a user. + * @param {String} id id of a user + * @param {String} role role to remove + * @param {Function} done callback after the operation is complete + */ + static removeRoleFromUser(id, role) { + + // Check to see if the user role is in the allowable set of roles. + if (USER_ROLES.indexOf(role) === -1) { + + // User role is not supported! Error out here. + return Promise.reject(new Error(`role ${role} is not supported`)); + } + + return UserModel.update({ + id: id + }, { + $pull: { + roles: role + } + }); + } + + /** + * Set status of a user. + * @param {String} id id of a user + * @param {String} status status to set + * @param {Function} done callback after the operation is complete + */ + static setStatus(id, status) { + + // Check to see if the user status is in the allowable set of roles. + if (USER_STATUS.indexOf(status) === -1) { + + // User status is not supported! Error out here. + return Promise.reject(new Error(`status ${status} is not supported`)); + } + + return UserModel.update({id}, {$set: {status}}); + } + + /** + * Finds a user with the id. + * @param {String} id user id (uuid) + */ + static findById(id) { + return UserModel.findOne({id}); + } + + /** + * Finds users in an array of ids. + * @param {Array} ids array of user identifiers (uuid) + */ + static findByIdArray(ids) { + return UserModel.find({ + id: {$in: ids} + }); + } + + /** + * Finds public user information by an array of ids. + * @param {Array} ids array of user identifiers (uuid) + */ + static findPublicByIdArray(ids) { + return UserModel.find({ + id: {$in: ids} + }, 'id displayName'); + } + + /** + * Creates a JWT from a user email. Only works for local accounts. + * @param {String} email of the local user + */ + static createPasswordResetToken(email) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) + .then((user) => { + if (!user) { + + // 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 = { + jti: uuid.v4(), + email, + userId: user.id, + version: user.__v + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + algorithm: 'HS256', + expiresIn: '1d', + subject: PASSWORD_RESET_JWT_SUBJECT + }); + }); + } + + /** + * Verifies that the token was indeed signed by the session secret. + * @param {String} token JWT token from the client + * @return {Promise} + */ + static verifyToken(token, options = {}) { + return new Promise((resolve, reject) => { + + // 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); + }); + }); + } + + /** + * Verifies a jwt and returns the associated user. + * @param {String} token the JSON Web Token to verify + */ + static verifyPasswordResetToken(token) { + return UsersService + .verifyToken(token, { + subject: PASSWORD_RESET_JWT_SUBJECT + }) + .then((decoded) => UsersService.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. + * @param {String} value value to search by + * @return {Promise} + */ + static search(value) { + return UserModel.find({ + $or: [ + + // Search by a prefix match on the displayName. + { + 'displayName': { + $regex: new RegExp(`^${value}`), + $options: 'i' + } + }, + + // Search by a prefix match on the email address. + { + 'profiles': { + $elemMatch: { + id: { + $regex: new RegExp(`^${value}`), + $options: 'i' + }, + provider: 'local' + } + } + } + ] + }); + } + + /** + * Returns a count of the current users. + * @return {Promise} + */ + static count() { + return UserModel.count(); + } + + /** + * Returns all the users. + * @return {Promise} + */ + static all() { + return UserModel.find(); + } + + /** + * Updates the user's settings. + * @return {Promise} + */ + static updateSettings(id, settings) { + return UserModel.update({ + id + }, { + $set: { + settings + } + }); + } + + /** + * Add an action to the user. + * @param {String} item_id identifier of the user (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the user + * @return {Promise} + */ + static addAction(item_id, user_id, action_type, metadata) { + return ActionsService.insertUserAction({ + item_id, + item_type: 'users', + user_id, + 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} + */ + static 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 UsersService + .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} + */ + static verifyEmailConfirmation(token) { + return UsersService + .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() + } + }); + }); + } + +}; diff --git a/services/wordlist.js b/services/wordlist.js index e912abc43..a06801e87 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -2,7 +2,7 @@ const debug = require('debug')('talk:services:wordlist'); const _ = require('lodash'); const natural = require('natural'); const tokenizer = new natural.WordTokenizer(); -const Setting = require('../models/setting'); +const SettingsService = require('./settings'); const Errors = require('../errors'); /** @@ -22,7 +22,7 @@ class Wordlist { * Loads wordlists in from the database */ load() { - return Setting + return SettingsService .retrieve() .then((settings) => { @@ -118,6 +118,42 @@ class Wordlist { }); } + /** + * Scans a specific field for wordlist violations. + */ + scan(fieldName, phrase) { + let errors = {}; + + // If the field doesn't exist in the body, then it can't be profane! + if (!phrase) { + + // Return that there wasn't a profane word here. + return errors; + } + + // Check if the field contains a banned word. + if (this.match(this.lists.banned, phrase)) { + debug(`the field "${fieldName}" contained a phrase "${phrase}" which contained a banned word/phrase`); + + errors.banned = Errors.ErrContainsProfanity; + + // Stop looping through the fields now, we discovered the worst possible + // situation (a banned word). + return errors; + } + + // Check if the field contains a banned word. + if (this.match(this.lists.suspect, phrase)) { + debug(`the field "${fieldName}" contained a phrase "${phrase}" which contained a suspected word/phrase`); + + errors.suspect = Errors.ErrContainsProfanity; + + // Continue looping through the fields now, we discovered a possible bad + // word (suspect). + return errors; + } + } + /** * Perform the filtering based on the loaded wordlists. */ @@ -129,9 +165,9 @@ class Wordlist { // Loop over all the fields from the body that we want to check. for (let i = 0; i < fields.length; i++) { - let field = fields[i]; + let fieldName = fields[i]; - let phrase = _.get(body, field, false); + let phrase = _.get(body, fieldName, false); // If the field doesn't exist in the body, then it can't be profane! if (!phrase) { @@ -140,11 +176,10 @@ class Wordlist { continue; } - // Check if the field contains a banned word. - if (this.match(this.lists.banned, phrase)) { - debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`); + errors = Object.assign(errors, this.scan(fieldName, phrase)); - errors.banned = Errors.ErrContainsProfanity; + // Check if the field contains a banned word. + if (errors.banned) { // Stop looping through the fields now, we discovered the worst possible // situation (a banned word). @@ -152,10 +187,7 @@ class Wordlist { } // Check if the field contains a banned word. - if (this.match(this.lists.suspect, phrase)) { - debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`); - - errors.suspect = Errors.ErrContainsProfanity; + if (errors.suspect) { // Continue looping through the fields now, we discovered a possible bad // word (suspect). diff --git a/tests/.eslintrc.json b/test/.eslintrc.json similarity index 100% rename from tests/.eslintrc.json rename to test/.eslintrc.json diff --git a/tests/client/.eslintrc.json b/test/client/.eslintrc.json similarity index 100% rename from tests/client/.eslintrc.json rename to test/client/.eslintrc.json diff --git a/tests/client/coral-admin/actions/assets.js b/test/client/coral-admin/actions/assets.js similarity index 100% rename from tests/client/coral-admin/actions/assets.js rename to test/client/coral-admin/actions/assets.js diff --git a/tests/client/coral-admin/reducers/assets.js b/test/client/coral-admin/reducers/assets.js similarity index 100% rename from tests/client/coral-admin/reducers/assets.js rename to test/client/coral-admin/reducers/assets.js diff --git a/tests/client/coral-framework/store/itemActions.js b/test/client/coral-framework/store/itemActions.js similarity index 100% rename from tests/client/coral-framework/store/itemActions.js rename to test/client/coral-framework/store/itemActions.js diff --git a/tests/client/coral-framework/store/itemReducer.js b/test/client/coral-framework/store/itemReducer.js similarity index 100% rename from tests/client/coral-framework/store/itemReducer.js rename to test/client/coral-framework/store/itemReducer.js diff --git a/tests/client/coral-framework/store/notificationReducer.spec.js b/test/client/coral-framework/store/notificationReducer.spec.js similarity index 100% rename from tests/client/coral-framework/store/notificationReducer.spec.js rename to test/client/coral-framework/store/notificationReducer.spec.js diff --git a/tests/client/coral-plugin-history/Comment.spec.js b/test/client/coral-plugin-history/Comment.spec.js similarity index 85% rename from tests/client/coral-plugin-history/Comment.spec.js rename to test/client/coral-plugin-history/Comment.spec.js index 78ad104bf..6d69d54cd 100644 --- a/tests/client/coral-plugin-history/Comment.spec.js +++ b/test/client/coral-plugin-history/Comment.spec.js @@ -9,20 +9,19 @@ describe('coral-plugin-history/Comment', () => { const asset = {url: 'https://google.com'}; beforeEach(() => { - render = shallow(); + render = shallow({}}/>); }); it('should render the provided comment body', () => { - const wrapper = mount(); + const wrapper = mount({}}/>); expect(wrapper.find('.myCommentBody')).to.have.length(1); expect(wrapper.find('.myCommentBody').text()).to.equal('this is a comment'); }); it('should render the asset url as a link', () => { - const wrapper = mount(); + const wrapper = mount({}}/>); expect(wrapper.find('.myCommentAnchor')).to.have.length(1); expect(wrapper.find('.myCommentAnchor').text()).to.equal('https://google.com'); - expect(wrapper.find('.myCommentAnchor').props().href).to.equal('https://google.com#123'); }); it('should render the comment with styles', () => { diff --git a/tests/client/coral-plugin-history/CommentHistory.spec.js b/test/client/coral-plugin-history/CommentHistory.spec.js similarity index 98% rename from tests/client/coral-plugin-history/CommentHistory.spec.js rename to test/client/coral-plugin-history/CommentHistory.spec.js index 3b6f5ee47..9d81f79cd 100644 --- a/tests/client/coral-plugin-history/CommentHistory.spec.js +++ b/test/client/coral-plugin-history/CommentHistory.spec.js @@ -9,11 +9,11 @@ describe('coral-plugin-history/CommentHistory', () => { const assets = [{'settings': null, 'created_at':'2016-12-06T21:36:09.302Z', 'url':'localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-08T02:11:15.943Z', '_id':'58472f499e775a38f23d5da0', 'type':'article', 'closedMessage':null, 'id':'7302e637-f884-47c0-9723-02cc10a18617', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-07T02:25:31.983Z', 'url':'http://localhost:3000/', 'scraped':null, 'status':'open', 'updated_at':'2016-12-13T22:58:36.061Z', '_id':'5847731b9e775a38f23d5da1', 'type':'article', 'closedMessage':null, 'id':'96fddf96-7c83-4008-80ad-50091997d006', 'closedAt':null}, {'settings':null, 'created_at':'2016-12-12T19:04:05.770Z', 'url':'http://localhost:3000/embed/stream', 'scraped':null, 'updated_at':'2016-12-14T20:13:21.934Z', '_id':'584ef4a59e775a38f23d5e86', 'type':'article', 'closedMessage':null, 'id':'cef81015-1b53-4d70-b9af-6eca680f22fc', 'closedAt':null}]; beforeEach(() => { - render = shallow(); + render = shallow({}}/>); }); it('should render Comments as children when given comments and assets', () => { - const wrapper = mount(); + const wrapper = mount({}}/>); expect(wrapper.find('.commentHistory__list').children()).to.have.length(7); }); @@ -21,4 +21,3 @@ describe('coral-plugin-history/CommentHistory', () => { expect(render.props().style).to.be.defined; }); }); - diff --git a/tests/e2e/globals.js b/test/e2e/globals.js similarity index 100% rename from tests/e2e/globals.js rename to test/e2e/globals.js diff --git a/tests/e2e/mocks.js b/test/e2e/mocks.js similarity index 100% rename from tests/e2e/mocks.js rename to test/e2e/mocks.js diff --git a/tests/e2e/pages/adminPage.js b/test/e2e/pages/adminPage.js similarity index 100% rename from tests/e2e/pages/adminPage.js rename to test/e2e/pages/adminPage.js diff --git a/tests/e2e/pages/embedStreamPage.js b/test/e2e/pages/embedStreamPage.js similarity index 100% rename from tests/e2e/pages/embedStreamPage.js rename to test/e2e/pages/embedStreamPage.js diff --git a/tests/e2e/tests/00_AppTest.js b/test/e2e/tests/00_AppTest.js similarity index 100% rename from tests/e2e/tests/00_AppTest.js rename to test/e2e/tests/00_AppTest.js diff --git a/tests/e2e/tests/01_EmbedStreamTest.js b/test/e2e/tests/01_EmbedStreamTest.js similarity index 100% rename from tests/e2e/tests/01_EmbedStreamTest.js rename to test/e2e/tests/01_EmbedStreamTest.js diff --git a/tests/e2e/tests/Admin/LoginTest.js b/test/e2e/tests/Admin/LoginTest.js similarity index 93% rename from tests/e2e/tests/Admin/LoginTest.js rename to test/e2e/tests/Admin/LoginTest.js index e75d29903..9babdf29b 100644 --- a/tests/e2e/tests/Admin/LoginTest.js +++ b/test/e2e/tests/Admin/LoginTest.js @@ -1,5 +1,5 @@ module.exports = { - '@tags': ['login', 'admin'], + '@tags': ['login', 'ADMIN'], before(client) { const embedStreamPage = client.page.embedStreamPage(); const {launchUrl} = client; diff --git a/tests/e2e/tests/Commenter/FlagCommentTest.js b/test/e2e/tests/Commenter/FlagCommentTest.js similarity index 100% rename from tests/e2e/tests/Commenter/FlagCommentTest.js rename to test/e2e/tests/Commenter/FlagCommentTest.js diff --git a/tests/e2e/tests/Commenter/FlagUsernameTest.js b/test/e2e/tests/Commenter/FlagUsernameTest.js similarity index 100% rename from tests/e2e/tests/Commenter/FlagUsernameTest.js rename to test/e2e/tests/Commenter/FlagUsernameTest.js diff --git a/tests/e2e/tests/Commenter/LikeCommentTest.js b/test/e2e/tests/Commenter/LikeCommentTest.js similarity index 100% rename from tests/e2e/tests/Commenter/LikeCommentTest.js rename to test/e2e/tests/Commenter/LikeCommentTest.js diff --git a/tests/e2e/tests/Commenter/LoginTest.js b/test/e2e/tests/Commenter/LoginTest.js similarity index 100% rename from tests/e2e/tests/Commenter/LoginTest.js rename to test/e2e/tests/Commenter/LoginTest.js diff --git a/tests/e2e/tests/Commenter/PermalinkTest.js b/test/e2e/tests/Commenter/PermalinkTest.js similarity index 100% rename from tests/e2e/tests/Commenter/PermalinkTest.js rename to test/e2e/tests/Commenter/PermalinkTest.js diff --git a/tests/e2e/tests/Commenter/PostComment.js b/test/e2e/tests/Commenter/PostComment.js similarity index 100% rename from tests/e2e/tests/Commenter/PostComment.js rename to test/e2e/tests/Commenter/PostComment.js diff --git a/tests/e2e/tests/EmbedStreamTests.js b/test/e2e/tests/EmbedStreamTests.js similarity index 96% rename from tests/e2e/tests/EmbedStreamTests.js rename to test/e2e/tests/EmbedStreamTests.js index 78c78680e..abe76fed3 100644 --- a/tests/e2e/tests/EmbedStreamTests.js +++ b/test/e2e/tests/EmbedStreamTests.js @@ -20,7 +20,7 @@ module.exports = { }, 'User registers and posts a comment with premod off': client => { client.perform((client, done) => { - mocks.settings({moderation: 'post'}) + mocks.settings({moderation: 'POST'}) .then(() => { // Load Page @@ -61,7 +61,7 @@ module.exports = { }, 'User posts a comment with premod on': client => { client.perform((client, done) => { - mocks.settings({moderation: 'pre'}) + mocks.settings({moderation: 'PRE'}) .then(() => { // Load Page @@ -86,7 +86,7 @@ module.exports = { }, 'User replies to a comment with premod off': client => { client.perform((client, done) => { - mocks.settings({moderation: 'post'}) + mocks.settings({moderation: 'POST'}) .then(() => { // Load Page @@ -119,7 +119,7 @@ module.exports = { }, 'User replies to a comment with premod on': client => { client.perform((client, done) => { - mocks.settings({moderation: 'pre'}) + mocks.settings({moderation: 'PRE'}) // Add a mock user .then(() => { diff --git a/tests/e2e/tests/Moderator/LoginTest.js b/test/e2e/tests/Moderator/LoginTest.js similarity index 92% rename from tests/e2e/tests/Moderator/LoginTest.js rename to test/e2e/tests/Moderator/LoginTest.js index 3f1754a50..d04baabac 100644 --- a/tests/e2e/tests/Moderator/LoginTest.js +++ b/test/e2e/tests/Moderator/LoginTest.js @@ -1,5 +1,5 @@ module.exports = { - '@tags': ['login', 'moderator'], + '@tags': ['login', 'MODERATOR'], before: client => { const embedStreamPage = client.page.embedStreamPage(); const {launchUrl} = client; diff --git a/tests/e2e/tests/Visitor/FlagCommentTest.js b/test/e2e/tests/Visitor/FlagCommentTest.js similarity index 100% rename from tests/e2e/tests/Visitor/FlagCommentTest.js rename to test/e2e/tests/Visitor/FlagCommentTest.js diff --git a/tests/e2e/tests/Visitor/LikeCommentTest.js b/test/e2e/tests/Visitor/LikeCommentTest.js similarity index 100% rename from tests/e2e/tests/Visitor/LikeCommentTest.js rename to test/e2e/tests/Visitor/LikeCommentTest.js diff --git a/tests/e2e/tests/Visitor/SignUpTest.js b/test/e2e/tests/Visitor/SignUpTest.js similarity index 100% rename from tests/e2e/tests/Visitor/SignUpTest.js rename to test/e2e/tests/Visitor/SignUpTest.js diff --git a/tests/helpers/browser.js b/test/helpers/browser.js similarity index 100% rename from tests/helpers/browser.js rename to test/helpers/browser.js diff --git a/tests/helpers/index.test.html b/test/helpers/index.test.html similarity index 100% rename from tests/helpers/index.test.html rename to test/helpers/index.test.html diff --git a/tests/helpers/mongoose.js b/test/helpers/mongoose.js similarity index 100% rename from tests/helpers/mongoose.js rename to test/helpers/mongoose.js diff --git a/tests/kue.js b/test/kue.js similarity index 100% rename from tests/kue.js rename to test/kue.js diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 000000000..a159ae7aa --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,7 @@ +test/helpers/*.js +test +--compilers js:babel-core/register +--require ignore-styles +--recursive +--colors +--sort diff --git a/tests/mongoose.js b/test/mongoose.js similarity index 100% rename from tests/mongoose.js rename to test/mongoose.js diff --git a/tests/passport.js b/test/passport.js similarity index 100% rename from tests/passport.js rename to test/passport.js diff --git a/tests/routes/api/assets/index.js b/test/routes/api/assets/index.js similarity index 85% rename from tests/routes/api/assets/index.js rename to test/routes/api/assets/index.js index 3354f9671..56c20fc78 100644 --- a/tests/routes/api/assets/index.js +++ b/test/routes/api/assets/index.js @@ -8,12 +8,13 @@ const expect = chai.expect; chai.should(); chai.use(require('chai-http')); -const Asset = require('../../../../models/asset'); +const AssetModel = require('../../../../models/asset'); +const AssetsService = require('../../../../services/assets'); describe('/api/v1/assets', () => { beforeEach(() => { - return Asset.create([ + return AssetModel.create([ { url: 'https://coralproject.net/news/asset1', title: 'Asset 1', @@ -34,7 +35,7 @@ describe('/api/v1/assets', () => { it('should return all assets without a search query', () => { return chai.request(app) .get('/api/v1/assets') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; @@ -50,7 +51,7 @@ describe('/api/v1/assets', () => { it('should return assets that we search for', () => { return chai.request(app) .get('/api/v1/assets?search=term2') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; @@ -71,7 +72,7 @@ describe('/api/v1/assets', () => { it('should not return assets that we do not search for', () => { return chai.request(app) .get('/api/v1/assets?search=term3') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; @@ -85,7 +86,7 @@ describe('/api/v1/assets', () => { it('should return only closed assets', () => { return chai.request(app) .get('/api/v1/assets?filter=closed') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; @@ -101,7 +102,7 @@ describe('/api/v1/assets', () => { it('should return only opened assets', () => { return chai.request(app) .get('/api/v1/assets?filter=open') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; @@ -121,21 +122,21 @@ describe('/api/v1/assets', () => { const today = Date.now(); - return Asset.findOrCreateByUrl('http://test.com') + return AssetsService.findOrCreateByUrl('http://test.com') .then((asset) => { expect(asset).to.have.property('isClosed', null); expect(asset).to.have.property('closedAt', null); return chai.request(app) .put(`/api/v1/assets/${asset.id}/status`) - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .send({closedAt: today}); }) .then((res) => { expect(res).to.have.status(204); - return Asset.findByUrl('http://test.com'); + return AssetsService.findByUrl('http://test.com'); }) .then((asset) => { expect(asset).to.have.property('isClosed', true); diff --git a/tests/routes/api/auth/index.js b/test/routes/api/auth/index.js similarity index 84% rename from tests/routes/api/auth/index.js rename to test/routes/api/auth/index.js index 6b39e0c8e..4882d859e 100644 --- a/tests/routes/api/auth/index.js +++ b/test/routes/api/auth/index.js @@ -4,7 +4,7 @@ const expect = chai.expect; chai.use(require('chai-http')); -const User = require('../../../../models/user'); +const UsersService = require('../../../../services/users'); describe('/api/v1/auth', () => { describe('#get', () => { @@ -19,15 +19,15 @@ describe('/api/v1/auth', () => { }); }); -const Setting = require('../../../../models/setting'); +const SettingsService = require('../../../../services/settings'); describe('/api/v1/auth/local', () => { let mockUser; beforeEach(() => { const settings = {requireEmailConfirmation: false, wordlist: {banned: ['bad'], suspect: ['naughty']}}; - return Setting.init(settings).then(() => { - return User.createLocalUser('maria@gmail.com', 'password!', 'Maria') + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUser('maria@gmail.com', 'password!', 'Maria') .then((user) => { mockUser = user; }); @@ -66,7 +66,7 @@ describe('/api/v1/auth/local', () => { describe('email confirmation enabled', () => { - beforeEach(() => Setting.init({requireEmailConfirmation: true})); + beforeEach(() => SettingsService.init({requireEmailConfirmation: true})); describe('#post', () => { it('should not allow a login from a user that is not confirmed', () => { @@ -76,9 +76,9 @@ describe('/api/v1/auth/local', () => { .catch((err) => { err.response.should.have.status(401); - return User.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id); + return UsersService.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id); }) - .then(User.verifyEmailConfirmation) + .then(UsersService.verifyEmailConfirmation) .then(() => { return chai.request(app) .post('/api/v1/auth/local') diff --git a/test/routes/api/comments/index.js b/test/routes/api/comments/index.js new file mode 100644 index 000000000..c305f9f6b --- /dev/null +++ b/test/routes/api/comments/index.js @@ -0,0 +1,345 @@ +const passport = require('../../../passport'); + +const app = require('../../../../app'); +const chai = require('chai'); +const expect = chai.expect; + +// Setup chai. +chai.should(); +chai.use(require('chai-http')); + +const CommentModel = require('../../../../models/comment'); +const ActionModel = require('../../../../models/action'); + +const CommentsService = require('../../../../services/comments'); +const UsersService = require('../../../../services/users'); +const SettingsService = require('../../../../services/settings'); + +const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + +describe('/api/v1/comments', () => { + + // Ensure that the settings are always available. + beforeEach(() => SettingsService.init(settings)); + + describe('#get', () => { + const comments = [{ + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: 'REJECTED', + status_history: [{ + type: 'REJECTED' + }] + }, { + body: 'comment 30', + asset_id: '456', + status: 'ACCEPTED', + status_history: [{ + type: 'ACCEPTED' + }] + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123456789' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123456789' + }]; + + const actions = [{ + action_type: 'FLAG', + item_id: 'abc', + item_type: 'COMMENTS' + }, { + action_type: 'LIKE', + item_id: 'hij', + item_type: 'COMMENTS' + }]; + + beforeEach(() => { + return Promise.all([ + CommentModel.create(comments).then((newComments) => { + newComments.forEach((comment, i) => { + comments[i].id = comment.id; + }); + + actions[0].item_id = comments[0].id; + actions[1].item_id = comments[1].id; + + return ActionModel.create(actions); + }), + UsersService.createLocalUsers(users) + ]); + }); + + it('should return only the owner’s published comments if the user is not an admin', () => { + return chai.request(app) + .get('/api/v1/comments?user_id=456') + .set(passport.inject({id: '456', roles: []})) + .then(res => { + expect(res).to.have.status(200); + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('author_id', '456'); + }); + }); + + it('should fail if a non-admin requests comments not owned by them', () => { + return chai.request(app) + .get('/api/v1/comments?user_id=456') + .set(passport.inject({id: '123', roles: []})) + .then((res) => { + expect(res).to.be.empty; + }) + .catch((err) => { + expect(err).to.have.status(401); + }); + }); + + it('should return all the comments', () => { + return chai.request(app) + .get('/api/v1/comments') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + + expect(res).to.have.status(200); + + }); + }); + + it('should return all the rejected comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=REJECTED') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.property('comments'); + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('id', comments[2].id); + }); + }); + + it('should return all the approved comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=ACCEPTED') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('id', comments[3].id); + }); + }); + + it('should return all the new comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=NEW') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body.comments).to.have.length(2); + }); + }); + + it('should return all the flagged comments', () => { + return chai.request(app) + .get('/api/v1/comments?action_type=FLAG') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(200); + + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('id', comments[0].id); + }); + }); + }); +}); + +describe('/api/v1/comments/:comment_id', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456' + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123456789' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123456789' + }]; + + const actions = [{ + action_type: 'FLAG', + item_id: 'abc', + item_type: 'COMMENTS' + }, { + action_type: 'LIKE', + item_id: 'hij', + item_type: 'COMMENTS' + }]; + + beforeEach(() => { + return SettingsService.init(settings).then(() => { + return Promise.all([ + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) + ]); + }); + }); + + describe('#get', () => { + + it('should return the right comment for the comment_id', () => { + return chai.request(app) + .get('/api/v1/comments/abc') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.have.property('body'); + expect(res.body).to.have.property('body', 'comment 10'); + }); + }); + }); + + describe('#delete', () => { + it('it should remove comment', () => { + return chai.request(app) + .delete('/api/v1/comments/abc') + .set(passport.inject({roles: ['ADMIN']})) + .then((res) => { + expect(res).to.have.status(204); + return CommentsService.findById('abc'); + }) + .then((comment) => { + expect(comment).to.be.null; + }); + }); + }); + + describe('#put', () => { + it('it should update status', function() { + return chai.request(app) + .put('/api/v1/comments/abc/status') + .set(passport.inject({roles: ['ADMIN']})) + .send({status: 'accepted'}) + .then((res) => { + expect(res).to.have.status(204); + expect(res.body).to.be.empty; + }); + }); + + it('it should not allow a non-ADMIN to update status', () => { + return chai.request(app) + .put('/api/v1/comments/abc/status') + .set(passport.inject({roles: []})) + .send({status: 'accepted'}) + .then((res) => { + expect(res).to.be.empty; + }) + .catch((err) => { + expect(err).to.have.property('status', 401); + }); + }); + }); +}); + +describe('/api/v1/comments/:comment_id/actions', () => { + + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123', + status_history: [] + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: 'REJECTED', + status_history: [{ + type: 'REJECTED' + }] + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456', + status: 'ACCEPTED', + status_history: [{ + type: 'ACCEPTED' + }] + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123456789' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123456789' + }]; + + const actions = [{ + action_type: 'FLAG', + item_type: 'COMMENTS', + item_id: 'abc' + }, { + action_type: 'LIKE', + item_type: 'COMMENTS', + item_id: 'hij' + }]; + + beforeEach(() => { + return SettingsService.init(settings).then(() => { + return Promise.all([ + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) + ]); + }); + }); + + describe('#post', () => { + it('it should create an action', () => { + return chai.request(app) + .post('/api/v1/comments/abc/actions') + .set(passport.inject({id: '456', roles: ['ADMIN']})) + .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}}) + .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'); + expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'}); + expect(res.body).to.have.property('item_id', 'abc'); + }); + }); + }); +}); diff --git a/tests/routes/api/queue/index.js b/test/routes/api/queue/index.js similarity index 75% rename from tests/routes/api/queue/index.js rename to test/routes/api/queue/index.js index 6a7507618..848cf10e6 100644 --- a/tests/routes/api/queue/index.js +++ b/test/routes/api/queue/index.js @@ -10,10 +10,10 @@ chai.use(require('chai-http')); const Comment = require('../../../../models/comment'); const Action = require('../../../../models/action'); -const User = require('../../../../models/user'); +const UsersService = require('../../../../services/users'); -const Setting = require('../../../../models/setting'); -const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['banned'], suspect: ['suspect']}}; +const SettingsService = require('../../../../services/settings'); +const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['banned'], suspect: ['suspect']}}; describe('/api/v1/queue', () => { const comments = [{ @@ -21,26 +21,26 @@ describe('/api/v1/queue', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: 'rejected', + status: 'REJECTED', status_history: [{ - type: 'rejected' + type: 'REJECTED' }] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: 'premod', + status: 'PREMOD', status_history: [{ - type: 'premod' + type: 'PREMOD' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: 'accepted', + status: 'ACCEPTED', status_history: [{ - type: 'accepted' + type: 'ACCEPTED' }] }]; @@ -55,18 +55,18 @@ describe('/api/v1/queue', () => { }]; const actions = [{ - action_type: 'flag', + action_type: 'FLAG', item_id: 'abc', - item_type: 'comment' + item_type: 'COMMENTS' }, { - action_type: 'like', + action_type: 'LIKE', item_id: 'hij', - item_type: 'comment' + item_type: 'COMMENTS' }]; beforeEach(() => { - return Setting.init(settings).then(() => { - return User.createLocalUsers(users) + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUsers(users) .then((u) => { comments[0].author_id = u[0].id; comments[1].author_id = u[1].id; @@ -80,7 +80,7 @@ describe('/api/v1/queue', () => { return Promise.all([ Action.create(actions), - Setting.init(settings) + SettingsService.init(settings) ]); }); }); @@ -89,7 +89,7 @@ describe('/api/v1/queue', () => { 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']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); diff --git a/tests/routes/api/settings/index.js b/test/routes/api/settings/index.js similarity index 64% rename from tests/routes/api/settings/index.js rename to test/routes/api/settings/index.js index 318af81f4..dfe846edf 100644 --- a/tests/routes/api/settings/index.js +++ b/test/routes/api/settings/index.js @@ -7,12 +7,12 @@ const expect = chai.expect; chai.should(); chai.use(require('chai-http')); -const Setting = require('../../../../models/setting'); -const defaults = {id: '1', moderation: 'pre'}; +const SettingsService = require('../../../../services/settings'); +const defaults = {id: '1', moderation: 'PRE'}; describe('/api/v1/settings', () => { - beforeEach(() => Setting.init(defaults)); + beforeEach(() => SettingsService.init(defaults)); describe('#get', () => { @@ -20,12 +20,12 @@ describe('/api/v1/settings', () => { return chai.request(app) .get('/api/v1/settings') .set(passport.inject({ - roles: ['admin'] + roles: ['ADMIN'] })) .then((res) => { expect(res).to.have.status(200); expect(res).to.be.json; - expect(res.body).to.have.property('moderation', 'pre'); + expect(res.body).to.have.property('moderation', 'PRE'); }); }); }); @@ -35,15 +35,15 @@ describe('/api/v1/settings', () => { it('should update the settings', () => { return chai.request(app) .put('/api/v1/settings') - .set(passport.inject({roles: ['admin']})) - .send({moderation: 'post'}) + .set(passport.inject({roles: ['ADMIN']})) + .send({moderation: 'POST'}) .then((res) => { expect(res).to.have.status(204); - return Setting.retrieve(); + return SettingsService.retrieve(); }) .then((settings) => { - expect(settings).to.have.property('moderation', 'post'); + expect(settings).to.have.property('moderation', 'POST'); }); }); }); diff --git a/tests/routes/api/user/index.js b/test/routes/api/user/index.js similarity index 77% rename from tests/routes/api/user/index.js rename to test/routes/api/user/index.js index 3aa6cbe3c..1bb36ea2c 100644 --- a/tests/routes/api/user/index.js +++ b/test/routes/api/user/index.js @@ -5,21 +5,21 @@ 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']}}; +const SettingsService = require('../../../../services/settings'); +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'); +const UsersService = require('../../../../services/users'); describe('/api/v1/users/:user_id/email/confirm', () => { let mockUser; - beforeEach(() => Setting.init(settings).then(() => { - return User.createLocalUser('ana@gmail.com', '123321123', 'Ana'); + beforeEach(() => SettingsService.init(settings).then(() => { + return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana'); }) .then((user) => { mockUser = user; @@ -31,7 +31,7 @@ describe('/api/v1/users/:user_id/email/confirm', () => { return chai.request(app) .post(`/api/v1/users/${mockUser.id}/email/confirm`) - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { expect(res).to.have.status(204); expect(mailer.task.tasks).to.have.length(1); @@ -41,7 +41,7 @@ describe('/api/v1/users/:user_id/email/confirm', () => { 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']})) + .set(passport.inject({roles: ['ADMIN']})) .then((res) => { expect(res).to.have.status(204); expect(mailer.task.tasks).to.have.length(1); @@ -63,8 +63,8 @@ describe('/api/v1/users/:user_id/actions', () => { }]; beforeEach(() => { - return Setting.init(settings).then(() => { - return User.createLocalUsers(users); + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUsers(users); }); }); @@ -72,7 +72,7 @@ describe('/api/v1/users/:user_id/actions', () => { it('it should update actions', () => { return chai.request(app) .post('/api/v1/users/abc/actions') - .set(passport.inject({id: '456', roles: ['admin']})) + .set(passport.inject({id: '456', roles: ['ADMIN']})) .send({'action_type': 'flag', metadata: {reason: 'Bio is too awesome.'}}) .then((res) => { expect(res).to.have.status(201); diff --git a/tests/models/action.js b/test/services/actions.js similarity index 58% rename from tests/models/action.js rename to test/services/actions.js index a1ebdc027..7fdec0a12 100644 --- a/tests/models/action.js +++ b/test/services/actions.js @@ -1,30 +1,32 @@ -const Action = require('../../models/action'); +const ActionModel = require('../../models/action'); +const ActionsService = require('../../services/actions'); + const expect = require('chai').expect; -describe('models.Action', () => { +describe('services.ActionsService', () => { let mockActions = []; - beforeEach(() => Action.create([ + beforeEach(() => ActionModel.create([ { - action_type: 'flag', + action_type: 'FLAG', item_id: '123', - item_type: 'comment', + item_type: 'COMMENTS', user_id: 'flagginguserid' }, { - action_type: 'flag', + action_type: 'FLAG', item_id: '456', - item_type: 'comment' + item_type: 'COMMENTS' }, { - action_type: 'flag', + action_type: 'FLAG', item_id: '123', - item_type: 'comment' + item_type: 'COMMENTS' }, { - action_type: 'like', + action_type: 'LIKE', item_id: '123', - item_type: 'comment' + item_type: 'COMMENTS' } ]).then((actions) => { mockActions = actions; @@ -32,16 +34,16 @@ describe('models.Action', () => { describe('#findById()', () => { it('should find an action by id', () => { - return Action.findById(mockActions[0].id).then((result) => { + return ActionsService.findById(mockActions[0].id).then((result) => { expect(result).to.not.be.null; - expect(result).to.have.property('action_type', 'flag'); + expect(result).to.have.property('action_type', 'FLAG'); }); }); }); describe('#findByItemIdArray()', () => { it('should find an array of actions from an array of item_ids', () => { - return Action.findByItemIdArray(['123', '456']).then((result) => { + return ActionsService.findByItemIdArray(['123', '456']).then((result) => { expect(result).to.have.length(4); }); }); @@ -49,46 +51,48 @@ describe('models.Action', () => { describe('#getActionSummaries()', () => { it('should return properly formatted summaries from an array of item_ids', () => { - return Action.getActionSummaries(['123', '789']).then((summaries) => { - expect(summaries).to.have.length(2); + return ActionsService + .getActionSummaries(['123', '789']) + .then((summaries) => { + expect(summaries).to.have.length(2); - expect(summaries).to.deep.include({ - action_type: 'like', - count: 1, - item_id: '123', - item_type: 'comment', - current_user: null - }); + expect(summaries).to.deep.include({ + action_type: 'LIKE', + count: 1, + item_id: '123', + item_type: 'COMMENTS', + current_user: null + }); - expect(summaries).to.deep.include({ - action_type: 'flag', - count: 2, - item_id: '123', - item_type: 'comment', - current_user: null + expect(summaries).to.deep.include({ + action_type: 'FLAG', + count: 2, + item_id: '123', + item_type: 'COMMENTS', + current_user: null + }); }); - }); }); it('should include a current user when one is passed', () => { - return Action + return ActionsService .getActionSummaries(['123'], 'flagginguserid') .then((summaries) => { expect(summaries).to.have.length(2); - let summary = summaries.find((s) => s.item_id === '123' && s.action_type === 'flag'); + let summary = summaries.find((s) => s.item_id === '123' && s.action_type === 'FLAG'); expect(summary).to.not.be.undefined; expect(summary.current_user).to.not.be.null; expect(summary.current_user).to.have.property('item_id', '123'); - expect(summary.current_user).to.have.property('item_type', 'comment'); + expect(summary.current_user).to.have.property('item_type', 'COMMENTS'); expect(summary.current_user).to.have.property('user_id', 'flagginguserid'); - expect(summary.current_user).to.have.property('action_type', 'flag'); + expect(summary.current_user).to.have.property('action_type', 'FLAG'); }); }); it('should not include a current user when one is passed for a user that doesn\'t have an action', () => { - return Action + return ActionsService .getActionSummaries(['123'], 'flagginguserid2') .then((summaries) => { expect(summaries).to.have.length(2); diff --git a/tests/models/asset.js b/test/services/assets.js similarity index 68% rename from tests/models/asset.js rename to test/services/assets.js index 7c5869caf..ff8d9e175 100644 --- a/tests/models/asset.js +++ b/test/services/assets.js @@ -1,4 +1,5 @@ -const Asset = require('../../models/asset'); +const AssetModel = require('../../models/asset'); +const AssetsService = require('../../services/assets'); const chai = require('chai'); const expect = chai.expect; @@ -6,16 +7,16 @@ const expect = chai.expect; // Use the chai should. chai.should(); -describe('models.Asset', () => { +describe('services.AssetsService', () => { beforeEach(() => { const defaults = {url:'http://test.com'}; - return Asset.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); + return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); }); describe('#findById', ()=> { it('should find an asset by the id', () => { - return Asset.findById(1) + return AssetsService.findById(1) .then((asset) => { expect(asset).to.have.property('url') .and.to.equal('http://test.com'); @@ -24,17 +25,19 @@ describe('models.Asset', () => { }); describe('#findByUrl', ()=> { - beforeEach(() => Asset.findOrCreateByUrl('http://test.com')); + beforeEach(() => AssetsService.findOrCreateByUrl('http://test.com')); it('should find an asset by a url', () => { - return Asset.findByUrl('http://test.com') + return AssetsService + .findByUrl('http://test.com') .then((asset) => { expect(asset).to.have.property('url', 'http://test.com'); }); }); it('should return null when a url does not exist', () => { - return Asset.findByUrl('http://new.test.com') + return AssetsService + .findByUrl('http://new.test.com') .then((asset) => { expect(asset).to.be.null; }); @@ -43,7 +46,8 @@ describe('models.Asset', () => { describe('#findOrCreateByUrl', ()=> { it('should find an asset by a url', () => { - return Asset.findOrCreateByUrl('http://test.com') + return AssetsService + .findOrCreateByUrl('http://test.com') .then((asset) => { expect(asset).to.have.property('url') .and.to.equal('http://test.com'); @@ -51,7 +55,8 @@ describe('models.Asset', () => { }); it('should return a new asset when the url does not exist', () => { - return Asset.findOrCreateByUrl('http://new.test.com') + return AssetsService + .findOrCreateByUrl('http://new.test.com') .then((asset) => { expect(asset).to.have.property('id') .and.to.not.equal(1); @@ -61,28 +66,29 @@ describe('models.Asset', () => { describe('#overrideSettings', () => { it('should update the settings', () => { - return Asset + return AssetsService .findOrCreateByUrl('https://override.test.com/asset') .then((asset) => { expect(asset).to.have.property('settings'); expect(asset.settings).to.be.null; - return Asset.overrideSettings(asset.id, {moderation: 'pre'}); + return AssetsService.overrideSettings(asset.id, {moderation: 'PRE'}); }) .then(() => { - return Asset.findOrCreateByUrl('https://override.test.com/asset'); + return AssetsService.findOrCreateByUrl('https://override.test.com/asset'); }) .then((asset) => { expect(asset).to.have.property('settings'); expect(asset.settings).is.an('object'); - expect(asset.settings).to.have.property('moderation', 'pre'); + expect(asset.settings).to.have.property('moderation', 'PRE'); }); }); }); describe('#findOrCreateByUrl', ()=> { it('should find an asset by a url', () => { - return Asset.findOrCreateByUrl('http://test.com') + return AssetsService + .findOrCreateByUrl('http://test.com') .then((asset) => { expect(asset).to.have.property('url') .and.to.equal('http://test.com'); @@ -90,7 +96,8 @@ describe('models.Asset', () => { }); it('should return a new asset when the url does not exist', () => { - return Asset.findOrCreateByUrl('http://new.test.com') + return AssetsService + .findOrCreateByUrl('http://new.test.com') .then((asset) => { expect(asset).to.have.property('id') .and.to.not.equal(1); diff --git a/test/services/comments.js b/test/services/comments.js new file mode 100644 index 000000000..fc2105cb1 --- /dev/null +++ b/test/services/comments.js @@ -0,0 +1,259 @@ +const CommentModel = require('../../models/comment'); +const ActionModel = require('../../models/action'); + +const ActionsService = require('../../services/actions'); +const UsersService = require('../../services/users'); +const SettingsService = require('../../services/settings'); +const CommentsService = require('../../services/comments'); + +const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + +const expect = require('chai').expect; + +describe('services.CommentsService', () => { + const comments = [{ + body: 'comment 10', + asset_id: '123', + status_history: [], + parent_id: '', + author_id: '123', + id: '1' + }, { + body: 'comment 20', + asset_id: '123', + status_history: [{ + type: 'ACCEPTED' + }], + status: 'ACCEPTED', + parent_id: '', + author_id: '123', + id: '2' + }, { + body: 'comment 30', + asset_id: '456', + status_history: [], + parent_id: '', + author_id: '456', + id: '3' + }, { + body: 'comment 40', + asset_id: '123', + status_history: [{ + type: 'REJECTED' + }], + status: 'REJECTED', + parent_id: '', + author_id: '456', + id: '4' + }, { + body: 'comment 50', + asset_id: '1234', + status_history: [{ + type: 'PREMOD' + }], + status: 'PREMOD', + parent_id: '', + author_id: '456', + id: '5' + }, { + body: 'comment 60', + asset_id: '1234', + status_history: [{ + type: 'PREMOD' + }], + status: 'PREMOD', + parent_id: '', + author_id: '456', + id: '6' + }]; + + const users = [{ + email: 'stampi@gmail.com', + displayName: 'Stampi', + password: '1Coral!!' + }, { + email: 'sockmonster@gmail.com', + displayName: 'Sockmonster', + password: '2Coral!!' + }]; + + const actions = [{ + action_type: 'FLAG', + item_id: '3', + item_type: 'COMMENTS', + user_id: '123' + }, { + action_type: 'LIKE', + item_id: '1', + item_type: 'COMMENTS', + user_id: '456' + }]; + + beforeEach(() => { + return SettingsService.init(settings).then(() => { + return Promise.all([ + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) + ]); + }); + }); + + describe('#publicCreate()', () => { + + it('creates a new comment', () => { + return CommentsService + .publicCreate({ + body: 'This is a comment!', + status: 'ACCEPTED' + }).then((c) => { + expect(c).to.not.be.null; + expect(c.id).to.not.be.null; + expect(c.id).to.be.uuid; + expect(c.status).to.be.equal('ACCEPTED'); + }); + }); + + it('creates many new comments', () => { + return CommentsService + .publicCreate([{ + body: 'This is a comment!', + status: 'ACCEPTED' + }, { + body: 'This is another comment!' + }, { + body: 'This is a rejected comment!', + status: 'REJECTED' + }]).then(([c1, c2, c3]) => { + expect(c1).to.not.be.null; + expect(c1.id).to.be.uuid; + expect(c1.status).to.be.equal('ACCEPTED'); + + expect(c2).to.not.be.null; + expect(c2.id).to.be.uuid; + expect(c2.status).to.be.null; + + expect(c3).to.not.be.null; + expect(c3.id).to.be.uuid; + expect(c3.status).to.be.equal('REJECTED'); + }); + }); + + }); + + describe('#findById()', () => { + + it('should find a comment by id', () => { + return CommentsService + .findById('1') + .then((result) => { + expect(result).to.not.be.null; + expect(result).to.have.property('body', 'comment 10'); + }); + }); + + }); + + describe('#findByAssetId()', () => { + + it('should find an array of all comments by asset id', () => { + return CommentsService + .findByAssetId('123') + .then((result) => { + expect(result).to.have.length(3); + result.sort((a, b) => { + if (a.body < b.body) {return -1;} + else {return 1;} + }); + expect(result[0]).to.have.property('body', 'comment 10'); + expect(result[1]).to.have.property('body', 'comment 20'); + expect(result[2]).to.have.property('body', 'comment 40'); + }); + }); + }); + + describe('#moderationQueue()', () => { + + it('should find an array of new comments to moderate when pre-moderation', () => { + return CommentsService + .moderationQueue('PREMOD') + .then((result) => { + expect(result).to.not.be.null; + expect(result).to.have.lengthOf(2); + }); + }); + + }); + + describe('#removeAction', () => { + + it('should remove an action', () => { + return CommentsService + .removeAction('3', '123', 'flag') + .then(() => { + return ActionsService.findByItemIdArray(['123']); + }) + .then((actions) => { + expect(actions.length).to.equal(0); + }); + }); + }); + + describe('#findByUserId', () => { + it('should return all comments if admin', () => { + return CommentsService + .findByUserId('456', true) + .then(comments => { + expect(comments).to.have.length(4); + }); + }); + + it('should not return premod and rejected comments if not admin', () => { + return CommentsService + .findByUserId('456') + .then(comments => { + expect(comments).to.have.length(1); + }); + }); + + }); + + describe('#changeStatus', () => { + + it('should change the status of a comment from no status', () => { + let comment_id = comments[0].id; + + return CommentsService.findById(comment_id) + .then((c) => { + expect(c.status).to.be.null; + + return CommentsService.pushStatus(comment_id, 'REJECTED', '123'); + }) + .then(() => CommentsService.findById(comment_id)) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.equal('REJECTED'); + expect(c.status_history).to.have.length(1); + expect(c.status_history[0]).to.have.property('type', 'REJECTED'); + expect(c.status_history[0]).to.have.property('assigned_by', '123'); + }); + }); + + it('should change the status of a comment from accepted', () => { + return CommentsService.pushStatus(comments[1].id, 'REJECTED', '123') + .then(() => CommentsService.findById(comments[1].id)) + .then((c) => { + expect(c).to.have.property('status_history'); + expect(c).to.have.property('status'); + expect(c.status).to.equal('REJECTED'); + expect(c.status_history).to.have.length(2); + expect(c.status_history[0]).to.have.property('type', 'ACCEPTED'); + expect(c.status_history[0]).to.have.property('assigned_by', null); + + expect(c.status_history[1]).to.have.property('type', 'REJECTED'); + expect(c.status_history[1]).to.have.property('assigned_by', '123'); + }); + }); + + }); +}); diff --git a/tests/services/scraper.js b/test/services/scraper.js similarity index 92% rename from tests/services/scraper.js rename to test/services/scraper.js index f3b5d006c..ab2474134 100644 --- a/tests/services/scraper.js +++ b/test/services/scraper.js @@ -1,4 +1,4 @@ -describe('scraper: services', () => { +describe('services.scraper', () => { describe('#create', () => { it('should create a new kue job'); }); diff --git a/tests/models/setting.js b/test/services/settings.js similarity index 64% rename from tests/models/setting.js rename to test/services/settings.js index a7c97abfb..3620ae633 100644 --- a/tests/models/setting.js +++ b/test/services/settings.js @@ -1,19 +1,19 @@ -const Setting = require('../../models/setting'); +const SettingsService = require('../../services/settings'); const expect = require('chai').expect; -describe('models.Setting', () => { +describe('services.SettingsService', () => { - beforeEach(() => Setting.init({moderation: 'pre', wordlist: ['donut']})); + beforeEach(() => SettingsService.init({moderation: 'PRE', wordlist: ['donut']})); describe('#retrieve()', () => { it('should have a moderation field defined', () => { - return Setting.retrieve().then(settings => { - expect(settings).to.have.property('moderation').and.to.equal('pre'); + return SettingsService.retrieve().then(settings => { + expect(settings).to.have.property('moderation').and.to.equal('PRE'); }); }); it('should have two infoBox fields defined', () => { - return Setting.retrieve().then(settings => { + return SettingsService.retrieve().then(settings => { expect(settings).to.have.property('infoBoxEnable').and.to.equal(false); expect(settings).to.have.property('infoBoxContent').and.to.equal(''); }); @@ -22,10 +22,10 @@ describe('models.Setting', () => { describe('#update()', () => { it('should update the settings with a passed object', () => { - const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'}; - return Setting.update(mockSettings).then(updatedSettings => { + const mockSettings = {moderation: 'POST', infoBoxEnable: true, infoBoxContent: 'yeah'}; + return SettingsService.update(mockSettings).then(updatedSettings => { expect(updatedSettings).to.be.an('object'); - expect(updatedSettings).to.have.property('moderation').and.to.equal('post'); + expect(updatedSettings).to.have.property('moderation').and.to.equal('POST'); expect(updatedSettings).to.have.property('infoBoxEnable', true); expect(updatedSettings).to.have.property('infoBoxContent', 'yeah'); }); @@ -34,7 +34,7 @@ describe('models.Setting', () => { describe('#get', () => { it('should return the moderation settings', () => { - return Setting.retrieve().then(({moderation}) => { + return SettingsService.retrieve().then(({moderation}) => { expect(moderation).not.to.be.null; }); }); @@ -42,14 +42,14 @@ describe('models.Setting', () => { describe('#merge', () => { it('should merge a settings object and its overrides', () => { - return Setting + return SettingsService .retrieve() .then((settings) => { - let ovrSett = {moderation: 'post'}; + let ovrSett = {moderation: 'POST'}; settings.merge(ovrSett); - expect(settings).to.have.property('moderation', 'post'); + expect(settings).to.have.property('moderation', 'POST'); }); }); }); diff --git a/tests/models/user.js b/test/services/users.js similarity index 60% rename from tests/models/user.js rename to test/services/users.js index 16ccacede..e2e5655e4 100644 --- a/tests/models/user.js +++ b/test/services/users.js @@ -1,16 +1,16 @@ -const User = require('../../models/user'); -const Comment = require('../../models/comment'); -const Setting = require('../../models/setting'); +const UsersService = require('../../services/users'); +const SettingsService = require('../../services/settings'); const expect = require('chai').expect; -describe('models.User', () => { +describe('services.UsersService', () => { + let mockUsers; beforeEach(() => { - const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - return Setting.init(settings).then(() => { - return User.createLocalUsers([{ + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUsers([{ email: 'stampi@gmail.com', displayName: 'Stampi', password: '1Coral!-' @@ -30,11 +30,10 @@ describe('models.User', () => { describe('#findById()', () => { it('should find a user by id', () => { - return User + return UsersService .findById(mockUsers[0].id) .then((user) => { - expect(user).to.have.property('displayName') - .and.to.equal('stampi'); + expect(user).to.have.property('displayName', 'stampi'); }); }); }); @@ -42,7 +41,7 @@ describe('models.User', () => { describe('#findByIdArray()', () => { it('should find an array of users from an array of ids', () => { const ids = mockUsers.map((user) => user.id); - return User.findByIdArray(ids).then((result) => { + return UsersService.findByIdArray(ids).then((result) => { expect(result).to.have.length(3); }); }); @@ -51,15 +50,14 @@ describe('models.User', () => { describe('#findPublicByIdArray()', () => { it('should find an array of users from an array of ids', () => { const ids = mockUsers.map((user) => user.id); - return User.findPublicByIdArray(ids).then((result) => { + return UsersService.findPublicByIdArray(ids).then((result) => { expect(result).to.have.length(3); const sorted = result.sort((a, b) => { if(a.displayName < b.displayName) {return -1;} if(a.displayName > b.displayName) {return 1;} return 0; }); - expect(sorted[0]).to.have.property('displayName') - .and.to.equal('marvel'); + expect(sorted[0]).to.have.property('displayName', 'marvel'); }); }); }); @@ -67,7 +65,7 @@ describe('models.User', () => { describe('#findLocalUser', () => { it('should find a user when we give the right credentials', () => { - return User + return UsersService .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.have.property('displayName') @@ -76,7 +74,7 @@ describe('models.User', () => { }); it('should not find the user when we give the wrong credentials', () => { - return User + return UsersService .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.equal(false); @@ -87,9 +85,9 @@ describe('models.User', () => { describe('#createLocalUser', () => { it('should not create a user with duplicate display name', () => { - return User.createLocalUsers([{ + return UsersService.createLocalUsers([{ email: 'otrostampi@gmail.com', - displayName: 'Stampi', + displayName: 'StampiTheSecond', password: '1Coralito!' }]) .then((user) => { @@ -104,7 +102,7 @@ describe('models.User', () => { describe('#createEmailConfirmToken', () => { it('should create a token for a valid user', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; @@ -112,15 +110,15 @@ describe('models.User', () => { }); it('should not create a token for a user already verified', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }) .then(() => { - return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); + return UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); }) .catch((err) => { expect(err).to.have.property('message', 'email address already confirmed'); @@ -132,17 +130,17 @@ describe('models.User', () => { describe('#verifyEmailConfirmation', () => { it('should correctly validate a valid token', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }); }); it('should correctly reject an invalid token', () => { - return User + return UsersService .verifyEmailConfirmation('cats') .catch((err) => { expect(err).to.not.be.null; @@ -150,15 +148,15 @@ describe('models.User', () => { }); it('should update the user model when verification is complete', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }) .then(() => { - return User.findById(mockUsers[0].id); + return UsersService.findById(mockUsers[0].id); }) .then((user) => { expect(user.profiles[0]).to.have.property('metadata'); @@ -171,85 +169,42 @@ describe('models.User', () => { describe('#setStatus', () => { it('should set the status to active', () => { - return User - .setStatus(mockUsers[0].id, 'active') - .then(() => { - User.findById(mockUsers[0].id) - .then((user) => { - expect(user).to.have.property('status') - .and.to.equal('active'); - }); + return UsersService + .setStatus(mockUsers[0].id, 'ACTIVE') + .then(() => UsersService.findById(mockUsers[0].id)) + .then((user) => { + expect(user).to.have.property('status', 'ACTIVE'); }); }); }); describe('#ban', () => { - - let mockComment; - - beforeEach(() => { - return Comment.create([ - { - body: 'testing the comment for that user if it is rejected.', - id: mockUsers[0].id - } - ]) - .then(([comment]) => { - mockComment = comment; - }); - }); - it('should set the status to banned', () => { - return User - .setStatus(mockUsers[0].id, 'banned', mockComment.id) - .then(() => { - return User.findById(mockUsers[0].id); - }) + return UsersService + .setStatus(mockUsers[0].id, 'BANNED') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status') - .and.to.equal('banned'); - }); - }); - - it('should set the comment to rejected', () => { - return User - .setStatus(mockUsers[0].id, 'banned', mockComment.id) - .then(() => { - return Comment.findById(mockComment.id); - }) - .then((comment) => { - expect(comment).to.have.property('status') - .and.to.equal('rejected'); + expect(user).to.have.property('status', 'BANNED'); }); }); it('should still disable and ban the user if there is no comment', () => { - return User - .setStatus(mockUsers[0].id, 'banned', '') - .then(() => User.findById(mockUsers[0].id)) + return UsersService + .setStatus(mockUsers[0].id, 'BANNED') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status', 'banned'); + expect(user).to.have.property('status', 'BANNED'); }); }); }); describe('#unban', () => { - let mockComment; - beforeEach(() => { - return Promise.all([ - Comment.create([{body: 'testing the comment for that user if it is rejected.', id: mockUsers[0].id}]) - ]) - .then((comment) => { - mockComment = comment; - }); - }); - it('should set the status to active', () => { - return User - .setStatus(mockUsers[0].id, 'active', mockComment.id) - .then(() => User.findById(mockUsers[0].id)) + return UsersService + .setStatus(mockUsers[0].id, 'ACTIVE') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status', 'active'); + expect(user).to.have.property('status', 'ACTIVE'); }); }); }); diff --git a/tests/services/wordlist.js b/test/services/wordlist.js similarity index 91% rename from tests/services/wordlist.js rename to test/services/wordlist.js index 5bb1d32ab..6bd05e640 100644 --- a/tests/services/wordlist.js +++ b/test/services/wordlist.js @@ -1,9 +1,9 @@ const expect = require('chai').expect; const Errors = require('../../errors'); const Wordlist = require('../../services/wordlist'); -const Setting = require('../../models/setting'); +const SettingsService = require('../../services/settings'); -describe('wordlist: services', () => { +describe('services.Wordlist', () => { const wordlists = { banned: [ @@ -17,9 +17,9 @@ describe('wordlist: services', () => { }; let wordlist = new Wordlist(); - const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - beforeEach(() => Setting.init(settings)); + beforeEach(() => SettingsService.init(settings)); describe('#init', () => { diff --git a/tests/models/comment.js b/tests/models/comment.js deleted file mode 100644 index 83a081da5..000000000 --- a/tests/models/comment.js +++ /dev/null @@ -1,245 +0,0 @@ -const Comment = require('../../models/comment'); -const User = require('../../models/user'); -const Action = require('../../models/action'); -const Setting = require('../../models/setting'); - -const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - -const expect = require('chai').expect; - -describe('models.Comment', () => { - const comments = [{ - body: 'comment 10', - asset_id: '123', - status_history: [], - parent_id: '', - author_id: '123', - id: '1' - }, { - body: 'comment 20', - asset_id: '123', - status_history: [{ - type: 'accepted' - }], - status: 'accepted', - parent_id: '', - author_id: '123', - id: '2' - }, { - body: 'comment 30', - asset_id: '456', - status_history: [], - parent_id: '', - author_id: '456', - id: '3' - }, { - body: 'comment 40', - asset_id: '123', - status_history: [{ - type: 'rejected' - }], - status: 'rejected', - parent_id: '', - author_id: '456', - id: '4' - }, { - body: 'comment 50', - asset_id: '1234', - status_history: [{ - type: 'premod' - }], - status: 'premod', - parent_id: '', - author_id: '456', - id: '5' - }, { - body: 'comment 60', - asset_id: '1234', - status_history: [{ - type: 'premod' - }], - status: 'premod', - parent_id: '', - author_id: '456', - id: '6' - }]; - - const users = [{ - email: 'stampi@gmail.com', - displayName: 'Stampi', - password: '1Coral!!' - }, { - email: 'sockmonster@gmail.com', - displayName: 'Sockmonster', - password: '2Coral!!' - }]; - - const actions = [{ - action_type: 'flag', - item_id: '3', - item_type: 'comments', - user_id: '123' - }, { - action_type: 'like', - item_id: '1', - item_type: 'comments', - user_id: '456' - }]; - - beforeEach(() => { - return Setting.init(settings).then(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - }); - - describe('#publicCreate()', () => { - - it('creates a new comment', () => { - return Comment.publicCreate({ - body: 'This is a comment!', - status: 'accepted' - }).then((c) => { - expect(c).to.not.be.null; - expect(c.id).to.not.be.null; - expect(c.id).to.be.uuid; - expect(c.status).to.be.equal('accepted'); - }); - }); - - it('creates many new comments', () => { - return Comment.publicCreate([{ - body: 'This is a comment!', - status: 'accepted' - }, { - body: 'This is another comment!' - }, { - body: 'This is a rejected comment!', - status: 'rejected' - }]).then(([c1, c2, c3]) => { - expect(c1).to.not.be.null; - expect(c1.id).to.be.uuid; - expect(c1.status).to.be.equal('accepted'); - - expect(c2).to.not.be.null; - expect(c2.id).to.be.uuid; - expect(c2.status).to.be.null; - - expect(c3).to.not.be.null; - expect(c3.id).to.be.uuid; - expect(c3.status).to.be.equal('rejected'); - }); - }); - - }); - - describe('#findById()', () => { - - it('should find a comment by id', () => { - return Comment.findById('1').then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.property('body', 'comment 10'); - }); - }); - - }); - - describe('#findByAssetId()', () => { - - it('should find an array of all comments by asset id', () => { - return Comment.findByAssetId('123').then((result) => { - expect(result).to.have.length(3); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 10'); - expect(result[1]).to.have.property('body', 'comment 20'); - expect(result[2]).to.have.property('body', 'comment 40'); - }); - }); - }); - - describe('#moderationQueue()', () => { - - it('should find an array of new comments to moderate when pre-moderation', () => { - return Comment.moderationQueue('premod').then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.lengthOf(2); - }); - }); - - }); - - describe('#removeAction', () => { - - it('should remove an action', () => { - return Comment.removeAction('3', '123', 'flag') - .then(() => { - return Action.findByItemIdArray(['123']); - }) - .then((actions) => { - expect(actions.length).to.equal(0); - }); - }); - }); - - describe('#findByUserId', () => { - it('should return all comments if admin', () => { - return Comment.findByUserId('456', true) - .then(comments => { - expect(comments).to.have.length(4); - }); - }); - - it('should not return premod and rejected comments if not admin', () => { - return Comment.findByUserId('456') - .then(comments => { - expect(comments).to.have.length(1); - }); - }); - - }); - - describe('#changeStatus', () => { - - it('should change the status of a comment from no status', () => { - let comment_id = comments[0].id; - - return Comment.findById(comment_id) - .then((c) => { - expect(c.status).to.be.null; - - return Comment.pushStatus(comment_id, 'rejected', '123'); - }) - .then(() => Comment.findById(comment_id)) - .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.equal('rejected'); - expect(c.status_history).to.have.length(1); - expect(c.status_history[0]).to.have.property('type', 'rejected'); - expect(c.status_history[0]).to.have.property('assigned_by', '123'); - }); - }); - - it('should change the status of a comment from accepted', () => { - return Comment.pushStatus(comments[1].id, 'rejected', '123') - .then(() => Comment.findById(comments[1].id)) - .then((c) => { - expect(c).to.have.property('status_history'); - expect(c).to.have.property('status'); - expect(c.status).to.equal('rejected'); - expect(c.status_history).to.have.length(2); - expect(c.status_history[0]).to.have.property('type', 'accepted'); - expect(c.status_history[0]).to.have.property('assigned_by', null); - - expect(c.status_history[1]).to.have.property('type', 'rejected'); - expect(c.status_history[1]).to.have.property('assigned_by', '123'); - }); - }); - - }); -}); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js deleted file mode 100644 index e905f273d..000000000 --- a/tests/routes/api/comments/index.js +++ /dev/null @@ -1,508 +0,0 @@ -const passport = require('../../../passport'); - -const app = require('../../../../app'); -const chai = require('chai'); -const expect = chai.expect; - -// Setup chai. -chai.should(); -chai.use(require('chai-http')); - -const Comment = require('../../../../models/comment'); -const Asset = require('../../../../models/asset'); -const Action = require('../../../../models/action'); -const User = require('../../../../models/user'); - -const Setting = require('../../../../models/setting'); -const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - -describe('/api/v1/comments', () => { - - // Ensure that the settings are always available. - beforeEach(() => Setting.init(settings)); - - describe('#get', () => { - const comments = [{ - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: 'rejected', - status_history: [{ - type: 'rejected' - }] - }, { - body: 'comment 30', - asset_id: '456', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123456789' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123456789' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comments' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comments' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments).then((newComments) => { - newComments.forEach((comment, i) => { - comments[i].id = comment.id; - }); - - actions[0].item_id = comments[0].id; - actions[1].item_id = comments[1].id; - - return Action.create(actions); - }), - User.createLocalUsers(users) - ]); - }); - - it('should return only the owner’s published comments if the user is not an admin', () => { - return chai.request(app) - .get('/api/v1/comments?user_id=456') - .set(passport.inject({id: '456', roles: []})) - .then(res => { - expect(res).to.have.status(200); - expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('author_id', '456'); - }); - }); - - it('should fail if a non-admin requests comments not owned by them', () => { - return chai.request(app) - .get('/api/v1/comments?user_id=456') - .set(passport.inject({id: '123', roles: []})) - .then((res) => { - expect(res).to.be.empty; - }) - .catch((err) => { - expect(err).to.have.status(401); - }); - }); - - it('should return all the comments', () => { - return chai.request(app) - .get('/api/v1/comments') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - - expect(res).to.have.status(200); - - }); - }); - - it('should return all the rejected comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=rejected') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body).to.have.property('comments'); - expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[2].id); - }); - }); - - it('should return all the approved comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=accepted') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[3].id); - }); - }); - - it('should return all the new comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=new') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments).to.have.length(2); - }); - }); - - it('should return all the flagged comments', () => { - return chai.request(app) - .get('/api/v1/comments?action_type=flag') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(200); - - expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[0].id); - }); - }); - }); - - describe('#post', () => { - - let asset_id; - let postmod_asset_id; - - beforeEach(() => Promise.all([ - Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => { - - // Update the asset id. - asset_id = asset.id; - }), - Asset.findOrCreateByUrl('https://coralproject.net/section/postmod-article-is-the-best').then((asset) => { - - // Update the asset id. - postmod_asset_id = asset.id; - - return Asset.overrideSettings(postmod_asset_id, {moderation: 'post'}); - }), - ])); - - it('should create a comment', () => { - return chai.request(app) - .post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': 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', 'premod'); - }); - }); - - it('should create a comment with a rejected status if it contains a bad word', () => { - 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) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); - }); - }); - - it('should create a comment with no status and a flag if it contains a suspected word', () => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', null); - - return Promise.all([ - res.body, - Action.findByType('flag', 'comments') - ]); - }) - .then(([comment, actions]) => { - expect(actions).to.have.length(1); - - let action = actions[0]; - - expect(action).to.have.property('item_id', comment.id); - expect(action).to.have.property('metadata'); - expect(action.metadata).to.have.property('field', 'body'); - expect(action.metadata).to.have.property('details', 'Matched suspect word filters.'); - }); - }); - - it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - .then((asset) => { - 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': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', 'premod'); - }); - }); - - it('should create a comment with null status if it\'s asset is has post-moderation enabled', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'post'}) - .then(() => asset); - }) - .then((asset) => { - 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': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', null); - }); - }); - - it('should create a rejected comment if the body is above the character count', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {charCountEnable: true, charCount: 10}) - .then(() => asset); - }) - .then((asset) => { - 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': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', 'rejected'); - }); - }); - - it('shouldn\'t create a comment when the asset has expired commenting', () => { - return Asset.create({ - closedAt: new Date().setDate(0), - closedMessage: 'tests said expired!' - }) - .then((asset) => { - 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': ''}); - }) - .then((res) => { - expect(res).to.have.status(500); - }) - .catch((err) => { - expect(err.response.body).to.not.be.null; - expect(err.response.body).to.have.property('message'); - expect(err.response.body.error.metadata.closedMessage).to.be.equal('tests said expired!'); - }); - }); - - it('should create a comment when the asset has not expired yet', () => { - return Asset.create({ - closedAt: new Date().setDate(32), - closedMessage: 'tests said expired!' - }) - .then((asset) => { - 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': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - }); - }); - }); -}); - -describe('/api/v1/comments/:comment_id', () => { - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123456789' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123456789' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comment' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comment' - }]; - - beforeEach(() => { - return Setting.init(settings).then(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - }); - - describe('#get', () => { - - it('should return the right comment for the comment_id', () => { - return chai.request(app) - .get('/api/v1/comments/abc') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(200); - expect(res).to.have.property('body'); - expect(res.body).to.have.property('body', 'comment 10'); - }); - }); - }); - - describe('#delete', () => { - it('it should remove comment', () => { - return chai.request(app) - .delete('/api/v1/comments/abc') - .set(passport.inject({roles: ['admin']})) - .then((res) => { - expect(res).to.have.status(204); - return Comment.findById('abc'); - }) - .then((comment) => { - expect(comment).to.be.null; - }); - }); - }); - - describe('#put', () => { - it('it should update status', function() { - return chai.request(app) - .put('/api/v1/comments/abc/status') - .set(passport.inject({roles: ['admin']})) - .send({status: 'accepted'}) - .then((res) => { - expect(res).to.have.status(204); - expect(res.body).to.be.empty; - }); - }); - - it('it should not allow a non-admin to update status', () => { - return chai.request(app) - .put('/api/v1/comments/abc/status') - .set(passport.inject({roles: []})) - .send({status: 'accepted'}) - .then((res) => { - expect(res).to.be.empty; - }) - .catch((err) => { - expect(err).to.have.property('status', 401); - }); - }); - }); -}); - -describe('/api/v1/comments/:comment_id/actions', () => { - - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123', - status_history: [] - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status_history: [{ - type: 'rejected' - }] - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - status_history: [{ - type: 'accepted' - }] - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123456789' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123456789' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Setting.init(settings).then(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - }); - - describe('#post', () => { - it('it should create an action', () => { - return chai.request(app) - .post('/api/v1/comments/abc/actions') - .set(passport.inject({id: '456', roles: ['admin']})) - .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}}) - .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'); - expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'}); - expect(res.body).to.have.property('item_id', 'abc'); - }); - }); - }); -}); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js deleted file mode 100644 index 104801996..000000000 --- a/tests/routes/api/stream/index.js +++ /dev/null @@ -1,225 +0,0 @@ -const app = require('../../../../app'); -const chai = require('chai'); -const expect = chai.expect; - -// Setup chai. -chai.should(); -chai.use(require('chai-http')); - -const Action = require('../../../../models/action'); -const User = require('../../../../models/user'); -const Comment = require('../../../../models/comment'); -const Asset = require('../../../../models/asset'); - -const Setting = require('../../../../models/setting'); - -describe('/api/v1/stream', () => { - describe('#get', () => { - const settings = { - id: '1', - moderation: 'post', - wordlist: { - banned: ['banned'], - suspect: ['suspect'] - } - }; - - const assets = [ - { - url: 'https://example.com/article/1' - }, - { - url: 'https://example.com/article/2', - settings: { - moderation: 'pre' - } - }, - { - url: 'https://example.com/article/3' - } - ]; - - const comments = [{ - id: 'abc', - body: 'comment 10', - author_id: '', - parent_id: '', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - id: 'def', - body: 'comment 20', - author_id: '', - parent_id: '', - status: null, - status_history: [] - }, { - id: 'uio', - body: 'comment 30', - asset_id: 'asset', - author_id: '456', - parent_id: '', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - id: 'hij', - body: 'comment 40', - asset_id: '456', - status: 'rejected', - status_history: [{ - type: 'rejected' - }] - }, { - body: 'comment 50', - status: 'premod', - status_history: [{ - type: 'premod' - }] - }, { - body: 'comment 60', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - body: 'comment 70', - status: 'rejected', - status_history: [{ - type: 'rejected' - }] - }, { - body: 'comment 70', - status: null, - status_history: [] - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123456789' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123456789' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Setting.init(settings) - .then(() => Promise.all([ - User.createLocalUsers(users), - Promise.all(assets.map((asset) => Asset.create(asset))) - ])) - .then(([mockUsers, mockAssets]) => { - - // Map the id's over. - mockAssets.forEach((asset, i) => { - assets[i].id = asset.id; - }); - - mockUsers.forEach((user, i) => { - users[i].id = user.id; - }); - - comments.forEach((comment, i) => { - comments[i].author_id = users[(i % 2) === 0 ? 0 : 1].id; - }); - - comments[0].asset_id = assets[0].id; - comments[1].asset_id = assets[0].id; - comments[2].asset_id = assets[1].id; - comments[3].asset_id = assets[1].id; - comments[4].asset_id = assets[2].id; - comments[5].asset_id = assets[2].id; - comments[6].asset_id = assets[2].id; - comments[7].asset_id = assets[2].id; - - return Promise.all([ - Comment.create(comments), - Action.create(actions) - ]); - }); - }); - - it('should return a stream with comments, users and actions for an existing asset', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[0].url}) - .then(res => { - expect(res).to.have.status(200); - expect(res.body.assets.length).to.equal(1); - expect(res.body.comments.length).to.equal(2); - expect(res.body.users.length).to.equal(2); - expect(res.body.actions.length).to.equal(1); - expect(res.body.settings).to.have.property('moderation', 'post'); - }); - }); - - it('should reject requests without a scheme in the asset_url', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: 'test.com'}) - .catch((err) => { - expect(err).to.have.status(400); - expect(err.response.body.message).to.contain('asset_url is invalid'); - }); - }); - - it('should merge the settings when the asset contains settings to override it with', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[1].url}) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.assets).to.have.length(1); - expect(res.body.comments).to.have.length(1); - expect(res.body.users).to.have.length(1); - expect(res.body.settings).to.have.property('moderation', 'pre'); - expect(res.body.settings).to.not.have.property('wordlist'); - }); - }); - - it('should not change the previously displayed comments based on moderation state changes', () => { - - let preComments, postComments; - - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[2].url}) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments.length).to.equal(2); - expect(res.body.settings).to.have.property('moderation', 'post'); - - preComments = res.body.comments; - - return Asset.overrideSettings(assets[2].id, {moderation: 'pre'}); - }) - .then(() => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[2].url}); - }) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments.length).to.equal(2); - expect(res.body.settings).to.have.property('moderation', 'pre'); - - postComments = res.body.comments; - - expect(preComments).to.deep.equal(postComments); - }); - }); - }); -}); diff --git a/views/admin/login.ejs b/views/admin/login.ejs new file mode 100644 index 000000000..e88dd86fd --- /dev/null +++ b/views/admin/login.ejs @@ -0,0 +1,132 @@ + + + + + + Admin Login + + + + + + +
+
+ Admin Login + + + + +
+
+
+ + + + diff --git a/views/password-reset.ejs b/views/admin/password-reset.ejs similarity index 97% rename from views/password-reset.ejs rename to views/admin/password-reset.ejs index ec905c041..06b9d2a86 100644 --- a/views/password-reset.ejs +++ b/views/admin/password-reset.ejs @@ -3,7 +3,6 @@ - Password Reset @@ -122,6 +121,9 @@ url: '/api/v1/account/password/reset', contentType: 'application/json', method: 'PUT', + headers: { + 'X-CSRF-Token': '<%= csrfToken %>' + }, data: JSON.stringify({password: password, token: location.hash.replace('#', '')}) }).then(function (success) { location.href = '<%= redirectUri %>'; diff --git a/views/article.ejs b/views/article.ejs index 302d76572..c63acc62b 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -46,6 +46,9 @@ } }, 100); }); + pymParent.onMessage('navigate', function (url) { + window.open(url, '_blank').focus(); + }) // wait till images and other iframes are loaded before scrolling the page. // or do we want to be more aggressive and scroll when we hit DOM ready? diff --git a/views/embed-stream.ejs b/views/embed-stream.ejs index a05b699f1..c69773bcb 100644 --- a/views/embed-stream.ejs +++ b/views/embed-stream.ejs @@ -31,7 +31,11 @@ pymParent.sendMessage('DOMContentLoaded', window.location.hash); } }, 100); - }); + } + pymParent.onMessage('navigate', function (url) { + window.open(url, '_blank').focus(); + }) + ); // wait till images and other iframes are loaded before scrolling the page. // or do we want to be more aggressive and scroll when we hit DOM ready? diff --git a/webpack.config.dev.js b/webpack.config.dev.js index 913ee35da..539233bef 100644 --- a/webpack.config.dev.js +++ b/webpack.config.dev.js @@ -70,6 +70,11 @@ module.exports = { { loader: 'url?limit=100000', test: /\.woff$/ + }, + { + test: /\.(graphql|gql)$/, + exclude: /node_modules/, + loader: 'graphql-tag/loader' } ] },