From 8cd929bb1bffcd6637eab43743c272c8b98e2472 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Tue, 24 Jan 2017 12:09:04 -0700 Subject: [PATCH 1/6] npm chokes on .js package names --- client/coral-embed-stream/src/PymConnection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-embed-stream/src/PymConnection.js b/client/coral-embed-stream/src/PymConnection.js index 8e9073e2c..cdbf95c2f 100644 --- a/client/coral-embed-stream/src/PymConnection.js +++ b/client/coral-embed-stream/src/PymConnection.js @@ -1,3 +1,3 @@ -import Pym from 'pym.js'; +import Pym from '../../../node_modules/pym.js'; export default new Pym.Child({polling: 100}); From a7e9c0c776daed13ff69f672798bc381f7bc54a1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 24 Jan 2017 12:10:32 -0700 Subject: [PATCH 2/6] Const, service, and model updates - Updated enum values to be uppercase - Updated services to expose service models - Updated models to only export the mongoose model - Moved all mongoose static methods over to new services - Updated tests to refelct new setup BREAKING - Status that were uppercased (caps) have caused issues with the admin pages --- bin/cli-assets | 6 +- bin/cli-settings | 6 +- bin/cli-users | 41 +- client/coral-admin/src/actions/auth.js | 2 +- client/coral-framework/actions/auth.js | 4 +- graph/loaders/actions.js | 5 +- graph/loaders/assets.js | 9 +- graph/loaders/comments.js | 17 +- graph/loaders/settings.js | 4 +- graph/loaders/users.js | 4 +- graph/mutators/action.js | 7 +- graph/mutators/comment.js | 11 +- graph/mutators/user.js | 4 +- init.js | 11 +- middleware/authorization.js | 6 +- middleware/payload-filter.js | 74 -- models/SETTINGS_ROADMAP.md | 30 - models/action.js | 194 +---- models/asset.js | 94 --- models/comment.js | 236 +------ models/setting.js | 72 +- models/user.js | 666 +----------------- routes/admin/index.js | 2 +- routes/api/account/index.js | 13 +- routes/api/actions/index.js | 4 +- routes/api/assets/index.js | 16 +- routes/api/comments/index.js | 107 +-- routes/api/index.js | 5 - routes/api/queue/index.js | 19 +- routes/api/settings/index.js | 28 +- routes/api/stream/index.js | 112 --- routes/api/users/index.js | 31 +- services/actions.js | 177 +++++ services/assets.js | 116 +++ services/comments.js | 220 ++++++ services/passport.js | 12 +- services/settings.js | 49 ++ services/users.js | 601 ++++++++++++++++ services/wordlist.js | 4 +- test/models/comment.js | 245 ------- test/routes/api/assets/index.js | 9 +- test/routes/api/auth/index.js | 14 +- test/routes/api/comments/index.js | 249 ++----- test/routes/api/queue/index.js | 30 +- test/routes/api/settings/index.js | 6 +- test/routes/api/stream/index.js | 225 ------ test/routes/api/user/index.js | 12 +- .../{models/action.js => services/actions.js} | 74 +- test/{models/asset.js => services/assets.js} | 35 +- test/services/comments.js | 259 +++++++ test/services/scraper.js | 2 +- .../setting.js => services/settings.js} | 16 +- test/{models/user.js => services/users.js} | 129 ++-- test/services/wordlist.js | 6 +- 54 files changed, 1831 insertions(+), 2499 deletions(-) delete mode 100644 middleware/payload-filter.js delete mode 100644 models/SETTINGS_ROADMAP.md delete mode 100644 routes/api/stream/index.js create mode 100644 services/actions.js create mode 100644 services/assets.js create mode 100644 services/comments.js create mode 100644 services/settings.js create mode 100644 services/users.js delete mode 100644 test/models/comment.js delete mode 100644 test/routes/api/stream/index.js rename test/{models/action.js => services/actions.js} (58%) rename test/{models/asset.js => services/assets.js} (69%) create mode 100644 test/services/comments.js rename test/{models/setting.js => services/settings.js} (75%) rename test/{models/user.js => services/users.js} (61%) 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..47acebbf9 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. @@ -24,8 +24,8 @@ program .action(() => { const defaults = {id: '1', moderation: 'pre'}; - 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..96f004d4c 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,13 +77,15 @@ 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(); @@ -102,7 +105,7 @@ function createUser(options) { * Deletes a user. */ function deleteUser(userID) { - User + UserModel .findOneAndRemove({ id: userID }) @@ -148,7 +151,7 @@ function passwd(userID) { return; } - User + UsersService .changePassword(userID, result.password) .then(() => { console.log('Password changed.'); @@ -168,7 +171,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 +184,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 +211,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 +251,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 +269,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 +287,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 +304,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 +321,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 +338,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 +355,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/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index fb469588b..c3f9b1be1 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -11,7 +11,7 @@ export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); coralApi('/auth') .then(result => { - const isAdmin = !!result.user.roles.filter(i => i === 'admin').length; + const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 0f65e14c2..e815fd5d0 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -27,7 +27,7 @@ export const fetchSignIn = (formData) => (dispatch) => { dispatch(signInRequest()); coralApi('/auth/local', {method: 'POST', body: formData}) .then(({user}) => { - const isAdmin = !!user.roles.filter(i => i === 'admin').length; + const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; dispatch(signInSuccess(user, isAdmin)); dispatch(hideSignInDialog()); dispatch(addItem(user, 'users')); @@ -132,7 +132,7 @@ export const checkLogin = () => dispatch => { throw new Error('Not logged in'); } - const isAdmin = !!result.user.roles.filter(i => i === 'admin').length; + const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); diff --git a/graph/loaders/actions.js b/graph/loaders/actions.js index c4664631a..cc7d43a12 100644 --- a/graph/loaders/actions.js +++ b/graph/loaders/actions.js @@ -2,7 +2,7 @@ const DataLoader = require('dataloader'); const util = require('./util'); -const Action = require('../../models/action'); +const ActionsService = require('../../services/actions'); /** * Looks up actions based on the requested id's all bounded by the user. @@ -11,7 +11,8 @@ const Action = require('../../models/action'); * @return {Promise} resolves to the promises of the requested actions */ const genActionSummariessByItemID = ({user = {}}, item_ids) => { - return Action.getActionSummaries(item_ids, user.id) + return ActionsService + .getActionSummaries(item_ids, user.id) .then(util.arrayJoinBy(item_ids, 'item_id')); }; diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index cd2ef881d..b4bb092cc 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -5,14 +5,15 @@ const errors = require('../../errors'); const scraper = require('../../services/scraper'); const util = require('./util'); -const Asset = require('../../models/asset'); +const AssetModel = require('../../models/asset'); +const AssetService = require('../../models/asset'); /** * 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) => Asset.find({ +const genAssetsByID = (context, ids) => AssetModel.find({ id: { $in: ids } @@ -32,7 +33,7 @@ const findOrCreateAssetByURL = (context, asset_url) => { return Promise.reject(errors.ErrInvalidAssetURL); } - return Asset.findOrCreateByUrl(asset_url) + return AssetService.findOrCreateByUrl(asset_url) .then((asset) => { // If the asset wasn't scraped before, scrape it! Otherwise just return @@ -58,6 +59,6 @@ module.exports = (context) => ({ getByURL: (url) => findOrCreateAssetByURL(context, url), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), - getAll: new util.SingletonResolver(() => Asset.find({})) + getAll: new util.SingletonResolver(() => AssetModel.find({})) } }); diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 24db48079..2c0d6719f 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -2,14 +2,15 @@ const DataLoader = require('dataloader'); const util = require('./util'); -const Action = require('../../models/action'); -const Comment = require('../../models/comment'); +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) => Comment.find({ +const genCommentsByAssetID = (context, ids) => CommentModel.find({ asset_id: { $in: ids }, @@ -23,7 +24,7 @@ const genCommentsByAssetID = (context, ids) => Comment.find({ * Retrieves comments by an array of parent ids. * @param {Array} ids array of ids to lookup */ -const genCommentsByParentID = (context, ids) => Comment.find({ +const genCommentsByParentID = (context, ids) => CommentModel.find({ parent_id: { $in: ids }, @@ -39,7 +40,7 @@ const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null} status = status.toLowerCase(); } - return Comment.moderationQueue(status, asset_id); + return CommentsService.moderationQueue(status, asset_id); }; const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => { @@ -49,13 +50,13 @@ const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = nul action_type = action_type.toLowerCase(); } - return Action.find({ + return ActionModel.find({ action_type, // TODO: remove when we move the enum over to the uppercase. item_type: 'comments' }).then((actions) => { - let comments = Comment.find({ + let comments = CommentModel.find({ id: { $in: actions.map((action) => action.item_id) } @@ -69,7 +70,7 @@ const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = nul }); }; -const genCommentsByAuthorID = (context, authorIDs) => Comment.find({ +const genCommentsByAuthorID = (context, authorIDs) => CommentModel.find({ author_id: { $in: authorIDs } diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js index c7a8810dd..77ece165f 100644 --- a/graph/loaders/settings.js +++ b/graph/loaders/settings.js @@ -1,4 +1,4 @@ -const Settings = require('../../models/setting'); +const SettingsService = require('../../services/settings'); const util = require('./util'); @@ -8,5 +8,5 @@ const util = require('./util'); * @return {Object} object of loaders */ module.exports = () => ({ - Settings: new util.SingletonResolver(() => Settings.retrieve()) + Settings: new util.SingletonResolver(() => SettingsService.retrieve()) }); diff --git a/graph/loaders/users.js b/graph/loaders/users.js index d59e524b6..455eac250 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -1,8 +1,8 @@ const DataLoader = require('dataloader'); -const User = require('../../models/user'); +const UserService = require('../../models/user'); -const genUserByIDs = (context, ids) => User.findByIdArray(ids); +const genUserByIDs = (context, ids) => UserService.findByIdArray(ids); /** * Creates a set of loaders based on a GraphQL context. diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 7c37ddd86..cf80c496d 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -1,4 +1,5 @@ -const Action = require('../../models/action'); +const ActionModel = require('../../models/action'); +const ActionsService = require('../../services/actions'); /** * Creates an action on a item. @@ -9,7 +10,7 @@ const Action = require('../../models/action'); * @return {Promise} resolves to the action created */ const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => { - return Action.insertUserAction({ + return ActionsService.insertUserAction({ item_id, item_type, user_id: user.id, @@ -25,7 +26,7 @@ const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = * @return {Promise} resolves when the action is deleted */ const deleteAction = ({user}, {id}) => { - return Action.remove({ + return ActionModel.remove({ id, user_id: user.id }); diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 26ee691b4..2e7a14877 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,6 +1,7 @@ const errors = require('../../errors'); -const Asset = require('../../models/asset'); -const Comment = require('../../models/comment'); + +const AssetsService = require('../../services/assets'); +const CommentsService = require('../../services/comments'); const Wordlist = require('../../services/wordlist'); @@ -14,7 +15,7 @@ const Wordlist = require('../../services/wordlist'); * @return {Promise} resolves to the created comment */ const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => { - return Comment.publicCreate({ + return CommentsService.publicCreate({ body, asset_id, parent_id, @@ -58,8 +59,8 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => { if (wordlist.banned) { status = Promise.resolve('rejected'); } else { - status = Asset - .rectifySettings(Asset.findById(asset_id).then((asset) => { + status = AssetsService + .rectifySettings(AssetsService.findById(asset_id).then((asset) => { if (!asset) { return Promise.reject(errors.ErrNotFound); } diff --git a/graph/mutators/user.js b/graph/mutators/user.js index b386a5535..22837caae 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,4 +1,4 @@ -const User = require('../../models/user'); +const UsersService = require('../../services/users'); /** * Updates a users settings. @@ -7,7 +7,7 @@ const User = require('../../models/user'); * @return {Promise} */ const updateUserSettings = ({user}, {bio}) => { - return User.updateSettings(user.id, {bio}); + return UsersService.updateSettings(user.id, {bio}); }; module.exports = (context) => { diff --git a/init.js b/init.js index c29684b23..71f05623f 100644 --- a/init.js +++ b/init.js @@ -1,7 +1,14 @@ -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({ + id: '1', + 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 b73ea77c5..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,178 +37,6 @@ const ActionSchema = new Schema({ } }); -/** - * Finds an action by the id. - * @param {String} id identifier of the action (uuid) -*/ -ActionSchema.statics.findById = function(id) { - return Action.findOne({id}); -}; - -/** - * Add an action. - * @param {String} item_id identifier of the comment (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the comment - * @return {Promise} - */ -ActionSchema.statics.insertUserAction = (action) => { - - // Actions are made unique by using a query that can be reproducable, i.e., - // not containing user inputable values. - let query = { - action_type: action.action_type, - item_type: action.item_type, - item_id: action.item_id, - user_id: action.user_id - }; - - // Create/Update the action. - return Action.findOneAndUpdate(query, action, { - - // Ensure that if it's new, we return the new object created. - new: true, - - // Perform an upsert in the event that this doesn't exist. - upsert: true, - - // Set the default values if not provided based on the mongoose models. - setDefaultsOnInsert: true - }); -}; - -/** - * Finds actions in an array of ids. - * @param {String} ids array of user identifiers (uuid) -*/ -ActionSchema.statics.findByItemIdArray = function(item_ids) { - return Action.find({ - 'item_id': {$in: item_ids} - }); -}; - -/** - * Fetches the action summaries for the given asset, and comments around the - * given user id. - * @param {[type]} asset_id [description] - * @param {[type]} comments [description] - * @param {String} [current_user_id=''] [description] - * @return {[type]} [description] - */ -ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => { - - // Get the user id's from the author id's as a unique array that gets - // sorted. - let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); - - // Fetch the actions for pretty much everything at this point. - return Action.getActionSummaries(_.uniq([ - - // Actions can be on assets... - asset_id, - - // Comments... - ...comments.map((comment) => comment.id), - - // Or Authors... - ...userIDs - ].filter((e) => e)), current_user_id); -}; - -/** - * Returns summaries of actions for an array of ids - * @param {String} ids array of user identifiers (uuid) -*/ -ActionSchema.statics.getActionSummaries = function(item_ids, current_user_id = '') { - return Action.aggregate([ - { - - // only grab items that match the specified item id's - $match: { - item_id: { - $in: item_ids - } - } - }, - { - $group: { - - // group unique documents by these properties, we are leveraging the - // fact that each uuid is completly unique. - _id: { - item_id: '$item_id', - action_type: '$action_type' - }, - - // and sum up all actions matching the above grouping criteria - count: { - $sum: 1 - }, - - // we are leveraging the fact that each uuid is completly unique and - // just grabbing the last instance of the item type here. - item_type: { - $last: '$item_type' - }, - - current_user: { - $max: { - $cond: { - if: { - $eq: ['$user_id', current_user_id], - }, - then: '$$CURRENT', - else: null - } - } - } - } - }, - { - $project: { - - // suppress the _id field - _id: false, - - // map the fields from the _id grouping down a level - item_id: '$_id.item_id', - action_type: '$_id.action_type', - - // map the field directly - count: '$count', - item_type: '$item_type', - - // set the current user to false here - current_user: '$current_user' - } - } - ]); -}; - -/* - * Finds all comments for a specific action. - * @param {String} action_type type of action - * @param {String} item_type type of item the action is on -*/ -ActionSchema.statics.findByType = function(action_type, item_type) { - return Action.find({ - 'action_type': action_type, - 'item_type': item_type - }); -}; - -/** - * Finds all comments ids for a specific action. - * @param {String} action_type type of action - * @param {String} item_type type of item the action is on -*/ -ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_type) { - return Action.find({ - 'action_type': action_type, - 'item_type': item_type - }, 'item_id'); -}; - const Action = mongoose.model('Action', ActionSchema); module.exports = Action; diff --git a/models/asset.js b/models/asset.js index ac87e006f..f0046aef0 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,8 +1,5 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; - -const Setting = require('./setting'); - const uuid = require('uuid'); const AssetSchema = new Schema({ @@ -68,18 +65,6 @@ AssetSchema.index({ background: true }); -/** - * Finds an asset by its id. - * @param {String} id identifier of the asset (uuid). - */ -AssetSchema.statics.findById = (id) => Asset.findOne({id}); - -/** - * Finds a asset by its url. - * @param {String} url identifier of the asset (uuid). - */ -AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); - /** * Returns true if the asset is closed, false else. */ @@ -87,85 +72,6 @@ AssetSchema.virtual('isClosed').get(function() { return this.closedAt && this.closedAt.getTime() <= new Date().getTime(); }); -/** - * Retrieves the settings given an asset query and rectifies it against the - * global settings. - * @param {Promise} assetQuery an asset query that returns a single asset. - * @return {Promise} - */ -AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ - Setting.retrieve(), - assetQuery -]).then(([settings, asset]) => { - - // If the asset exists and has settings then return the merged object. - if (asset && asset.settings) { - settings.merge(asset.settings); - } - - return settings; -}); - -/** - * Finds a asset by its url. - * - * NOTE: This function has scalability concerns regarding mongoose's decision - * always write {updated_at: new Date()} on every call to findOneAndUpdate - * even though the update document exactly matches the query document... In - * the future this function should never update, only findOneAndCreate but this - * is not possible with the mongoose driver. - * - * @param {String} url identifier of the asset (uuid). - * @return {Promise} - */ -AssetSchema.statics.findOrCreateByUrl = (url) => Asset.findOneAndUpdate({url}, {url}, { - - // Ensure that if it's new, we return the new object created. - new: true, - - // Perform an upsert in the event that this doesn't exist. - upsert: true, - - // Set the default values if not provided based on the mongoose models. - setDefaultsOnInsert: true -}); - -/** - * Updates the settings for the asset. - * @param {[type]} id [description] - * @param {[type]} settings [description] - * @return {[type]} [description] - */ -AssetSchema.statics.overrideSettings = (id, settings) => Asset.findOneAndUpdate({id}, { - $set: { - settings - } -}, { - new: true -}); - -/** - * Finds assets matching keywords on the model. If `value` is an empty string, - * then it will not even perform a text search query. - * @param {String} value string to search by. - * @return {Promise} - */ -AssetSchema.statics.search = (value) => value.length === 0 ? Asset.find({}) : Asset.find({ - $text: { - $search: value - } -}); - -/** - * Finds multiple assets with matching ids - * @param {Array} ids an array of Strings of asset_id - * @return {Promise} resolves to list of Assets - */ -AssetSchema.statics.findMultipleById = function (ids) { - const query = ids.map(id => ({id})); - return Asset.find(query); -}; - const Asset = mongoose.model('Asset', AssetSchema); module.exports = Asset; diff --git a/models/comment.js b/models/comment.js index 99eaec069..5ceef0e29 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,8 +1,12 @@ 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', +]; /** * The Mongo schema for a Comment Status. @@ -11,11 +15,7 @@ const Action = require('./action'); const StatusSchema = new Schema({ type: { type: String, - enum: [ - 'accepted', - 'rejected', - 'premod', - ], + enum: STATUSES, }, // The User ID of the user that assigned the status. @@ -56,228 +56,6 @@ const CommentSchema = new Schema({ } }); -/** - * toJSON overrides to remove fields from the json - * output. - */ -CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.virtuals = true; -CommentSchema.options.toJSON.hide = '_id'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * Filters the object for the given user only allowing those with the allowed - * roles/permissions to access particular parameters. - */ -CommentSchema.method('filterForUser', function(user = false) { - if (!user || !user.roles.includes('admin')) { - return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']); - } - - return this.toJSON(); -}); - -/** - * Creates a new Comment that came from a public source. - * @param {Mixed} comment either a single comment or an array of comments. - * @return {Promise} - */ -CommentSchema.statics.publicCreate = (comment) => { - - // Check to see if this is an array of comments, if so map it out. - if (Array.isArray(comment)) { - return Promise.all(comment.map(Comment.publicCreate)); - } - - const { - body, - asset_id, - parent_id, - status = null, - author_id - } = comment; - - comment = new Comment({ - body, - asset_id, - parent_id, - status_history: status ? [{ - type: status, - created_at: new Date() - }] : [], - status, - author_id - }); - - return comment.save(); -}; - -/** - * Finds a comment by the id. - * @param {String} id identifier of comment (uuid) - * @return {Promise} - */ -CommentSchema.statics.findById = (id) => Comment.findOne({id}); - -/** - * Finds ALL the comments by the asset_id. - * @param {String} asset_id identifier of the asset which owns this comment (uuid) - * @return {Promise} - */ -CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ - asset_id -}); - -/** - * findByAssetIdWithStatuses finds all the comments where the asset id matches - * what's provided and the status is one of the ones listed in the statuses - * array. - * @param {String} asset_id the asset id to search by - * @param {Array} [statuses=[]] the array of statuses to search by - * @return {Promise} resolves to an array of comments - */ -CommentSchema.statics.findByAssetIdWithStatuses = (asset_id, statuses = []) => Comment.find({ - asset_id, - status: { - $in: statuses - } -}); - -/** - * Find comments by an action that was performed on them. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ -CommentSchema.statics.findByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => Comment.find({ - id: { - $in: actions.map((a) => a.item_id) - } - })); - -/** - * Find comment id's where the action type matches the argument. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ -CommentSchema.statics.findIdsByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comments') - .then((actions) => actions.map(a => a.item_id)); - -/** - * Find comments by current status - * @param {String} status status of the comment to search for - * @return {Promise} resovles to comment array - */ -CommentSchema.statics.findByStatus = (status = null) => { - return Comment.find({status}); -}; - -/** - * Find comments that need to be moderated (aka moderation queue). - * @param {String} asset_id - * @return {Promise} - */ -CommentSchema.statics.moderationQueue = (status, asset_id = null) => { - - // Fetch the comments with statuses. - let comments = Comment.findByStatus(status); - - if (asset_id) { - comments = comments.where('asset_id', asset_id); - } - - return comments; -}; - -/** - * Pushes a new status in for the user. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment - * @param {String} assigned_by the user id for the user who performed the - * moderation action - * @return {Promise} - */ -CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { - $push: { - status_history: { - type: status, - created_at: new Date(), - assigned_by - } - }, - $set: {status} -}); - -/** - * Add an action to the comment. - * @param {String} item_id identifier of the comment (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the comment - * @return {Promise} - */ -CommentSchema.statics.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({ - item_id, - item_type: 'comments', - user_id, - action_type, - metadata -}); - -/** - * Change the status of a comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment - * @return {Promise} - */ -CommentSchema.statics.removeById = (id) => Comment.remove({id}); - -/** - * Remove an action from the comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} action_type the type of the action to be removed - * @param {String} user_id the id of the user performing the action - * @return {Promise} - */ -CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({ - action_type, - item_type: 'comment', - item_id, - user_id -}); - -/** - * Returns all the comments in the collection. - * @return {Promise} - */ -CommentSchema.statics.all = () => Comment.find(); - -/** - * Returns all the comments by user - * probably to be paginated at some point in the future - * @return {Promise} array resolves to an array of comments by that user - */ -CommentSchema.statics.findByUserId = function (author_id, admin = false) { - - // do not return un-published comments for non-admins - let query = {author_id}; - - if (!admin) { - query.$nor = [{status: 'premod'}, {status: 'rejected'}]; - } - - return Comment.find(query); -}; - // Comment model. const Comment = mongoose.model('Comment', CommentSchema); diff --git a/models/setting.js b/models/setting.js index ed41acfa6..9d2a7028f 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], @@ -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 5fe84c9eb..0b8edf0fd 100644 --- a/models/user.js +++ b/models/user.js @@ -1,40 +1,18 @@ const mongoose = require('../services/mongoose'); const uuid = require('uuid'); -const _ = require('lodash'); -const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); -const Action = require('./action'); -const Comment = require('./comment'); -const Wordlist = require('../services/wordlist'); -const errors = require('../errors'); - -const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; -const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; - -// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run -// through during the salting process. -const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ - 'admin', - 'moderator' + 'ADMIN', + 'MODERATOR' ]; -// USER_STATUSES is the list of statuses that are permitted for the user status. +// USER_STATUS is the list of statuses that are permitted for the user status. const USER_STATUS = [ - 'active', - 'banned' + 'ACTIVE', + 'BANNED' ]; -// In the event that the TALK_SESSION_SECRET is missing but we are testing, then -// set the process.env.TALK_SESSION_SECRET. -if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { - process.env.TALK_SESSION_SECRET = 'keyboard cat'; -} else if (!process.env.TALK_SESSION_SECRET) { - throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); -} - // ProfileSchema is the mongoose schema defined as the representation of a // User's profile stored in MongoDB. const ProfileSchema = new mongoose.Schema({ @@ -103,11 +81,18 @@ const UserSchema = new mongoose.Schema({ // Roles provides an array of roles (as strings) that is associated with a // user. - roles: [String], + roles: [{ + type: String, + enum: USER_ROLES + }], // Status provides a string that says in which state the account is. // When the account is banned, the user login is disabled. - status: {type: String, enum: USER_STATUS, default: 'active'}, + status: { + type: String, + enum: USER_STATUS, + default: 'ACTIVE' + }, // User's settings settings: { @@ -134,627 +119,20 @@ UserSchema.index({ background: false }); -/** - * toJSON overrides to remove the password field from the json - * output. - */ -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]; - }); - } - - 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(); -}); - /** * Returns true if the user has all the roles specified. */ UserSchema.method('hasRoles', function(...roles) { - return roles.every((role) => this.roles.indexOf(role) >= 0); + return roles.every((role) => { + + // 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) { - if (err.message.match('displayName')) { - return reject(errors.ErrDisplayTaken); - } - return reject(errors.ErrEmailTaken); - } - return reject(err); - } - return resolve(user); - }); - }); - }); - }); -}; - -/** - * Disables a given user account. - * @param {String} id id of a user - * @param {Function} done callback after the operation is complete - */ -UserService.disableUser = (id) => { - return UserModel.update({ - id: id - }, { - $set: { - disabled: true - } - }); -}; - -/** - * Enables a given user account. - * @param {String} id id of a user - * @param {Function} done callback after the operation is complete - */ -UserService.enableUser = (id) => { - return UserModel.update({ - id: id - }, { - $set: { - disabled: false - } - }); -}; - -/** - * Adds a role to a user. - * @param {String} id id of a user - * @param {String} role role to add - * @param {Function} done callback after the operation is complete - */ -UserService.addRoleToUser = (id, role) => { - - // Check to see if the user role is in the allowable set of roles. - if (USER_ROLES.indexOf(role) === -1) { - - // User role is not supported! Error out here. - return Promise.reject(new Error(`role ${role} is not supported`)); - } - - return UserModel.update({ - id: id - }, { - $addToSet: { - roles: role - } - }); -}; - -/** - * Removes a role from a user. - * @param {String} id id of a user - * @param {String} role role to remove - * @param {Function} done callback after the operation is complete - */ -UserService.removeRoleFromUser = (id, role) => { - return UserModel.update({ - id: id - }, { - $pull: { - roles: role - } - }); -}; - -/** - * Set status of a user. - * @param {String} id id of a user - * @param {String} status status to set - * @param {String} comment_id id of the comment that the user was ban for. - * @param {Function} done callback after the operation is complete - */ -UserService.setStatus = (id, status, comment_id) => { - - // Check to see if the user status is in the allowable set of roles. - if (USER_STATUS.indexOf(status) === -1) { - - // User status is not supported! Error out here. - return Promise.reject(new Error(`status ${status} is not supported`)); - } - - // If ban then reject the comment and update status - if (status === 'banned') { - return UserModel.update({ - id: id - }, { - $set: { - status: status - } - }) - .then(() => { - return Comment.pushStatus(comment_id, 'rejected', id); - }); - } - - if (status === 'active') { - return UserModel.update({ - id: id - }, { - $set: { - status: status - } - }); - } -}; - -/** - * Finds a user with the id. - * @param {String} id user id (uuid) -*/ -UserService.findById = (id) => { - return UserModel.findOne({id}); -}; - -/** - * Finds users in an array of ids. - * @param {Array} ids array of user identifiers (uuid) -*/ -UserService.findByIdArray = (ids) => { - return UserModel.find({ - 'id': {$in: ids} - }); -}; - -/** - * Finds public user information by an array of ids. - * @param {Array} ids array of user identifiers (uuid) -*/ -UserService.findPublicByIdArray = (ids) => { - return UserModel.find({ - 'id': {$in: ids} - }, 'id displayName'); -}; - -/** - * Creates a JWT from a user email. Only works for local accounts. - * @param {String} email of the local user - */ -UserService.createPasswordResetToken = function (email) { - if (!email || typeof email !== 'string') { - return Promise.reject('email is required when creating a JWT for resetting passord'); - } - - email = email.toLowerCase(); - - return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) - .then((user) => { - if (!user) { - - // Since we don't want to reveal that the email does/doesn't exist - // just go ahead and resolve the Promise with null and check in the - // endpoint. - return; - } - - const payload = { - jti: uuid.v4(), - email, - userId: user.id, - version: user.__v - }; - - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { - algorithm: 'HS256', - expiresIn: '1d', - subject: PASSWORD_RESET_JWT_SUBJECT - }); - }); -}; - -/** - * Verifies that the token was indeed signed by the session secret. - * @param {String} token JWT token from the client - * @return {Promise} - */ -UserService.verifyToken = (token, options = {}) => { - return new Promise((resolve, reject) => { - - // Set the allowed algorithms. - options.algorithms = ['HS256']; - - jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { - if (err) { - return reject(err); - } - - resolve(decoded); - }); - }); -}; - -/** - * Verifies a jwt and returns the associated user. - * @param {String} token the JSON Web Token to verify - */ -UserService.verifyPasswordResetToken = (token) => { - return UserService - .verifyToken(token, { - subject: PASSWORD_RESET_JWT_SUBJECT - }) - .then((decoded) => UserService.findById(decoded.userId)); -}; - -/** - * Finds a user using a value which gets compared using a prefix match against - * the user's email address and/or their display name. - * @param {String} value value to search by - * @return {Promise} - */ -UserService.search = (value) => { - return UserModel.find({ - $or: [ - - // Search by a prefix match on the displayName. - { - 'displayName': { - $regex: new RegExp(`^${value}`), - $options: 'i' - } - }, - - // Search by a prefix match on the email address. - { - 'profiles': { - $elemMatch: { - id: { - $regex: new RegExp(`^${value}`), - $options: 'i' - }, - provider: 'local' - } - } - } - ] - }); -}; - -/** - * Returns a count of the current users. - * @return {Promise} - */ -UserService.count = () => UserModel.count(); - -/** - * Returns all the users. - * @return {Promise} - */ -UserService.all = () => UserModel.find(); - -/** - * Updates the user's settings. - * @return {Promise} - */ -UserService.updateSettings = (id, settings) => UserModel.update({ - id -}, { - $set: { - settings - } -}); - -/** - * Add an action to the user. - * @param {String} item_id identifier of the user (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the user - * @return {Promise} - */ -UserService.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({ - item_id, - item_type: 'users', - user_id, - action_type, - metadata -}); - -/** - * This creates a token based around confirming the local profile. - * @param {String} userID The user id for the user that we are creating the - * token for. - * @param {String} email The email that we are needing to get confirmed. - * @return {Promise} - */ -UserService.createEmailConfirmToken = (userID, email) => { - if (!email || typeof email !== 'string') { - return Promise.reject('email is required when creating a JWT for resetting passord'); - } - - email = email.toLowerCase(); - - return UserService - .findById(userID) - .then((user) => { - if (!user) { - return Promise.reject(new Error('user not found')); - } - - // Get the profile representing the local account. - let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); - - // Ensure that the user email hasn't already been verified. - if (profile && profile.metadata && profile.metadata.confirmed_at) { - return Promise.reject(new Error('email address already confirmed')); - } - - const payload = { - email, - userID - }; - - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { - jwtid: uuid.v4(), - algorithm: 'HS256', - expiresIn: '1d', - subject: EMAIL_CONFIRM_JWT_SUBJECT - }); - }); -}; - -/** - * This verifies that a given token was for the email confirmation and updates - * that user's profile with a 'confirmed_at' parameter with the current date. - * @param {String} token the token containing the email confirmation details - * signed with our secret. - * @return {Promise} - */ -UserService.verifyEmailConfirmation = (token) => { - return UserService - .verifyToken(token, { - subject: EMAIL_CONFIRM_JWT_SUBJECT - }) - .then(({userID, email}) => { - - return UserModel - .update({ - id: userID, - profiles: { - $elemMatch: { - id: email, - provider: 'local' - } - } - }, { - $set: { - 'profiles.$.metadata.confirmed_at': new Date() - } - }); - }); -}; +module.exports = UserModel; +module.exports.USER_ROLES = USER_ROLES; +module.exports.USER_STATUS = USER_STATUS; diff --git a/routes/admin/index.js b/routes/admin/index.js index 283295af1..2a37e8f0f 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -11,7 +11,7 @@ router.get('/password-reset', (req, res) => { }); router.get('/login', (req, res, next) => { - res.render('login'); + res.render('admin/login'); }); router.get('*', (req, res) => { 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..c73a9f536 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'); @@ -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 + CommentsService .findById(req.params.comment_id) .then(comment => { if (!comment) { @@ -171,7 +102,7 @@ router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { }); router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => { - Comment + CommentsService .removeById(req.params.comment_id) .then(() => { res.status(204).end(); @@ -186,7 +117,7 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next 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..b905e3b27 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,12 +1,8 @@ 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')); @@ -15,7 +11,6 @@ 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')); diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index 34902557c..6ec5a5b29 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) ])) @@ -30,7 +31,7 @@ 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}); @@ -43,7 +44,7 @@ router.get('/comments/pending', 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}); @@ -64,8 +65,8 @@ router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) return query; }; - Comment.findIdsByActionType('flag') - .then(ids => assetIDWrap(Comment.find({ + CommentsService.findIdsByActionType('FLAG') + .then(ids => assetIDWrap(CommentModel.find({ id: {$in: ids} }))) .then(gatherActionsAndUsers) diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 76d16c7ec..f1a5fe9f7 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -1,24 +1,26 @@ const express = require('express'); -const Setting = require('../../../models/setting'); +const SettingsService = require('../../../services/settings'); const router = express.Router(); router.get('/', (req, res, next) => { - Setting.retrieve().then((settings) => { - res.json(settings); - }) - .catch((err) => { - next(err); - }); + SettingsService + .retrieve().then((settings) => { + res.json(settings); + }) + .catch((err) => { + next(err); + }); }); router.put('/', (req, res, next) => { - Setting.update(req.body).then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); + SettingsService + .update(req.body).then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js deleted file mode 100644 index 06845a21a..000000000 --- a/routes/api/stream/index.js +++ /dev/null @@ -1,112 +0,0 @@ -const express = require('express'); -const _ = require('lodash'); -const scraper = require('../../../services/scraper'); -const errors = require('../../../errors'); -const url = require('url'); - -const Comment = require('../../../models/comment'); -const User = require('../../../models/user'); -const Action = require('../../../models/action'); -const Asset = require('../../../models/asset'); -const Setting = require('../../../models/setting'); - -const router = express.Router(); - -router.get('/', (req, res, next) => { - - let asset_url = decodeURIComponent(req.query.asset_url); - - // Verify that the asset_url is parsable. - let parsed_asset_url = url.parse(asset_url); - if (!parsed_asset_url.protocol) { - return next(errors.ErrInvalidAssetURL); - } - - // Get the asset_id for this url (or create it if it doesn't exist) - Promise.all([ - - // Find or create the asset by url. - Asset.findOrCreateByUrl(asset_url) - - // Add the found asset to the scraper if it's not already scraped. - .then((asset) => { - if (!asset.scraped) { - return scraper.create(asset).then(() => asset); - } - - return asset; - }), - - // Get the moderation setting from the settings. - Setting.retrieve() - ]) - .then(([asset, settings]) => { - - // Merge the asset specific settings with the returned settings object in - // the event that the asset that was returned also had settings. - if (asset && asset.settings) { - settings.merge(asset.settings); - } - - return Promise.all([ - - // This is the promised component... Fetch the comments based on the - // moderation settings. - Comment.findByAssetIdWithStatuses(asset.id, [null, 'accepted']), - - // Send back the reference to the asset. - asset, - - // Send back the settings to the stream. - settings - ]); - }) - - // Get all the users and actions for those comments. - .then(([comments, asset, settings]) => { - - // Get the user id's from the author id's as a unique array that gets - // sorted. - let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); - - // Fetch the users for which there is a comment available for them. - let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; - - // Fetch the actions for pretty much everything at this point. - let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false); - - return Promise.all([ - - // Pass back the asset that we loaded... - asset, - - // It's comments... - comments, - - // The users who wrote those comments - users, - - // And all actions about the asset, comments, and users. - actions, - - // Pass back the settings that we loaded. - settings - ]); - }) - .then(([asset, comments, users, actions, settings]) => { - - // Send back the payload containing all this data. - res.json({ - assets: [asset], - comments, - users, - actions, - settings - }); - }) - .catch(error => { - next(error); - }); -}); - -module.exports = router; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 756293c16..130d5bc0b 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -1,7 +1,8 @@ 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'); @@ -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({ @@ -35,7 +36,7 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { }); router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { - User + UsersService .addRoleToUser(req.params.user_id, req.body.role) .then(() => { res.status(204).end(); @@ -43,11 +44,15 @@ router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => .catch(next); }); -router.post('/:user_id/status', (req, res, next) => { - User - .setStatus(req.params.user_id, req.body.status, req.body.comment_id) +router.post('/:user_id/status', authorization.needed('admin'), (req, res, next) => { + UsersService + .setStatus(req.params.user_id, req.body.status) .then((status) => { res.status(201).json(status); + + if (status === 'BANNED' && req.body.comment_id) { + return CommentsService.pushStatus(req.body.comment_id, 'rejected', req.params.user_id); + } }) .catch(next); }); @@ -64,7 +69,7 @@ router.post('/:user_id/status', (req, res, next) => { * @param {String} userID the id for the user to send the email to * @param {String} email the email for the user to send the email to */ -const SendEmailConfirmation = (app, userID, email) => User +const SendEmailConfirmation = (app, userID, email) => UsersService .createEmailConfirmToken(userID, email) .then((token) => { return mailer.sendSimple({ @@ -87,14 +92,14 @@ router.post('/', (req, res, next) => { displayName } = req.body; - User + UsersService .createLocalUser(email, password, displayName) .then((user) => { // Get the settings from the database to find out if we need to send an // email confirmation. The Front end will know about the // requireEmailConfirmation as it's included in the settings get endpoint. - return Setting.retrieve().then(({requireEmailConfirmation = false}) => { + return SettingsService.retrieve().then(({requireEmailConfirmation = false}) => { if (requireEmailConfirmation) { @@ -123,7 +128,7 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { metadata } = req.body; - User + UsersService .addAction(req.params.user_id, req.user.id, action_type, metadata) .then((action) => { res.status(201).json(action); @@ -138,7 +143,7 @@ router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, 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/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/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..8f77fbea7 --- /dev/null +++ b/services/users.js @@ -0,0 +1,601 @@ +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const Wordlist = require('./wordlist'); +const errors = require('../errors'); +const uuid = require('uuid'); + +const UserModel = require('../models/user'); +const USER_STATUS = require('../models/user').USER_STATUS; +const USER_ROLES = require('../models/user').USER_ROLES; + +const ActionsService = require('./actions'); + +// In the event that the TALK_SESSION_SECRET is missing but we are testing, then +// set the process.env.TALK_SESSION_SECRET. +if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { + process.env.TALK_SESSION_SECRET = 'keyboard cat'; +} else if (!process.env.TALK_SESSION_SECRET) { + throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); +} + +const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; +const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; + +// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run +// through during the salting process. +const SALT_ROUNDS = 10; + +// UsersService is the interface for the application to interact with the +// UserModel through. +module.exports = class UsersService { + + /** + * Finds a user given their email address that we have for them in the system + * and ensures that the retuned user matches the password passed in as well. + * @param {string} email - email to look up the user by + * @param {string} password - password to match against the found user + * @param {Function} done [description] + */ + static findLocalUser(email, password) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required for findLocalUser'); + } + + return UserModel.findOne({ + profiles: { + $elemMatch: { + id: email.toLowerCase(), + provider: 'local' + } + } + }) + .then((user) => { + if (!user) { + return false; + } + + return new Promise((resolve, reject) => { + bcrypt.compare(password, user.password, (err, res) => { + if (err) { + return reject(err); + } + + if (!res) { + return resolve(false); + } + + return resolve(user); + }); + }); + }); + } + + /** + * Merges two users together by taking all the profiles on a given user and + * pushing them into the source user followed by deleting the destination user's + * user account. This will not merge the roles associated with the source user. + * @param {String} dstUserID id of the user to which is the target of the merge + * @param {String} srcUserID id of the user to which is the source of the merge + * @return {Promise} resolves when the users are merged + */ + static mergeUsers(dstUserID, srcUserID) { + let srcUser, dstUser; + + return Promise + .all([ + UserModel.findOne({id: dstUserID}).exec(), + UserModel.findOne({id: srcUserID}).exec() + ]) + .then((users) => { + dstUser = users[0]; + srcUser = users[1]; + + srcUser.profiles.forEach((profile) => { + dstUser.profiles.push(profile); + }); + + return srcUser.remove(); + }) + .then(() => dstUser.save()); + } + + /** + * Finds a user given a social profile and if the user does not exist, creates + * them. + * @param {Object} profile - User social/external profile + * @param {Function} done [description] + */ + static findOrCreateExternalUser(profile) { + return UserModel + .findOne({ + profiles: { + $elemMatch: { + id: profile.id, + provider: profile.provider + } + } + }) + .then((user) => { + if (user) { + return user; + } + + // The user was not found, lets create them! + user = new UserModel({ + displayName: profile.displayName, + roles: [], + profiles: [ + { + id: profile.id, + provider: profile.provider + } + ] + }); + + return user.save(); + }); + } + + static changePassword(id, password) { + return new Promise((resolve, reject) => { + bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { + if (err) { + return reject(err); + } + + resolve(hashedPassword); + }); + }) + .then((hashedPassword) => { + return UserModel.update({id}, { + $inc: {__v: 1}, + $set: { + password: hashedPassword + } + }); + }); + } + + /** + * Creates local users. + * @param {Array} users Users to create + * @return {Promise} Resolves with the users that were created + */ + static createLocalUsers(users) { + return Promise.all(users.map((user) => { + return UsersService + .createLocalUser(user.email, user.password, user.displayName); + })); + } + + /** + * Check the requested displayname for naughty words (currently in English) and special chars + * @param {String} displayName word to be checked for profanity + * @return {Promise} rejected if the machine's sensibilites are offended + */ + static isValidDisplayName(displayName) { + const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/; + + if (!displayName) { + return Promise.reject(errors.ErrMissingDisplay); + } + + if (!onlyLettersNumbersUnderscore.test(displayName)) { + + return Promise.reject(errors.ErrSpecialChars); + } + + // check for profanity + return Wordlist.displayNameCheck(displayName); + } + + /** + * Creates the local user with a given email, password, and name. + * @param {String} email email of the new user + * @param {String} password plaintext password of the new user + * @param {String} displayName name of the display user + * @param {Function} done callback + */ + static createLocalUser(email, password, displayName) { + + if (!email) { + return Promise.reject(errors.ErrMissingEmail); + } + + email = email.toLowerCase().trim(); + displayName = displayName.toLowerCase().trim(); + + if (!password) { + return Promise.reject(errors.ErrMissingPassword); + } + + if (password.length < 8) { + return Promise.reject(errors.ErrPasswordTooShort); + } + + return UsersService.isValidDisplayName(displayName) + .then(() => { // displayName is valid + return new Promise((resolve, reject) => { + bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { + if (err) { + return reject(err); + } + + let user = new UserModel({ + displayName: displayName, + password: hashedPassword, + roles: [], + profiles: [ + { + id: email, + provider: 'local' + } + ] + }); + + user.save((err) => { + if (err) { + if (err.code === 11000) { + if (err.message.match('displayName')) { + return reject(errors.ErrDisplayTaken); + } + return reject(errors.ErrEmailTaken); + } + return reject(err); + } + return resolve(user); + }); + }); + }); + }); + } + + /** + * Disables a given user account. + * @param {String} id id of a user + * @param {Function} done callback after the operation is complete + */ + static disableUser(id) { + return UserModel.update({ + id: id + }, { + $set: { + disabled: true + } + }); + } + + /** + * Enables a given user account. + * @param {String} id id of a user + * @param {Function} done callback after the operation is complete + */ + static enableUser(id) { + return UserModel.update({ + id: id + }, { + $set: { + disabled: false + } + }); + } + + /** + * Adds a role to a user. + * @param {String} id id of a user + * @param {String} role role to add + * @param {Function} done callback after the operation is complete + */ + static addRoleToUser(id, role) { + + // Check to see if the user role is in the allowable set of roles. + if (USER_ROLES.indexOf(role) === -1) { + + // User role is not supported! Error out here. + return Promise.reject(new Error(`role ${role} is not supported`)); + } + + return UserModel.update({ + id: id + }, { + $addToSet: { + roles: role + } + }); + } + + /** + * Removes a role from a user. + * @param {String} id id of a user + * @param {String} role role to remove + * @param {Function} done callback after the operation is complete + */ + static removeRoleFromUser(id, role) { + + // Check to see if the user role is in the allowable set of roles. + if (USER_ROLES.indexOf(role) === -1) { + + // User role is not supported! Error out here. + return Promise.reject(new Error(`role ${role} is not supported`)); + } + + return UserModel.update({ + id: id + }, { + $pull: { + roles: role + } + }); + } + + /** + * Set status of a user. + * @param {String} id id of a user + * @param {String} status status to set + * @param {Function} done callback after the operation is complete + */ + static setStatus(id, status) { + + // Check to see if the user status is in the allowable set of roles. + if (USER_STATUS.indexOf(status) === -1) { + + // User status is not supported! Error out here. + return Promise.reject(new Error(`status ${status} is not supported`)); + } + + return UserModel.update({id}, {$set: {status}}); + } + + /** + * Finds a user with the id. + * @param {String} id user id (uuid) + */ + static findById(id) { + return UserModel.findOne({id}); + } + + /** + * Finds users in an array of ids. + * @param {Array} ids array of user identifiers (uuid) + */ + static findByIdArray(ids) { + return UserModel.find({ + id: {$in: ids} + }); + } + + /** + * Finds public user information by an array of ids. + * @param {Array} ids array of user identifiers (uuid) + */ + static findPublicByIdArray(ids) { + return UserModel.find({ + id: {$in: ids} + }, 'id displayName'); + } + + /** + * Creates a JWT from a user email. Only works for local accounts. + * @param {String} email of the local user + */ + static createPasswordResetToken(email) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) + .then((user) => { + if (!user) { + + // Since we don't want to reveal that the email does/doesn't exist + // just go ahead and resolve the Promise with null and check in the + // endpoint. + return; + } + + const payload = { + jti: uuid.v4(), + email, + userId: user.id, + version: user.__v + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + algorithm: 'HS256', + expiresIn: '1d', + subject: PASSWORD_RESET_JWT_SUBJECT + }); + }); + } + + /** + * Verifies that the token was indeed signed by the session secret. + * @param {String} token JWT token from the client + * @return {Promise} + */ + static verifyToken(token, options = {}) { + return new Promise((resolve, reject) => { + + // Set the allowed algorithms. + options.algorithms = ['HS256']; + + jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { + if (err) { + return reject(err); + } + + resolve(decoded); + }); + }); + } + + /** + * Verifies a jwt and returns the associated user. + * @param {String} token the JSON Web Token to verify + */ + static verifyPasswordResetToken(token) { + return UsersService + .verifyToken(token, { + subject: PASSWORD_RESET_JWT_SUBJECT + }) + .then((decoded) => UsersService.findById(decoded.userId)); + } + + /** + * Finds a user using a value which gets compared using a prefix match against + * the user's email address and/or their display name. + * @param {String} value value to search by + * @return {Promise} + */ + static search(value) { + return UserModel.find({ + $or: [ + + // Search by a prefix match on the displayName. + { + 'displayName': { + $regex: new RegExp(`^${value}`), + $options: 'i' + } + }, + + // Search by a prefix match on the email address. + { + 'profiles': { + $elemMatch: { + id: { + $regex: new RegExp(`^${value}`), + $options: 'i' + }, + provider: 'local' + } + } + } + ] + }); + } + + /** + * Returns a count of the current users. + * @return {Promise} + */ + static count() { + return UserModel.count(); + } + + /** + * Returns all the users. + * @return {Promise} + */ + static all() { + UserModel.find(); + } + + /** + * Updates the user's settings. + * @return {Promise} + */ + static updateSettings(id, settings) { + return UserModel.update({ + id + }, { + $set: { + settings + } + }); + } + + /** + * Add an action to the user. + * @param {String} item_id identifier of the user (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the user + * @return {Promise} + */ + static addAction(item_id, user_id, action_type, metadata) { + return ActionsService.insertUserAction({ + item_id, + item_type: 'users', + user_id, + action_type, + metadata + }); + } + + /** + * This creates a token based around confirming the local profile. + * @param {String} userID The user id for the user that we are creating the + * token for. + * @param {String} email The email that we are needing to get confirmed. + * @return {Promise} + */ + static createEmailConfirmToken(userID, email) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UsersService + .findById(userID) + .then((user) => { + if (!user) { + return Promise.reject(new Error('user not found')); + } + + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); + + // Ensure that the user email hasn't already been verified. + if (profile && profile.metadata && profile.metadata.confirmed_at) { + return Promise.reject(new Error('email address already confirmed')); + } + + const payload = { + email, + userID + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + jwtid: uuid.v4(), + algorithm: 'HS256', + expiresIn: '1d', + subject: EMAIL_CONFIRM_JWT_SUBJECT + }); + }); + } + + /** + * This verifies that a given token was for the email confirmation and updates + * that user's profile with a 'confirmed_at' parameter with the current date. + * @param {String} token the token containing the email confirmation details + * signed with our secret. + * @return {Promise} + */ + static verifyEmailConfirmation(token) { + return UsersService + .verifyToken(token, { + subject: EMAIL_CONFIRM_JWT_SUBJECT + }) + .then(({userID, email}) => { + + return UserModel + .update({ + id: userID, + profiles: { + $elemMatch: { + id: email, + provider: 'local' + } + } + }, { + $set: { + 'profiles.$.metadata.confirmed_at': new Date() + } + }); + }); + } + +}; diff --git a/services/wordlist.js b/services/wordlist.js index 6cb8449a6..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) => { diff --git a/test/models/comment.js b/test/models/comment.js deleted file mode 100644 index 83a081da5..000000000 --- a/test/models/comment.js +++ /dev/null @@ -1,245 +0,0 @@ -const Comment = require('../../models/comment'); -const User = require('../../models/user'); -const Action = require('../../models/action'); -const Setting = require('../../models/setting'); - -const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - -const expect = require('chai').expect; - -describe('models.Comment', () => { - const comments = [{ - body: 'comment 10', - asset_id: '123', - status_history: [], - parent_id: '', - author_id: '123', - id: '1' - }, { - body: 'comment 20', - asset_id: '123', - status_history: [{ - type: 'accepted' - }], - status: 'accepted', - parent_id: '', - author_id: '123', - id: '2' - }, { - body: 'comment 30', - asset_id: '456', - status_history: [], - parent_id: '', - author_id: '456', - id: '3' - }, { - body: 'comment 40', - asset_id: '123', - status_history: [{ - type: 'rejected' - }], - status: 'rejected', - parent_id: '', - author_id: '456', - id: '4' - }, { - body: 'comment 50', - asset_id: '1234', - status_history: [{ - type: 'premod' - }], - status: 'premod', - parent_id: '', - author_id: '456', - id: '5' - }, { - body: 'comment 60', - asset_id: '1234', - status_history: [{ - type: 'premod' - }], - status: 'premod', - parent_id: '', - author_id: '456', - id: '6' - }]; - - const users = [{ - email: 'stampi@gmail.com', - displayName: 'Stampi', - password: '1Coral!!' - }, { - email: 'sockmonster@gmail.com', - displayName: 'Sockmonster', - password: '2Coral!!' - }]; - - const actions = [{ - action_type: 'flag', - item_id: '3', - item_type: 'comments', - user_id: '123' - }, { - action_type: 'like', - item_id: '1', - item_type: 'comments', - user_id: '456' - }]; - - beforeEach(() => { - return Setting.init(settings).then(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - }); - - describe('#publicCreate()', () => { - - it('creates a new comment', () => { - return Comment.publicCreate({ - body: 'This is a comment!', - status: 'accepted' - }).then((c) => { - expect(c).to.not.be.null; - expect(c.id).to.not.be.null; - expect(c.id).to.be.uuid; - expect(c.status).to.be.equal('accepted'); - }); - }); - - it('creates many new comments', () => { - return Comment.publicCreate([{ - body: 'This is a comment!', - status: 'accepted' - }, { - body: 'This is another comment!' - }, { - body: 'This is a rejected comment!', - status: 'rejected' - }]).then(([c1, c2, c3]) => { - expect(c1).to.not.be.null; - expect(c1.id).to.be.uuid; - expect(c1.status).to.be.equal('accepted'); - - expect(c2).to.not.be.null; - expect(c2.id).to.be.uuid; - expect(c2.status).to.be.null; - - expect(c3).to.not.be.null; - expect(c3.id).to.be.uuid; - expect(c3.status).to.be.equal('rejected'); - }); - }); - - }); - - describe('#findById()', () => { - - it('should find a comment by id', () => { - return Comment.findById('1').then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.property('body', 'comment 10'); - }); - }); - - }); - - describe('#findByAssetId()', () => { - - it('should find an array of all comments by asset id', () => { - return Comment.findByAssetId('123').then((result) => { - expect(result).to.have.length(3); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 10'); - expect(result[1]).to.have.property('body', 'comment 20'); - expect(result[2]).to.have.property('body', 'comment 40'); - }); - }); - }); - - describe('#moderationQueue()', () => { - - it('should find an array of new comments to moderate when pre-moderation', () => { - return Comment.moderationQueue('premod').then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.lengthOf(2); - }); - }); - - }); - - describe('#removeAction', () => { - - it('should remove an action', () => { - return Comment.removeAction('3', '123', 'flag') - .then(() => { - return Action.findByItemIdArray(['123']); - }) - .then((actions) => { - expect(actions.length).to.equal(0); - }); - }); - }); - - describe('#findByUserId', () => { - it('should return all comments if admin', () => { - return Comment.findByUserId('456', true) - .then(comments => { - expect(comments).to.have.length(4); - }); - }); - - it('should not return premod and rejected comments if not admin', () => { - return Comment.findByUserId('456') - .then(comments => { - expect(comments).to.have.length(1); - }); - }); - - }); - - describe('#changeStatus', () => { - - it('should change the status of a comment from no status', () => { - let comment_id = comments[0].id; - - return Comment.findById(comment_id) - .then((c) => { - expect(c.status).to.be.null; - - return Comment.pushStatus(comment_id, 'rejected', '123'); - }) - .then(() => Comment.findById(comment_id)) - .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.equal('rejected'); - expect(c.status_history).to.have.length(1); - expect(c.status_history[0]).to.have.property('type', 'rejected'); - expect(c.status_history[0]).to.have.property('assigned_by', '123'); - }); - }); - - it('should change the status of a comment from accepted', () => { - return Comment.pushStatus(comments[1].id, 'rejected', '123') - .then(() => Comment.findById(comments[1].id)) - .then((c) => { - expect(c).to.have.property('status_history'); - expect(c).to.have.property('status'); - expect(c.status).to.equal('rejected'); - expect(c.status_history).to.have.length(2); - expect(c.status_history[0]).to.have.property('type', 'accepted'); - expect(c.status_history[0]).to.have.property('assigned_by', null); - - expect(c.status_history[1]).to.have.property('type', 'rejected'); - expect(c.status_history[1]).to.have.property('assigned_by', '123'); - }); - }); - - }); -}); diff --git a/test/routes/api/assets/index.js b/test/routes/api/assets/index.js index 3354f9671..9c9d0cf05 100644 --- a/test/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', @@ -121,7 +122,7 @@ 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); @@ -135,7 +136,7 @@ describe('/api/v1/assets', () => { 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/test/routes/api/auth/index.js b/test/routes/api/auth/index.js index 6b39e0c8e..4882d859e 100644 --- a/test/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 index e905f273d..955a35ada 100644 --- a/test/routes/api/comments/index.js +++ b/test/routes/api/comments/index.js @@ -8,18 +8,19 @@ const expect = chai.expect; 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 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 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)); + beforeEach(() => SettingsService.init(settings)); describe('#get', () => { const comments = [{ @@ -34,16 +35,16 @@ describe('/api/v1/comments', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', - status: 'rejected', + status: 'REJECTED', status_history: [{ - type: 'rejected' + type: 'REJECTED' }] }, { body: 'comment 30', asset_id: '456', - status: 'accepted', + status: 'ACCEPTED', status_history: [{ - type: 'accepted' + type: 'ACCEPTED' }] }]; @@ -58,18 +59,18 @@ describe('/api/v1/comments', () => { }]; const actions = [{ - action_type: 'flag', + action_type: 'FLAG', item_id: 'abc', - item_type: 'comments' + item_type: 'COMMENTS' }, { - action_type: 'like', + action_type: 'LIKE', item_id: 'hij', - item_type: 'comments' + item_type: 'COMMENTS' }]; beforeEach(() => { return Promise.all([ - Comment.create(comments).then((newComments) => { + CommentModel.create(comments).then((newComments) => { newComments.forEach((comment, i) => { comments[i].id = comment.id; }); @@ -77,9 +78,9 @@ describe('/api/v1/comments', () => { actions[0].item_id = comments[0].id; actions[1].item_id = comments[1].id; - return Action.create(actions); + return ActionModel.create(actions); }), - User.createLocalUsers(users) + UsersService.createLocalUsers(users) ]); }); @@ -119,7 +120,7 @@ describe('/api/v1/comments', () => { it('should return all the rejected comments', () => { return chai.request(app) - .get('/api/v1/comments?status=rejected') + .get('/api/v1/comments?status=REJECTED') .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); @@ -131,7 +132,7 @@ describe('/api/v1/comments', () => { it('should return all the approved comments', () => { return chai.request(app) - .get('/api/v1/comments?status=accepted') + .get('/api/v1/comments?status=ACCEPTED') .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); @@ -142,7 +143,7 @@ describe('/api/v1/comments', () => { it('should return all the new comments', () => { return chai.request(app) - .get('/api/v1/comments?status=new') + .get('/api/v1/comments?status=NEW') .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); @@ -152,7 +153,7 @@ describe('/api/v1/comments', () => { it('should return all the flagged comments', () => { return chai.request(app) - .get('/api/v1/comments?action_type=flag') + .get('/api/v1/comments?action_type=FLAG') .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); @@ -162,174 +163,6 @@ describe('/api/v1/comments', () => { }); }); }); - - describe('#post', () => { - - let asset_id; - let postmod_asset_id; - - beforeEach(() => Promise.all([ - Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => { - - // Update the asset id. - asset_id = asset.id; - }), - Asset.findOrCreateByUrl('https://coralproject.net/section/postmod-article-is-the-best').then((asset) => { - - // Update the asset id. - postmod_asset_id = asset.id; - - return Asset.overrideSettings(postmod_asset_id, {moderation: 'post'}); - }), - ])); - - it('should create a comment', () => { - return chai.request(app) - .post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'premod'); - }); - }); - - it('should create a comment with a rejected status if it contains a bad word', () => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); - }); - }); - - it('should create a comment with no status and a flag if it contains a suspected word', () => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', null); - - return Promise.all([ - res.body, - Action.findByType('flag', 'comments') - ]); - }) - .then(([comment, actions]) => { - expect(actions).to.have.length(1); - - let action = actions[0]; - - expect(action).to.have.property('item_id', comment.id); - expect(action).to.have.property('metadata'); - expect(action.metadata).to.have.property('field', 'body'); - expect(action.metadata).to.have.property('details', 'Matched suspect word filters.'); - }); - }); - - it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - .then((asset) => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', 'premod'); - }); - }); - - it('should create a comment with null status if it\'s asset is has post-moderation enabled', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'post'}) - .then(() => asset); - }) - .then((asset) => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', null); - }); - }); - - it('should create a rejected comment if the body is above the character count', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {charCountEnable: true, charCount: 10}) - .then(() => asset); - }) - .then((asset) => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'This is way way way way way too long.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status', 'rejected'); - }); - }); - - it('shouldn\'t create a comment when the asset has expired commenting', () => { - return Asset.create({ - closedAt: new Date().setDate(0), - closedMessage: 'tests said expired!' - }) - .then((asset) => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(500); - }) - .catch((err) => { - expect(err.response.body).to.not.be.null; - expect(err.response.body).to.have.property('message'); - expect(err.response.body.error.metadata.closedMessage).to.be.equal('tests said expired!'); - }); - }); - - it('should create a comment when the asset has not expired yet', () => { - return Asset.create({ - closedAt: new Date().setDate(32), - closedMessage: 'tests said expired!' - }) - .then((asset) => { - return chai.request(app).post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - }); - }); - }); }); describe('/api/v1/comments/:comment_id', () => { @@ -360,21 +193,21 @@ describe('/api/v1/comments/:comment_id', () => { }]; const actions = [{ - action_type: 'flag', + action_type: 'FLAG', item_id: 'abc', - item_type: 'comment' + item_type: 'COMMENTS' }, { - action_type: 'like', + action_type: 'LIKE', item_id: 'hij', - item_type: 'comment' + item_type: 'COMMENTS' }]; beforeEach(() => { - return Setting.init(settings).then(() => { + return SettingsService.init(settings).then(() => { return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) ]); }); }); @@ -400,7 +233,7 @@ describe('/api/v1/comments/:comment_id', () => { .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(204); - return Comment.findById('abc'); + return CommentsService.findById('abc'); }) .then((comment) => { expect(comment).to.be.null; @@ -448,15 +281,17 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', + status: 'REJECTED', status_history: [{ - type: 'rejected' + type: 'REJECTED' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', + status: 'ACCEPTED', status_history: [{ - type: 'accepted' + type: 'ACCEPTED' }] }]; @@ -471,19 +306,21 @@ describe('/api/v1/comments/:comment_id/actions', () => { }]; const actions = [{ - action_type: 'flag', + action_type: 'FLAG', + item_type: 'COMMENTS', item_id: 'abc' }, { - action_type: 'like', + action_type: 'LIKE', + item_type: 'COMMENTS', item_id: 'hij' }]; beforeEach(() => { - return Setting.init(settings).then(() => { + return SettingsService.init(settings).then(() => { return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) ]); }); }); diff --git a/test/routes/api/queue/index.js b/test/routes/api/queue/index.js index 6a7507618..ad2b7d11d 100644 --- a/test/routes/api/queue/index.js +++ b/test/routes/api/queue/index.js @@ -10,9 +10,9 @@ 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 SettingsService = require('../../../../services/settings'); const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['banned'], suspect: ['suspect']}}; describe('/api/v1/queue', () => { @@ -21,26 +21,26 @@ describe('/api/v1/queue', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: 'rejected', + status: 'REJECTED', status_history: [{ - type: 'rejected' + type: 'REJECTED' }] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: 'premod', + status: 'PREMOD', status_history: [{ - type: 'premod' + type: 'PREMOD' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: 'accepted', + status: 'ACCEPTED', status_history: [{ - type: 'accepted' + type: 'ACCEPTED' }] }]; @@ -55,18 +55,18 @@ describe('/api/v1/queue', () => { }]; const actions = [{ - action_type: 'flag', + action_type: 'FLAG', item_id: 'abc', - item_type: 'comment' + item_type: 'COMMENTS' }, { - action_type: 'like', + action_type: 'LIKE', item_id: 'hij', - item_type: 'comment' + item_type: 'COMMENTS' }]; beforeEach(() => { - return Setting.init(settings).then(() => { - return User.createLocalUsers(users) + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUsers(users) .then((u) => { comments[0].author_id = u[0].id; comments[1].author_id = u[1].id; @@ -80,7 +80,7 @@ describe('/api/v1/queue', () => { return Promise.all([ Action.create(actions), - Setting.init(settings) + SettingsService.init(settings) ]); }); }); diff --git a/test/routes/api/settings/index.js b/test/routes/api/settings/index.js index 318af81f4..2eaf6eef8 100644 --- a/test/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 SettingsService = require('../../../../services/settings'); const defaults = {id: '1', moderation: 'pre'}; describe('/api/v1/settings', () => { - beforeEach(() => Setting.init(defaults)); + beforeEach(() => SettingsService.init(defaults)); describe('#get', () => { @@ -40,7 +40,7 @@ describe('/api/v1/settings', () => { .then((res) => { expect(res).to.have.status(204); - return Setting.retrieve(); + return SettingsService.retrieve(); }) .then((settings) => { expect(settings).to.have.property('moderation', 'post'); diff --git a/test/routes/api/stream/index.js b/test/routes/api/stream/index.js deleted file mode 100644 index 104801996..000000000 --- a/test/routes/api/stream/index.js +++ /dev/null @@ -1,225 +0,0 @@ -const app = require('../../../../app'); -const chai = require('chai'); -const expect = chai.expect; - -// Setup chai. -chai.should(); -chai.use(require('chai-http')); - -const Action = require('../../../../models/action'); -const User = require('../../../../models/user'); -const Comment = require('../../../../models/comment'); -const Asset = require('../../../../models/asset'); - -const Setting = require('../../../../models/setting'); - -describe('/api/v1/stream', () => { - describe('#get', () => { - const settings = { - id: '1', - moderation: 'post', - wordlist: { - banned: ['banned'], - suspect: ['suspect'] - } - }; - - const assets = [ - { - url: 'https://example.com/article/1' - }, - { - url: 'https://example.com/article/2', - settings: { - moderation: 'pre' - } - }, - { - url: 'https://example.com/article/3' - } - ]; - - const comments = [{ - id: 'abc', - body: 'comment 10', - author_id: '', - parent_id: '', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - id: 'def', - body: 'comment 20', - author_id: '', - parent_id: '', - status: null, - status_history: [] - }, { - id: 'uio', - body: 'comment 30', - asset_id: 'asset', - author_id: '456', - parent_id: '', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - id: 'hij', - body: 'comment 40', - asset_id: '456', - status: 'rejected', - status_history: [{ - type: 'rejected' - }] - }, { - body: 'comment 50', - status: 'premod', - status_history: [{ - type: 'premod' - }] - }, { - body: 'comment 60', - status: 'accepted', - status_history: [{ - type: 'accepted' - }] - }, { - body: 'comment 70', - status: 'rejected', - status_history: [{ - type: 'rejected' - }] - }, { - body: 'comment 70', - status: null, - status_history: [] - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123456789' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123456789' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Setting.init(settings) - .then(() => Promise.all([ - User.createLocalUsers(users), - Promise.all(assets.map((asset) => Asset.create(asset))) - ])) - .then(([mockUsers, mockAssets]) => { - - // Map the id's over. - mockAssets.forEach((asset, i) => { - assets[i].id = asset.id; - }); - - mockUsers.forEach((user, i) => { - users[i].id = user.id; - }); - - comments.forEach((comment, i) => { - comments[i].author_id = users[(i % 2) === 0 ? 0 : 1].id; - }); - - comments[0].asset_id = assets[0].id; - comments[1].asset_id = assets[0].id; - comments[2].asset_id = assets[1].id; - comments[3].asset_id = assets[1].id; - comments[4].asset_id = assets[2].id; - comments[5].asset_id = assets[2].id; - comments[6].asset_id = assets[2].id; - comments[7].asset_id = assets[2].id; - - return Promise.all([ - Comment.create(comments), - Action.create(actions) - ]); - }); - }); - - it('should return a stream with comments, users and actions for an existing asset', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[0].url}) - .then(res => { - expect(res).to.have.status(200); - expect(res.body.assets.length).to.equal(1); - expect(res.body.comments.length).to.equal(2); - expect(res.body.users.length).to.equal(2); - expect(res.body.actions.length).to.equal(1); - expect(res.body.settings).to.have.property('moderation', 'post'); - }); - }); - - it('should reject requests without a scheme in the asset_url', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: 'test.com'}) - .catch((err) => { - expect(err).to.have.status(400); - expect(err.response.body.message).to.contain('asset_url is invalid'); - }); - }); - - it('should merge the settings when the asset contains settings to override it with', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[1].url}) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.assets).to.have.length(1); - expect(res.body.comments).to.have.length(1); - expect(res.body.users).to.have.length(1); - expect(res.body.settings).to.have.property('moderation', 'pre'); - expect(res.body.settings).to.not.have.property('wordlist'); - }); - }); - - it('should not change the previously displayed comments based on moderation state changes', () => { - - let preComments, postComments; - - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[2].url}) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments.length).to.equal(2); - expect(res.body.settings).to.have.property('moderation', 'post'); - - preComments = res.body.comments; - - return Asset.overrideSettings(assets[2].id, {moderation: 'pre'}); - }) - .then(() => { - return chai.request(app) - .get('/api/v1/stream') - .query({asset_url: assets[2].url}); - }) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body.comments.length).to.equal(2); - expect(res.body.settings).to.have.property('moderation', 'pre'); - - postComments = res.body.comments; - - expect(preComments).to.deep.equal(postComments); - }); - }); - }); -}); diff --git a/test/routes/api/user/index.js b/test/routes/api/user/index.js index 3aa6cbe3c..200513088 100644 --- a/test/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 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; @@ -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); }); }); diff --git a/test/models/action.js b/test/services/actions.js similarity index 58% rename from test/models/action.js rename to test/services/actions.js index a1ebdc027..7fdec0a12 100644 --- a/test/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/test/models/asset.js b/test/services/assets.js similarity index 69% rename from test/models/asset.js rename to test/services/assets.js index 7c5869caf..c748a9797 100644 --- a/test/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,16 +66,16 @@ 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'); @@ -82,7 +87,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'); @@ -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..a2e7d3bcc --- /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/test/services/scraper.js b/test/services/scraper.js index f3b5d006c..ab2474134 100644 --- a/test/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/test/models/setting.js b/test/services/settings.js similarity index 75% rename from test/models/setting.js rename to test/services/settings.js index a7c97abfb..c99bd07a6 100644 --- a/test/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 => { + 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(''); }); @@ -23,7 +23,7 @@ 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 => { + 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('infoBoxEnable', true); @@ -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,7 +42,7 @@ 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'}; diff --git a/test/models/user.js b/test/services/users.js similarity index 61% rename from test/models/user.js rename to test/services/users.js index 16ccacede..44f065531 100644 --- a/test/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']}}; - return Setting.init(settings).then(() => { - return User.createLocalUsers([{ + return SettingsService.init(settings).then(() => { + return UsersService.createLocalUsers([{ email: 'stampi@gmail.com', displayName: 'Stampi', password: '1Coral!-' @@ -30,11 +30,10 @@ describe('models.User', () => { describe('#findById()', () => { it('should find a user by id', () => { - return User + return UsersService .findById(mockUsers[0].id) .then((user) => { - expect(user).to.have.property('displayName') - .and.to.equal('stampi'); + expect(user).to.have.property('displayName', 'stampi'); }); }); }); @@ -42,7 +41,7 @@ describe('models.User', () => { describe('#findByIdArray()', () => { it('should find an array of users from an array of ids', () => { const ids = mockUsers.map((user) => user.id); - return User.findByIdArray(ids).then((result) => { + return UsersService.findByIdArray(ids).then((result) => { expect(result).to.have.length(3); }); }); @@ -51,15 +50,14 @@ describe('models.User', () => { describe('#findPublicByIdArray()', () => { it('should find an array of users from an array of ids', () => { const ids = mockUsers.map((user) => user.id); - return User.findPublicByIdArray(ids).then((result) => { + return UsersService.findPublicByIdArray(ids).then((result) => { expect(result).to.have.length(3); const sorted = result.sort((a, b) => { if(a.displayName < b.displayName) {return -1;} if(a.displayName > b.displayName) {return 1;} return 0; }); - expect(sorted[0]).to.have.property('displayName') - .and.to.equal('marvel'); + expect(sorted[0]).to.have.property('displayName', 'marvel'); }); }); }); @@ -67,7 +65,7 @@ describe('models.User', () => { describe('#findLocalUser', () => { it('should find a user when we give the right credentials', () => { - return User + return UsersService .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.have.property('displayName') @@ -76,7 +74,7 @@ describe('models.User', () => { }); it('should not find the user when we give the wrong credentials', () => { - return User + return UsersService .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.equal(false); @@ -87,9 +85,9 @@ describe('models.User', () => { describe('#createLocalUser', () => { it('should not create a user with duplicate display name', () => { - return User.createLocalUsers([{ + return UsersService.createLocalUsers([{ email: 'otrostampi@gmail.com', - displayName: 'Stampi', + displayName: 'StampiTheSecond', password: '1Coralito!' }]) .then((user) => { @@ -104,7 +102,7 @@ describe('models.User', () => { describe('#createEmailConfirmToken', () => { it('should create a token for a valid user', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; @@ -112,15 +110,15 @@ describe('models.User', () => { }); it('should not create a token for a user already verified', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }) .then(() => { - return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); + return UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); }) .catch((err) => { expect(err).to.have.property('message', 'email address already confirmed'); @@ -132,17 +130,17 @@ describe('models.User', () => { describe('#verifyEmailConfirmation', () => { it('should correctly validate a valid token', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }); }); it('should correctly reject an invalid token', () => { - return User + return UsersService .verifyEmailConfirmation('cats') .catch((err) => { expect(err).to.not.be.null; @@ -150,15 +148,15 @@ describe('models.User', () => { }); it('should update the user model when verification is complete', () => { - return User + return UsersService .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) .then((token) => { expect(token).to.not.be.null; - return User.verifyEmailConfirmation(token); + return UsersService.verifyEmailConfirmation(token); }) .then(() => { - return User.findById(mockUsers[0].id); + return UsersService.findById(mockUsers[0].id); }) .then((user) => { expect(user.profiles[0]).to.have.property('metadata'); @@ -171,85 +169,42 @@ describe('models.User', () => { describe('#setStatus', () => { it('should set the status to active', () => { - return User - .setStatus(mockUsers[0].id, 'active') - .then(() => { - User.findById(mockUsers[0].id) - .then((user) => { - expect(user).to.have.property('status') - .and.to.equal('active'); - }); + return UsersService + .setStatus(mockUsers[0].id, 'ACTIVE') + .then(() => UsersService.findById(mockUsers[0].id)) + .then((user) => { + expect(user).to.have.property('status', 'ACTIVE'); }); }); }); describe('#ban', () => { - - let mockComment; - - beforeEach(() => { - return Comment.create([ - { - body: 'testing the comment for that user if it is rejected.', - id: mockUsers[0].id - } - ]) - .then(([comment]) => { - mockComment = comment; - }); - }); - it('should set the status to banned', () => { - return User - .setStatus(mockUsers[0].id, 'banned', mockComment.id) - .then(() => { - return User.findById(mockUsers[0].id); - }) + return UsersService + .setStatus(mockUsers[0].id, 'BANNED') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status') - .and.to.equal('banned'); - }); - }); - - it('should set the comment to rejected', () => { - return User - .setStatus(mockUsers[0].id, 'banned', mockComment.id) - .then(() => { - return Comment.findById(mockComment.id); - }) - .then((comment) => { - expect(comment).to.have.property('status') - .and.to.equal('rejected'); + expect(user).to.have.property('status', 'BANNED'); }); }); it('should still disable and ban the user if there is no comment', () => { - return User - .setStatus(mockUsers[0].id, 'banned', '') - .then(() => User.findById(mockUsers[0].id)) + return UsersService + .setStatus(mockUsers[0].id, 'BANNED') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status', 'banned'); + expect(user).to.have.property('status', 'BANNED'); }); }); }); describe('#unban', () => { - let mockComment; - beforeEach(() => { - return Promise.all([ - Comment.create([{body: 'testing the comment for that user if it is rejected.', id: mockUsers[0].id}]) - ]) - .then((comment) => { - mockComment = comment; - }); - }); - it('should set the status to active', () => { - return User - .setStatus(mockUsers[0].id, 'active', mockComment.id) - .then(() => User.findById(mockUsers[0].id)) + return UsersService + .setStatus(mockUsers[0].id, 'ACTIVE') + .then(() => UsersService.findById(mockUsers[0].id)) .then((user) => { - expect(user).to.have.property('status', 'active'); + expect(user).to.have.property('status', 'ACTIVE'); }); }); }); diff --git a/test/services/wordlist.js b/test/services/wordlist.js index 5bb1d32ab..183564495 100644 --- a/test/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: [ @@ -19,7 +19,7 @@ describe('wordlist: services', () => { let wordlist = new Wordlist(); const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; - beforeEach(() => Setting.init(settings)); + beforeEach(() => SettingsService.init(settings)); describe('#init', () => { From d977c33cf5f24ef1f7efc7b26ba8149eca59d5c8 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Tue, 24 Jan 2017 12:13:56 -0700 Subject: [PATCH 3/6] refetch when you post a comment --- client/coral-embed-stream/src/Comment.js | 2 +- client/coral-embed-stream/src/Embed.js | 2 +- client/coral-plugin-commentbox/CommentBox.js | 8 ++++---- client/coral-plugin-replies/ReplyBox.js | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index e90bcd5cb..f734bce0b 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -132,7 +132,7 @@ class Comment extends React.Component { { this.state.replyBoxVisible ? { + commentPostedHandler={() => { console.log('replyPostedHandler'); this.setState({replyBoxVisible: false}); refetch(); diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index 66dcfe90f..1060d2cb3 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -120,7 +120,7 @@ class Embed extends Component { { user ? console.error(err)); diff --git a/client/coral-plugin-replies/ReplyBox.js b/client/coral-plugin-replies/ReplyBox.js index ac1a702f8..e7ef83086 100644 --- a/client/coral-plugin-replies/ReplyBox.js +++ b/client/coral-plugin-replies/ReplyBox.js @@ -3,10 +3,10 @@ import CommentBox from '../coral-plugin-commentbox/CommentBox'; const name = 'coral-plugin-replies'; -const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, replyPostedHandler}) => ( +const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler}) => (
Date: Tue, 24 Jan 2017 12:15:11 -0700 Subject: [PATCH 4/6] Fixed import --- graph/loaders/assets.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index b4bb092cc..a07a18a3f 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -6,7 +6,7 @@ const scraper = require('../../services/scraper'); const util = require('./util'); const AssetModel = require('../../models/asset'); -const AssetService = require('../../models/asset'); +const AssetsService = require('../../services/assets'); /** * Retrieves assets by an array of ids. @@ -33,7 +33,7 @@ const findOrCreateAssetByURL = (context, asset_url) => { return Promise.reject(errors.ErrInvalidAssetURL); } - return AssetService.findOrCreateByUrl(asset_url) + return AssetsService.findOrCreateByUrl(asset_url) .then((asset) => { // If the asset wasn't scraped before, scrape it! Otherwise just return From 71f219c0c4e8c789cd850d3b88fc75fa3dadcac6 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 24 Jan 2017 12:17:06 -0700 Subject: [PATCH 5/6] Fixed another import --- graph/loaders/users.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graph/loaders/users.js b/graph/loaders/users.js index 455eac250..b7f0fc573 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -1,8 +1,8 @@ const DataLoader = require('dataloader'); -const UserService = require('../../models/user'); +const UsersService = require('../../services/users'); -const genUserByIDs = (context, ids) => UserService.findByIdArray(ids); +const genUserByIDs = (context, ids) => UsersService.findByIdArray(ids); /** * Creates a set of loaders based on a GraphQL context. From 368606befe9ed6b914e8d62a6d452fc0a832dbd1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 24 Jan 2017 12:18:32 -0700 Subject: [PATCH 6/6] Fixed yet, another, import --- services/scraper.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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)