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 new file mode 100644 index 000000000..4289167f7 --- /dev/null +++ b/.nodemon.json @@ -0,0 +1,4 @@ +{ + "verbose": true, + "ignore": ["test/*", "client/*", "dist/*"] +} 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 2d466966f..8bc0d4e4c 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 => { diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index 07b2224f3..df68b72c4 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -65,9 +65,9 @@ export const fetchFlaggedQueue = () => { dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); return coralApi('/queue/comments/flagged') - .then(comments => { - comments.forEach(comment => comment.flagged = true); - return comments; + .then(results => { + results.comments.forEach(comment => comment.flagged = true); + return results; }) .then(addUsersCommentsActions.bind(this, dispatch)); }; diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/components/BanUserDialog.css index dfac4f194..054c343dd 100644 --- a/client/coral-admin/src/components/BanUserDialog.css +++ b/client/coral-admin/src/components/BanUserDialog.css @@ -1,17 +1,25 @@ .dialog { - border: none; - box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); - width: 280px; - top: 10px; -} + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 500px; + top: 50%; + transform: translateY(-50%); + height: 184px; + padding: 20px; -.header { - margin-bottom: 20px; -} + h2 { + color: black; + font-size: 1.76em; + font-weight: 500; + margin: 0; + } -.header h1, .separator h1{ - text-align: center; - font-size: 1.2em; + h3 { + color: black; + font-size: 1.4em; + font-weight: 500; + margin: 0; + } } .formField { @@ -143,5 +151,14 @@ input.error{ } .cancel { - margin: 10px 0; + margin-right: 10px; + width: 47%; +} + +.ban { + width: 47%; +} + +.buttons { + margin: 20px 0; } diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js index f1d289fe6..c393df21d 100644 --- a/client/coral-admin/src/components/BanUserDialog.js +++ b/client/coral-admin/src/components/BanUserDialog.js @@ -19,23 +19,23 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => ( ×
-

+

{lang.t('bandialog.ban_user')} -

+
-

+

{lang.t('bandialog.are_you_sure', user.userName)} -

+ {lang.t('bandialog.note')}
- -
diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js index 40b4c070c..bbc6078b7 100644 --- a/client/coral-admin/src/components/Comment.js +++ b/client/coral-admin/src/components/Comment.js @@ -7,9 +7,8 @@ import styles from './ModerationList.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; -import {Icon} from 'react-mdl'; import Highlighter from 'react-highlight-words'; -import {FabButton, Button} from 'coral-ui'; +import {FabButton, Button, Icon} from 'coral-ui'; const linkify = new Linkify(); @@ -20,22 +19,19 @@ const Comment = props => { const links = linkify.getMatches(comment.body); return ( -
  • +
  • - person {author.displayName || lang.t('comment.anon')} {timeago().format(comment.createdAt || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} {comment.flagged ?

    {lang.t('comment.flagged')}

    : null}
    -
    +
    {links ? Contains Link : null}
    {props.modActions.map((action, i) => getActionButton(action, i, props))}
    -
    -
    {authorStatus === 'banned' ? {lang.t('comment.banned_user')} : null}
    @@ -65,15 +61,19 @@ const getActionButton = (option, i, props) => { } if (option === 'ban') { return ( - +
    + +
    ); } const menuOption = menuOptionsMap[option]; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index 3f695095f..fbcbce7d7 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -36,13 +36,33 @@ .listItem { border-bottom: 1px solid #e0e0e0; - padding: 16px; font-size: 16px; + width: 100%; + max-width: 660px; + min-width: 400px; + margin: 0 auto; + padding: 16px 14px; + position: relative; + transition: box-shadow 200ms; + + + &:hover { + box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + } &:last-child { border-bottom: none; } + .sideActions { + position: absolute; + right: 0; + height: 100%; + top: 0; + padding: 40px 18px; + box-sizing: border-box; + } + .itemHeader { display: flex; align-items: center; @@ -72,25 +92,20 @@ .created { color: #666; - font-size: 10px; - margin-left: 10px; + font-size: 13px; + margin-left: 40px; } .actionButton { - transform: scale(.7); + transform: scale(.8); margin: 0; } .body { margin-top: 20px; flex: 1; - font-size: 1em; - color: rgba(0,0,0,.54); - } - - .actions { - margin-left: 10px; - display: flex; + font-size: 0.88em; + color: black; } .flagged { @@ -150,3 +165,20 @@ margin-right: 5px; } } + +.ban { + display: block; + text-align: center; + margin-top: 5px; +} + +.banButton { + width: 114px; + letter-spacing: 1px; + + i { + vertical-align: middle; + margin-right: 10px; + font-size: 14px; + } +} diff --git a/client/coral-admin/src/components/ui/Header.css b/client/coral-admin/src/components/ui/Header.css index a0d7dd30d..8fbdedc0a 100644 --- a/client/coral-admin/src/components/ui/Header.css +++ b/client/coral-admin/src/components/ui/Header.css @@ -1,40 +1,42 @@ .header { - background: #505050; + background-color: transparent; + box-shadow: none; + min-height: 58px; } .header > div { - position: relative; - padding: 0; - width: 1170px; - margin: 0 auto; -} - -.active { - background: #232323; + background-color: #696969; + position: relative; + padding: 0; + min-width: 1280px; + margin: 0 auto; + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); + height: 58px; } .rightPanel { - position: absolute; - right: 0; - width: 170px; + position: absolute; + right: 0; + width: 170px; + height: 100%; } .rightPanel ul { list-style: none; - line-height: 38px; + margin-right: 20px; } .rightPanel li { display: inline-block; float: right; margin-left: 15px; + font-size: 15px; + font-weight: 500; + line-height: 33px; } .rightPanel .settings { - vertical-align: middle; - border-radius: 3px; - border: solid 1px #9e9e9e; - line-height: 10px; + line-height: 0; } .rightPanel .settings > div { @@ -45,3 +47,25 @@ background: rgba(158, 158, 158, 0.69); cursor: pointer; } + +.navLink { + padding: 0 20px; + font-size: 15px; + font-weight: 500; + background-color: transparent; + transition: background-color 200ms; + + &.active { + background-color: #232323; + } +} + +.nav { + overflow: hidden; + height: 58px !important; +} + +.nav .navLink { + padding: 0 20px; + letter-spacing: 0.4px; +} diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 4c76424cc..4e2b391c8 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -9,7 +9,7 @@ import {Logo} from './Logo'; export default ({handleLogout}) => (
    - + {lang.t('configure.moderate')} (

    - + Talk

    diff --git a/client/coral-admin/src/containers/Community/Community.css b/client/coral-admin/src/containers/Community/Community.css index b19d0261d..6c101fcaf 100644 --- a/client/coral-admin/src/containers/Community/Community.css +++ b/client/coral-admin/src/containers/Community/Community.css @@ -1,17 +1,96 @@ -.roleButton { - display: block; +.container { + padding: 10px; + display: flex; + padding-bottom: 200px; } -.searchInput { - display: block; - padding-left: 40px; - width: auto; +.leftColumn { + padding: 42px 56px; + width: 234px; +} + +.mainContent { + width: calc(100% - 300px); + padding: 34px 14px; + box-sizing: border-box; +} + +.roleButton { + display: block; } .searchBox { - background: white; + width: 100%; + padding: 9px; + border: 1px solid #ccc; + border-radius: 2px; + display: flex; + background: white; + box-sizing: border-box; + height: 40px; + + i { + color: #A1A1A1 + } + + input { + display: block; + width: 100%; + height: 100%; + border: none; + font-size: 16px; + padding: 0 2px 0 15px; + box-sizing: border-box; + } } .email { - display: block; + display: block; +} + +.dataTable { + width: 100%; + border-left: none; + border-right: none; + + th { + font-size: 1.1em; + } + + th:nth-child(2), th:nth-child(3) { + width: 100px; + } +} + +.selectField { + position: relative; + width: 150px; + height: 36px; + background: #2c2c2c; + padding: 10px 15px; + box-sizing: border-box; + color: white; + border-radius: 2px; + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); + + > div { + padding: 0; + } + + i { + position: absolute; + top: 7px; + right: 7px; + } + + input { + padding: 0; + font-size: 13px; + letter-spacing: 0.7px; + font-weight: 400; + } + + &:hover { + cursor: pointer; + } } diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/Community.js index 286a7f364..d63dd1e96 100644 --- a/client/coral-admin/src/containers/Community/Community.js +++ b/client/coral-admin/src/containers/Community/Community.js @@ -1,13 +1,12 @@ import React from 'react'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; -import {Grid, Cell} from 'react-mdl'; import styles from './Community.css'; import Table from './Table'; import Loading from './Loading'; import NoResults from './NoResults'; -import Pager from 'coral-ui/components/Pager'; +import {Pager} from 'coral-ui'; const lang = new I18n(translations); @@ -33,17 +32,17 @@ const tableHeaders = [ const Community = ({isFetching, commenters, ...props}) => { const hasResults = !isFetching && !!commenters.length; return ( - - +
    +
    -
    -
    +
    { isFetching && } { !hasResults && } { hasResults && @@ -68,8 +67,8 @@ const Community = ({isFetching, commenters, ...props}) => { page={props.page} onNewPageHandler={props.onNewPageHandler} /> - - +
    +
    ); }; diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 1d81b1b2e..254550e74 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -52,19 +52,21 @@ class Table extends Component { this.onCommenterStatusChange(row.id, status)}> - - + + 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 6000c1d43..0ccb5f820 100644 --- a/client/coral-admin/src/containers/Configure/CommentSettings.js +++ b/client/coral-admin/src/containers/Configure/CommentSettings.js @@ -3,15 +3,8 @@ import {SelectField, Option} from 'react-mdl-selectfield'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import styles from './Configure.css'; -import { - List, - ListItem, - ListItemContent, - ListItemAction, - Textfield, - Checkbox, - Icon -} from 'react-mdl'; +import {Textfield, Checkbox} from 'react-mdl'; +import {Card, Icon, Spinner} from 'coral-ui'; const TIMESTAMPS = { weeks: 60 * 60 * 24 * 7, @@ -35,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}); }; @@ -71,35 +64,32 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => { const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => { if (fetchingSettings) { - - /* maybe a spinner here at some point */ - return

    Loading settings...

    ; + return Loading settings...; } return ( -
    +

    {title}

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

    +

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

    - - - - +
    +
    + +
    - - +
    +
    {lang.t('configure.comment-count-header')}

    {lang.t('configure.comment-count-text-pre')} @@ -118,32 +108,44 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError }

    - - - - +
    +
    + +
    - - +
    +
    {lang.t('configure.include-comment-stream')}

    {lang.t('configure.include-comment-stream-desc')}

    - - - - +
    +
    + +
    +
    +
    +
    + +
    + {lang.t('configure.closed-comments-desc')} +
    - - - - +
    +
    +
    + +
    {lang.t('configure.close-after')}
    {lang.t('configure.weeks')}
    - -
    - - - {lang.t('configure.closed-comments-desc')} - - - -
    +
    +
    ); }; diff --git a/client/coral-admin/src/containers/Configure/Configure.css b/client/coral-admin/src/containers/Configure/Configure.css index 2b5c8d578..1f23af30b 100644 --- a/client/coral-admin/src/containers/Configure/Configure.css +++ b/client/coral-admin/src/containers/Configure/Configure.css @@ -1,26 +1,29 @@ .container { - padding: 10px; display: flex; + + h3 { + color: black; + font-size: 1.76em; + font-weight: 500; + } } .leftColumn { - width: 300px; + padding: 42px 56px; + width: 234px; } .mainContent { - width: calc(70% - 300px) -} - -.settingOption { - cursor: pointer; + width: calc(100% - 300px); + padding: 10px 14px; + box-sizing: border-box; + max-width: 718px; } .configSetting { - border: 1px solid #ccc; - border-radius: 4px; - height: 95px; - margin-bottom: 10px; + margin-bottom: 20px; align-items: flex-start; + min-height: 100px; } .settingsError { @@ -42,13 +45,13 @@ } .configSettingInfoBox { - border: 1px solid #ccc; - border-radius: 4px; - margin-bottom: 10px; + min-height: 100px; + margin-bottom: 20px; cursor: pointer; width: auto; height: auto; text-align: left; + overflow: visible; } .configSettingInfoBox p { @@ -77,7 +80,7 @@ } .charCountTexfieldEnabled { - border-color: #4caf50; + border-color: #00796b; } .charCountTexfield:focus { @@ -85,11 +88,12 @@ } .changedSave { - background-color:#4caf50; + background-color: #00796B; + color: white; } .copiedText { - color: #008000; + color: #00796b; float: right; padding: 12px; font-size: 14px; @@ -97,6 +101,7 @@ .copyButton { float: right; + width: 200px; } .embedInput { @@ -113,8 +118,12 @@ } #bannedWordlist, #suspectWordlist { + width: 100%; + padding: 10px; + + input { width: 100%; - padding: 10px; + } } .wordlistHeader { @@ -124,7 +133,7 @@ } .enabledSetting { - border-left-color: #4caf50; + border-left-color: #00796b; border-left-style: solid; border-left-width: 7px; } @@ -136,3 +145,23 @@ .hidden { display: none; } + +.saveBox { + margin-top: 38px; +} + +.commentSettingsSection { + padding-bottom: 200px; + .action { + display: inline-block; + position: absolute; + top: 0; + left: 0; + padding: 20px; + } + + .content { + display: inline-block; + padding-left: 30px; + } +} diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index 9fc24ad53..8ad6cacfd 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {Component} from 'react'; import {connect} from 'react-redux'; import { fetchSettings, @@ -6,13 +6,8 @@ import { saveSettingsToServer, updateWordlist, } from '../../actions/settings'; -import { - List, - ListItem, - ListItemContent, - Button, - Icon -} from 'react-mdl'; + +import {Button, List, Item} from 'coral-ui'; import styles from './Configure.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; @@ -21,7 +16,7 @@ import CommentSettings from './CommentSettings'; import Wordlist from './Wordlist'; import has from 'lodash/has'; -class Configure extends React.Component { +class Configure extends Component { constructor (props) { super(props); @@ -30,6 +25,8 @@ class Configure extends React.Component { changed: false, errors: {} }; + + this.changeSection = this.changeSection.bind(this); } componentWillMount = () => { @@ -41,7 +38,7 @@ class Configure extends React.Component { this.setState({changed: false}); } - changeSection = (activeSection) => () => { + changeSection(activeSection) { this.setState({activeSection}); } @@ -99,7 +96,8 @@ class Configure extends React.Component { } render () { - const section = this.getSection(this.state.activeSection); + const {activeSection} = this.state; + const section = this.getSection(activeSection); const showSave = Object.keys(this.state.errors).reduce( (bool, error) => this.state.errors[error] ? false : bool, this.state.changed); @@ -107,37 +105,40 @@ class Configure extends React.Component { return (
    - - - {lang.t('configure.comment-settings')} - - - {lang.t('configure.embed-comment-stream')} - - - {lang.t('configure.wordlist')} - + + + {lang.t('configure.comment-settings')} + + + {lang.t('configure.embed-comment-stream')} + + + {lang.t('configure.wordlist')} + +
    { showSave ? - - : + + : + } +
    diff --git a/client/coral-admin/src/containers/Configure/EmbedLink.js b/client/coral-admin/src/containers/Configure/EmbedLink.js index 374d78ea3..105b531c5 100644 --- a/client/coral-admin/src/containers/Configure/EmbedLink.js +++ b/client/coral-admin/src/containers/Configure/EmbedLink.js @@ -2,11 +2,7 @@ import React, {Component} from 'react'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import styles from './Configure.css'; -import { - List, - ListItem, - Button -} from 'react-mdl'; +import {Button, Card} from 'coral-ui'; class EmbedLink extends Component { @@ -34,16 +30,16 @@ class EmbedLink extends Component { return (

    {this.props.title}

    - - +
    +

    {lang.t('configure.copy-and-paste')}

    + +
    ; + } +} + +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..1ff12bdef 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/client/coral-sign-in/components/SignDialog.js b/client/coral-sign-in/components/SignDialog.js index 508a8a48f..6243472b4 100644 --- a/client/coral-sign-in/components/SignDialog.js +++ b/client/coral-sign-in/components/SignDialog.js @@ -3,7 +3,7 @@ import {Dialog} from 'coral-ui'; import styles from './styles.css'; import SignInContent from './SignInContent'; -import SingUpContent from './SignUpContent'; +import SignUpContent from './SignUpContent'; import ForgotContent from './ForgotContent'; const SignDialog = ({open, view, handleClose, offset, ...props}) => ( @@ -17,7 +17,7 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => ( }}> × {view === 'SIGNIN' && } - {view === 'SIGNUP' && } + {view === 'SIGNUP' && } {view === 'FORGOT' && } ); diff --git a/client/coral-sign-in/components/SignUpContent.js b/client/coral-sign-in/components/SignUpContent.js index 2f9e9afe1..4144b1d2f 100644 --- a/client/coral-sign-in/components/SignUpContent.js +++ b/client/coral-sign-in/components/SignUpContent.js @@ -56,7 +56,7 @@ const SignUpContent = ({handleChange, formData, ...props}) => ( onChange={handleChange} minLength="8" /> - { !props.errors.password && Password must be at least 8 characters. } + { props.errors.password && Password must be at least 8 characters. } ( +const Button = ({cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props}) => ( ); diff --git a/client/coral-ui/components/Card.css b/client/coral-ui/components/Card.css new file mode 100644 index 000000000..d4b5b1e91 --- /dev/null +++ b/client/coral-ui/components/Card.css @@ -0,0 +1,35 @@ +.base { + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + font-size: 16px; + font-weight: 400; + min-height: 200px; + overflow: hidden; + width: 330px; + z-index: 1; + position: relative; + background: #fff; + border-radius: 2px; + box-sizing: border-box; + + width: 100%; + padding: 20px; +} + +.shadow--4 { + box-shadow: 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12), 0 2px 4px -1px rgba(0,0,0,.2); +} + +.shadow--2{ + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); +} + +.shadow--3 { + box-shadow: 0 3px 4px 0 rgba(0,0,0,.14), 0 3px 3px -2px rgba(0,0,0,.2), 0 1px 8px 0 rgba(0,0,0,.12); +} + + diff --git a/client/coral-ui/components/Card.js b/client/coral-ui/components/Card.js new file mode 100644 index 000000000..0bdb490e7 --- /dev/null +++ b/client/coral-ui/components/Card.js @@ -0,0 +1,8 @@ +import React from 'react'; +import styles from './Card.css'; + +export default ({children, className, shadow = 2, ...props}) => ( +
    + {children} +
    +); diff --git a/client/coral-ui/components/CoralLogo.css b/client/coral-ui/components/CoralLogo.css new file mode 100644 index 000000000..6091cffcb --- /dev/null +++ b/client/coral-ui/components/CoralLogo.css @@ -0,0 +1,8 @@ +.base { + height: 30px; + width : 30px; +} + +.mark { + stroke: #FFFFFF; +} diff --git a/client/coral-ui/components/CoralLogo.js b/client/coral-ui/components/CoralLogo.js index f85fd6f2d..002b0cabc 100644 --- a/client/coral-ui/components/CoralLogo.js +++ b/client/coral-ui/components/CoralLogo.js @@ -1,9 +1,10 @@ import React, {PropTypes} from 'react'; +import styles from './CoralLogo.css'; -const CoralLogo = ({height = '30px', width = '30px', stroke = '#FFFFFF'}) => ( - +const CoralLogo = ({className = ''}) => ( + - + diff --git a/client/coral-ui/components/FabButton.css b/client/coral-ui/components/FabButton.css index 688190407..c90d857d9 100644 --- a/client/coral-ui/components/FabButton.css +++ b/client/coral-ui/components/FabButton.css @@ -1,9 +1,25 @@ +.base { + background: red; + i { + font-size: 30px; + transform: translate(-14px,-12px) !important; + } +} + .type--approve { - background: #00796b; - color: rgba(255, 255, 255, 0.901961); + background: #388E3C; + color: rgba(255, 255, 255, 0.901961); +} + +.type--approve:hover { + background: #40a244; } .type--reject { - background: #d32f2f ; - color: rgba(255, 255, 255, 0.901961); + background: #D32F2F ; + color: rgba(255, 255, 255, 0.901961); +} + +.type--reject:hover { + background: #e53333; } diff --git a/client/coral-ui/components/FabButton.js b/client/coral-ui/components/FabButton.js index df48af55d..64ba4e82a 100644 --- a/client/coral-ui/components/FabButton.js +++ b/client/coral-ui/components/FabButton.js @@ -3,7 +3,7 @@ import styles from './FabButton.css'; import {FABButton, Icon} from 'react-mdl'; const FabButton = ({cStyle = 'local', icon, className, ...props}) => ( - + ); diff --git a/client/coral-ui/components/Icon.js b/client/coral-ui/components/Icon.js new file mode 100644 index 000000000..65bf52f72 --- /dev/null +++ b/client/coral-ui/components/Icon.js @@ -0,0 +1,8 @@ +import React from 'react'; +import {Icon as IconMDL} from 'react-mdl'; + +const Icon = ({className, name}) => ( + +); + +export default Icon; diff --git a/client/coral-ui/components/Item.css b/client/coral-ui/components/Item.css new file mode 100644 index 000000000..3a608d28c --- /dev/null +++ b/client/coral-ui/components/Item.css @@ -0,0 +1,24 @@ +.base { + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); + background-color: white; + width: 208px; + padding: 10px 14px; + font-size: 14px; + margin-bottom: 10px; + list-style: none; + + &:hover { + cursor: pointer; + } + + &.active { + color: white; + background-color: #262626; + } + + i { + margin-right: 13px; + font-size: 18px; + vertical-align: middle; + } +} diff --git a/client/coral-ui/components/Item.js b/client/coral-ui/components/Item.js new file mode 100644 index 000000000..ecb81ab88 --- /dev/null +++ b/client/coral-ui/components/Item.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './Item.css'; +import Icon from './Icon'; + +export default ({children, itemId, active, onItemClick, className = '', icon}) => ( +
  • onItemClick(itemId)} + > + {icon && } + {children} +
  • +); diff --git a/client/coral-ui/components/List.css b/client/coral-ui/components/List.css new file mode 100644 index 000000000..82dc6b010 --- /dev/null +++ b/client/coral-ui/components/List.css @@ -0,0 +1,4 @@ +.base { + padding: 0; + margin: 0; +} diff --git a/client/coral-ui/components/List.js b/client/coral-ui/components/List.js new file mode 100644 index 000000000..500f2307d --- /dev/null +++ b/client/coral-ui/components/List.js @@ -0,0 +1,32 @@ +import React, {Component} from 'react'; +import styles from './List.css'; + +export default class List extends Component { + constructor(props) { + super(props); + this.handleClickItem = this.handleClickItem.bind(this); + } + + handleClickItem(itemId) { + if (this.props.onChange) { + this.props.onChange(itemId); + } + } + + render() { + const {children, activeItem, className = ''} = this.props; + return ( +
      + {React.Children.toArray(children) + .filter(child => !child.props.restricted) + .map((child, i) => + React.cloneElement(child, { + i, + active: child.props.itemId === activeItem, + onItemClick: this.handleClickItem, + }) + )} +
    + ); + } +} diff --git a/client/coral-ui/components/Pager.css b/client/coral-ui/components/Pager.css index 82e96f727..42559d11d 100644 --- a/client/coral-ui/components/Pager.css +++ b/client/coral-ui/components/Pager.css @@ -1,10 +1,19 @@ -.li { +.pager { + text-align: center; + + li { display: inline-block; margin-right: 5px; - padding: 0; - min-width: 30px; + color: white; + height: 30px; + text-align: center; + vertical-align: middle; + line-height: 30px; + width: 30px; + } } .current { - background: #e3edf3; -} \ No newline at end of file + background: #696969; + box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12); +} diff --git a/client/coral-ui/components/Pager.js b/client/coral-ui/components/Pager.js index fca20b0db..ffd1b9d0a 100644 --- a/client/coral-ui/components/Pager.js +++ b/client/coral-ui/components/Pager.js @@ -2,19 +2,18 @@ import React, {PropTypes} from 'react'; import styles from './Pager.css'; const Rows = (curr, total, onClickHandler) => Array.from(Array(total)).map((e, i) => -
  • onClickHandler(i + 1)}> {i + 1}
  • ); const Pager = ({totalPages, page, onNewPageHandler}) => ( -
    +
      { (totalPages > page && totalPages > 1) ?
    • onNewPageHandler(page - 1)}> Prev
    • @@ -25,7 +24,6 @@ const Pager = ({totalPages, page, onNewPageHandler}) => ( { (page < totalPages && totalPages > 1) ?
    • onNewPageHandler(page + 1)}> Next
    • diff --git a/client/coral-ui/components/TabBar.js b/client/coral-ui/components/TabBar.js index 2a673f78c..f22250194 100644 --- a/client/coral-ui/components/TabBar.js +++ b/client/coral-ui/components/TabBar.js @@ -1,7 +1,7 @@ import React from 'react'; import styles from './TabBar.css'; -export class TabBar extends React.Component { +class TabBar extends React.Component { constructor(props) { super(props); this.handleClickTab = this.handleClickTab.bind(this); diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 71b0d1d7e..23d5d876a 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -9,3 +9,8 @@ export {default as Spinner} from './components/Spinner'; export {default as Tooltip} from './components/Tooltip'; export {default as PopupMenu} from './components/PopupMenu'; export {default as Checkbox} from './components/Checkbox'; +export {default as Icon} from './components/Icon'; +export {default as List} from './components/List'; +export {default as Item} from './components/Item'; +export {default as Card} from './components/Card'; +export {default as Pager} from './components/Pager'; diff --git a/docs/swagger.yaml b/docs/swagger.yaml index eeb9d6383..6358f8f06 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -924,7 +924,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/errors.js b/errors.js index b5b217b61..1b26e7170 100644 --- a/errors.js +++ b/errors.js @@ -54,6 +54,11 @@ const ErrEmailTaken = new APIError('Email address already in use', { status: 400 }); +const ErrDisplayTaken = new APIError('Display name already in use', { + translation_key: 'DISPLAYNAME_IN_USE', + status: 400 +}); + const ErrSpecialChars = new APIError('No special characters are allowed in a display name', { translation_key: 'NO_SPECIAL_CHARACTERS', status: 400 @@ -116,6 +121,7 @@ module.exports = { ErrSpecialChars, ErrMissingDisplay, ErrContainsProfanity, + ErrDisplayTaken, ErrAssetCommentingClosed, ErrNotFound, ErrInvalidAssetURL, 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..35463dfd0 --- /dev/null +++ b/graph/loaders/comments.js @@ -0,0 +1,84 @@ +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. + * @param {Array} ids array of ids to lookup + */ +const genCommentsByAssetID = (context, ids) => CommentModel.find({ + asset_id: { + $in: ids + }, + parent_id: null, + status: { + $in: [null, 'ACCEPTED'] + } +}).then(util.arrayJoinBy(ids, 'asset_id')); + +/** + * Retrieves comments by an array of parent ids. + * @param {Array} ids array of ids to lookup + */ +const genCommentsByParentID = (context, ids) => CommentModel.find({ + parent_id: { + $in: ids + }, + status: { + $in: [null, 'ACCEPTED'] + } +}).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) + } + }); + + if (asset_id) { + comments = comments.where({asset_id}); + } + + return comments; + }); +}; + +const genCommentsByAuthorID = (context, authorIDs) => CommentModel.find({ + author_id: { + $in: authorIDs + } +}).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..cf80c496d --- /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) { + 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..6bd7cea26 --- /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) { + 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..22837caae --- /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) { + 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.js b/graph/typeDefs.js new file mode 100644 index 000000000..0665a6132 --- /dev/null +++ b/graph/typeDefs.js @@ -0,0 +1,185 @@ +// 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 typeDefs = [` +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 +} +`]; + +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 97be21b26..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,187 +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' - }, - metadata: { - $push: '$metadata' - }, - created_at: { - $min: '$created_at' - }, - updated_at: { - $max : '$updated_at' - }, - 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 actions for a specific type. - * @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 d99cbed06..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,244 +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 -}); - -/** - * Finds the accepted comments by the asset_id get the comments that are - * accepted. - * @param {String} asset_id identifier of the asset which owns the comments (uuid) - * @return {Promise} - */ -CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ - asset_id, - status: 'accepted' -}); - -/** - * Finds the new and accepted comments by the asset_id. - * @param {String} asset_id identifier of the asset which owns the comments (uuid) - * @return {Promise} - */ -CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ - asset_id, - $or: [ - {status: 'accepted'}, - {status: null} - ] -}); - -/** - * 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) => { - - /** - * This adds the asset_id requirement to the query if the asset_id is defined. - */ - const assetIDWrap = (query) => { - if (asset_id) { - query = query.where('asset_id', asset_id); - } - - return query; - }; - - // Pre-moderation: New comments are shown in the moderator queues immediately. - let comments = assetIDWrap(Comment.findByStatus(status)); - - 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 3a7ffd3ad..52111a4bd 100644 --- a/models/user.js +++ b/models/user.js @@ -1,42 +1,20 @@ 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', - 'pending', - 'suspended', - 'banned' + 'ACTIVE', + 'BANNED', + 'PENDING', + 'SUSPENDED', ]; -// 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({ @@ -83,7 +61,11 @@ const UserSchema = new mongoose.Schema({ // This is sourced from the social provider or set manually during user setup // and simply provides a name to display for the given user. - displayName: String, + displayName: { + type: String, + unique: true, + required: true + }, // This is true when the user account is disabled, no action should be // acknowledged when they are disabled. Logins are also prevented. @@ -101,11 +83,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: { @@ -133,625 +122,19 @@ 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; -}; - -/** - * Filters the object for the given user only allowing those with the allowed - * roles/permissions to access particular parameters. - */ -UserSchema.method('filterForUser', function(user = false) { - if (!user || !user.roles.includes('admin')) { - let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at']; - - if (user && user.id === this.id) { - allowed.push('roles'); - } - - return _.pick(this.toJSON(), allowed); - } - - return this.toJSON(); + // TODO: remove toUpperCase() once we've migrated usage. + return this.roles.indexOf(role.toUpperCase()) >= 0; + }); }); // 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) { - 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' || status === 'suspended') { - return UserModel.update({ - id: id - }, { - $set: { - status: status - } - }) - .then(() => { - return comment_id ? Comment.pushStatus(comment_id, 'rejected', id) : - Promise.resolve(); - }); - } - - if (status === 'active' || status === 'pending') { - 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 -}); - -/** - * Returns all users with pending moderation actions. - * @return {Promise} - */ -UserService.moderationQueue = () => { - return UserModel.find({status: 'pending'}); -}; - -/* - * 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 e6e211d07..59b31eb69 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "main": "app.js", "scripts": { "start": "./bin/cli serve --jobs", - "dev-start": "nodemon --exec \"./bin/cli -c .env serve --jobs\"", + "dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"", "build": "NODE_ENV=production webpack --config webpack.config.js --bail", "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", "embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs", @@ -48,18 +48,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", @@ -74,6 +80,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" }, @@ -115,6 +122,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 32fa6f3bf..a0756b4c3 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) @@ -79,12 +80,11 @@ router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) // Returns back all the users that are in the moderation queue. router.get('/users/pending', (req, res, next) => { - - User.moderationQueue() + UsersService.moderationQueue() .then((users) => { return Promise.all([ users, - Action.getActionSummaries(users.map((user) => user.id)) + ActionsService.getActionSummaries(users.map((user) => user.id)) ]); }) .then(([users, actions]) => { 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 540e36e52..000000000 --- a/routes/api/stream/index.js +++ /dev/null @@ -1,121 +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); - } - - // Fetch the appropriate comments stream. - let comments; - - if (settings.moderation === 'pre') { - comments = Comment.findAcceptedByAssetId(asset.id); - } else { - comments = Comment.findAcceptedAndNewByAssetId(asset.id); - } - - return Promise.all([ - - // This is the promised component... Fetch the comments based on the - // moderation settings. - comments, - - // 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 5dc71264d..da47007ee 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,17 +44,21 @@ 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); }); router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) => { - User.findById(req.params.user_id) + UsersService.findById(req.params.user_id) .then(user => { let localProfile = user.profiles.find((profile) => profile.provider === 'local'); @@ -92,7 +97,7 @@ router.post('/:user_id/email', authorization.needed('admin'), (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({ @@ -115,14 +120,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) { @@ -150,13 +155,13 @@ 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) => { // Set the user status to "pending" for review by moderators - if (action_type.slice(0, 4) === 'flag') { - return User.setStatus(req.user.id, 'pending') + if (action_type.slice(0, 4) === 'FLAG') { + return UsersService.setStatus(req.user.id, 'PENDING') .then(() => action); } else { return action; @@ -166,16 +171,17 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { res.status(201).json(action); }) .catch((err) => { + console.log('Error', err); next(err); }); }); -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..7a888ede1 --- /dev/null +++ b/services/users.js @@ -0,0 +1,609 @@ +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() + } + }); + }); + } + + /** + * Returns all users with pending 'ADMIN'ation actions. + * @return {Promise} + */ + static moderationQueue() { + return UserModel.find({status: 'PENDING'}); + } + +}; 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 d572c2695..70b4c6ad0 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 6439fad84..6b760dee4 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,22 +55,22 @@ 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' }, { - action_type: 'flag', + action_type: 'FLAG', item_id: '123', - item_type: 'user' + item_type: 'USERS' }]; 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; @@ -79,7 +79,7 @@ describe('/api/v1/queue', () => { return Promise.all([ Comment.create(comments), u, - ...u.map((u) => User.setStatus(u.id, 'pending')) + ...u.map((user) => UsersService.setStatus(user.id, 'PENDING')) ]); }) .then(([c, u]) => { @@ -89,7 +89,7 @@ describe('/api/v1/queue', () => { return Promise.all([ Action.create(actions), - Setting.init(settings) + SettingsService.init(settings) ]); }); }); @@ -98,7 +98,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); @@ -111,7 +111,7 @@ describe('/api/v1/queue', () => { it('should return all pending users and actions', function(done){ chai.request(app) .get('/api/v1/queue/users/pending') - .set(passport.inject({roles: ['admin']})) + .set(passport.inject({roles: ['ADMIN']})) .end(function(err, res){ expect(err).to.be.null; expect(res).to.have.status(200); 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 69% rename from tests/routes/api/user/index.js rename to test/routes/api/user/index.js index 3aa6cbe3c..d1771726d 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,14 +72,12 @@ 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']})) - .send({'action_type': 'flag', metadata: {reason: 'Bio is too awesome.'}}) + .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); expect(res).to.have.body; - expect(res.body).to.have.property('action_type', 'flag'); - expect(res.body).to.have.property('metadata') - .and.to.deep.equal({'reason': 'Bio is too awesome.'}); + expect(res.body).to.have.property('action_type', 'FLAG'); expect(res.body).to.have.property('item_id', 'abc'); }); }); 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 59% rename from tests/models/user.js rename to test/services/users.js index 69be314c4..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); @@ -85,10 +83,26 @@ describe('models.User', () => { }); + describe('#createLocalUser', () => { + it('should not create a user with duplicate display name', () => { + return UsersService.createLocalUsers([{ + email: 'otrostampi@gmail.com', + displayName: 'StampiTheSecond', + password: '1Coralito!' + }]) + .then((user) => { + expect(user).to.be.null; + }) + .catch((error) => { + expect(error).to.not.be.null; + }); + }); + }); + 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; @@ -96,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'); @@ -116,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; @@ -134,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'); @@ -155,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 419fb4d79..000000000 --- a/tests/models/comment.js +++ /dev/null @@ -1,267 +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'); - }); - }); - - it('should find an array of accepted comments by asset id', () => { - return Comment.findAcceptedByAssetId('123').then((result) => { - expect(result).to.have.length(1); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 20'); - }); - }); - - it('should find an array of new and accepted comments by asset id', () => { - return Comment.findAcceptedAndNewByAssetId('123').then((result) => { - expect(result).to.have.length(2); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 10'); - }); - }); - }); - - 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 ed1f3d22f..000000000 --- a/tests/routes/api/comments/index.js +++ /dev/null @@ -1,485 +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'); - }); - }); - - 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 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 d9b59d6fe..000000000 --- a/tests/routes/api/stream/index.js +++ /dev/null @@ -1,152 +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 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' - }] - }]; - - 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([ - User.createLocalUsers(users), - Asset.findOrCreateByUrl('http://test.com'), - Asset - .findOrCreateByUrl('http://coralproject.net/asset2') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - ]) - .then(([users, asset1, asset2]) => { - - comments[0].author_id = users[0].id; - comments[1].author_id = users[1].id; - comments[2].author_id = users[0].id; - comments[3].author_id = users[1].id; - - comments[0].asset_id = asset1.id; - comments[1].asset_id = asset1.id; - comments[2].asset_id = asset2.id; - comments[3].asset_id = asset2.id; - - return Promise.all([ - Comment.create(comments), - Action.create(actions) - ]); - }); - }); - }); - - 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': 'http://test.com'}) - .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': 'http://coralproject.net/asset2'}) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.assets.length).to.equal(1); - expect(res.body.comments.length).to.equal(1); - expect(res.body.users.length).to.equal(1); - expect(res.body.settings).to.have.property('moderation', 'pre'); - expect(res.body.settings).to.not.have.property('wordlist'); - }); - }); - }); -}); diff --git a/util.js b/util.js index 6907d90cd..b081078e7 100644 --- a/util.js +++ b/util.js @@ -37,6 +37,8 @@ util.onshutdown = (jobs) => { }; // Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the -// event that we have an external event. -process.on('SIGTERM', () => util.shutdown()); -process.on('SIGINT', () => util.shutdown()); +// event that we have an external event. SIGUSR2 is called when the app is asked +// to be 'killed', same procedure here. +process.on('SIGTERM', () => util.shutdown()); +process.on('SIGINT', () => util.shutdown()); +process.once('SIGUSR2', () => util.shutdown()); diff --git a/views/admin.ejs b/views/admin.ejs index efcf1dfc9..32d38734a 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -5,6 +5,7 @@ Talk - Coral Admin + + + +
      +
      + Admin Login + + + + +
      +
      +
      + + + + diff --git a/views/admin/password-reset.ejs b/views/admin/password-reset.ejs new file mode 100644 index 000000000..06b9d2a86 --- /dev/null +++ b/views/admin/password-reset.ejs @@ -0,0 +1,139 @@ + + + + + + Password Reset + + + + + + +
      +
      + + Set new password + + + + +
      foo
      +
      +
      + + + + 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' } ] },