diff --git a/README.md b/README.md index 0f217d89c..0b77e95b7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,14 @@ A commenting platform from The Coral Project. [https://coralproject.net](https:/ ## Contributing to Talk +### Product Roadmap +You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625 + +You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk + ### Local Dependencies Node + Mongo ### Getting Started @@ -19,13 +25,19 @@ Runs Talk. The Talk application requires specific configuration options to be available inside the environment in order to run, those variables are listed here: -- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to - secure cookies +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to +secure cookies. - `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook - Login enabled app. +Login enabled app. - `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your - Facebook Login enabled app. -- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. +Facebook Login enabled app. +- `TALK_ROOT_URL` (*required*) - root url of the installed application externally +available in the format: `://` without the path. +- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name. +- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. +- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. +- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. +- `TALK_SMTP_PORT` (*required*) - SMTP port. ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` @@ -37,9 +49,11 @@ Make sure you have Docker running first and then run `docker-compose up -d` `npm run lint` ### Helpful URLs -Bare comment stream: http://localhost:5000/client/coral-embed-stream/ -Comment stream embedded on sample article: http://localhost:5000/client/coral-embed-stream/samplearticle.html -Moderator view: http://localhost:5000/admin/ +Comment stream: http://localhost:3000/ + +Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html + +Moderator view: http://localhost:3000/admin ### Docs `swagger.yaml` diff --git a/app.js b/app.js index dd58e9883..fe392932b 100644 --- a/app.js +++ b/app.js @@ -94,7 +94,9 @@ app.use((req, res, next) => { // returning a status code that makes sense. app.use('/api', (err, req, res, next) => { if (err !== ErrNotFound) { - console.error(err); + if (app.get('env') !== 'test') { + console.error(err); + } } res.status(err.status || 500); diff --git a/architecture.png b/architecture.png new file mode 100644 index 000000000..0096ccd41 Binary files /dev/null and b/architecture.png differ diff --git a/architecture.xml b/architecture.xml new file mode 100644 index 000000000..ce2b09a19 --- /dev/null +++ b/architecture.xml @@ -0,0 +1 @@ +7Vpbc6M2FP41nrYP6wFksP1oO0k7nd1O2nTa7qMMCtZGICpE4vTXVwIJkCVnnQbbWU/z4MDRBen7zk0HRmCVbX9ksNh8ogkio8BLtiNwNQqC6dwTv1Lw3AjCYNoIUoaTRuR3gjv8D1JCNS6tcIJKoyOnlHBcmMKY5jmKuSGDjNEns9s9JeZTC5giS3AXQ2JL/8QJ3zTSWeh18p8QTjf6yb6nWtYwfkgZrXL1vFEA7uu/pjmDei7Vv9zAhD71ROB6BFaMUt5cZdsVIhJaDVsz7mZPa7tuhnJ+yIBJ1Ix4hKRCesn1wvizBkOMELiLm6VYbSGFMaGVmGD5tMEc3RUwlsInoQlCtuEZEXe+uFRTI8bRdu/6/HbXQpkQzRBnz6KLGhBpjdCK1Nw9daQEGvpNn5C56giVIqTtzB0Y4kLh4cYGOKBIhJqoW8r4hqY0h+S6ky5r7pGcwTPBQFvM/5LicajuPuuWXCys1yRvP6sJSg4ZX0iNrmGHZYljLb7BpJ08T+xOQtjr8gVx/qwsDVacClG3g4+UFqpfyRl9QCtKKKv3DLz6r23R9tARbLEpIKMVixVmCkWx4hSpXoHSOgnni1rAEIEcP5pW+RZOw/kxOPV7jHrjaWiQalA6Bt8IqTvU/BeWQ+9EpEb+8UkNTFLHfmTyGrbtt4hhsQXEviETHoDtJpKcgO3AiliLohCCO8REqBkFERErWib4UVym8vL7X0R6MgpWcp/bgqGyHH8pf9AdxeN6fS1FOijEOdCyot7eEAemZojzdZbRC3L+xBXkZt7b0Zx8Pf4fH4I2fVIQABcEMxcEkwEgiCbHdx8vhYTatVyil/AnjtB/Ii/huxTbcg2LJMP5SObIN+JXHmOYWAbN9zkHewLpet6JI/F1dqwdSTCzrShwWFE0hBGF5zWi6cUaUXhGIwotI1pRBmWX62yNkvIdaDmYOWLFsbTcn9vJRyWeGni3jD5i4T7OAUmkE2Bt+L59TPY9YEMCBoCkXW4HySeIiUrIsNj1OQAJDECmDhVxlQ2GwCME53WEl+oHdc3gLCfMwC6T/UzXwta9XyskJI5Dx0OF3tcxY7aTYx/qN6cDGAVwBBKCxWa+kxjeib1biIhtcRMEU8dymkvLuRe6uiOCBKe51GkxvzyFLyVIOIZkoRoynCS12blQ34Pra4qWhxxnpg6kgyGQjo7hfuyi5WW4mDe4DjX0lmIxY0t9sHOYB7ucNi5Njepoff1EjR+0Jqr1o93PYSWAo5TPTJVpg5Sjzn056mRGLDC3I1ZzLD1BxJrYdZz/X14ci9TwRKQC+/hxw2jtNq7zRJ5CSJXivHTlIwzBWHS8YSiptoemJfqFXxOLE1qt6xbf0BV/TyQd9k3g7rkvmJzw3DeZWsCrYpFQNFU+imAmd52vy8JdK1rRLKtyzJ/39f1qtekTzGHa1bgPJK/KyCKWC+0yoY9wjcgtLXFd7AJXa8o5zUQHIhuW7UvsniWp19h2NsXpDtntO21pd4V+GyKVA+epEg+gEMAMkD6w9QEcSx1mdjrbI9eiDWXrujLwrmkbnhLgOHcfixKdYvco+Q0l2C7HaHgTyGEp4N13Dhj4O4adigSIbGjmcxsa3x8AG/2sgXMBqyRxifE+1J8fnaH8GjpKazRP6dV+p3FqrTYNPnAWHl3v7QZR68CCx3C+PXyivyv5RdVSFhU+qPrAQvQg6J53rbuR975JrexpZMOHslZPOYs/Kbb2LLpS/jskD+LfgsWSj5hXjfgKw5TBrBcUmseZS3ghJTjz9v4QLWNfUkiKDXzlNi6p1mNmIbZbb2VvrDTL3L39LrA55nffXoLrfwE= \ No newline at end of file diff --git a/bin/cli-settings b/bin/cli-settings index c639a5ca5..e7ba30151 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -15,7 +15,7 @@ const mongoose = require('../mongoose'); const Setting = require('../models/setting'); const util = require('../util'); -// Regeister the shutdown criteria. +// Register the shutdown criteria. util.onshutdown([ () => mongoose.disconnect() ]); diff --git a/bin/cli-users b/bin/cli-users index 2e0dc84e6..012eba9d4 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -34,6 +34,7 @@ function createUser(options) { email: options.email, password: options.password, displayName: options.name, + role: options.role }); } @@ -62,6 +63,11 @@ function createUser(options) { name: 'displayName', description: 'Display Name', required: true + }, + { + name: 'role', + description: 'User Role', + required: false } ], (err, result) => { if (err) { @@ -76,15 +82,21 @@ function createUser(options) { }); }) .then((result) => { - return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()); - }) - .then((user) => { - console.log(`Created user ${user.id}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(); + return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) + .then((user) => { + console.log(`Created user ${user.id}.`); + + return User + .addRoleToUser(user.id, result.role.trim()) + .then(() => { + console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); + util.shutdown(); + }); + }) + .catch((err) => { + console.error(err); + util.shutdown(); + }); }); } @@ -330,6 +342,7 @@ program .option('--email [email]', 'Email to use') .option('--password [password]', 'Password to use') .option('--name [name]', 'Name to use') + .option('--role [role]', 'Role to add') .option('-f, --flag_mode', 'Source from flags instead of prompting') .description('create a new user') .action(createUser); diff --git a/client/coral-admin/src/containers/Configure/CommentSettings.js b/client/coral-admin/src/containers/Configure/CommentSettings.js index f5be9f5c8..d9628f352 100644 --- a/client/coral-admin/src/containers/Configure/CommentSettings.js +++ b/client/coral-admin/src/containers/Configure/CommentSettings.js @@ -26,11 +26,16 @@ const updateInfoBoxContent = (props) => (event) => { props.updateSettings({infoBoxContent}); }; -const StateLess = (props) => +const updateClosedMessage = (props) => (event) => { + const closedMessage = event.target.value; + props.updateSettings({closedMessage}); +}; + +const CommentSettings = (props) => {lang.t('configure.enable-pre-moderation')} @@ -38,7 +43,7 @@ const StateLess = (props) => @@ -48,10 +53,20 @@ const StateLess = (props) =>

- + + + {lang.t('configure.closed-comments-desc')} + + + + @@ -59,6 +74,6 @@ const StateLess = (props) =>
; -export default StateLess; +export default CommentSettings; const lang = new I18n(translations); diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index 4ee8a1c13..db3bf6f20 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -18,21 +18,19 @@ import Wordlist from './Wordlist'; class Configure extends React.Component { constructor (props) { super(props); + this.state = { activeSection: 'comments', wordlist: [], changed: false }; - this.saveSettings = this.saveSettings.bind(this); - this.onChangeWordlist = this.onChangeWordlist.bind(this); - this.onSettingUpdate = this.onSettingUpdate.bind(this); } - componentWillMount () { + componentWillMount = () => { this.props.dispatch(fetchSettings()); } - componentWillUpdate (newProps) { + componentWillUpdate = (newProps) => { if ((!this.props.settings || !this.props.settings.wordlist) && newProps.settings.wordlist @@ -41,16 +39,16 @@ class Configure extends React.Component { } } - saveSettings () { + saveSettings = () => { this.props.dispatch(saveSettingsToServer()); this.setState({changed: false}); } - changeSection (activeSection) { + changeSection = (activeSection) => () => { this.setState({activeSection}); } - onChangeWordlist (event) { + onChangeWordlist = (event) => { event.preventDefault(); const newlist = event.target.value; this.setState({wordlist: newlist.toLowerCase(), changed: true}); @@ -61,12 +59,12 @@ class Configure extends React.Component { })); } - onSettingUpdate (setting) { + onSettingUpdate = (setting) => { this.setState({changed: true}); this.props.dispatch(updateSettings(setting)); } - getSection (section) { + getSection = (section) => { switch(section){ case 'comments': return { switch(section) { case 'comments': return lang.t('configure.comment-settings'); @@ -106,17 +104,17 @@ class Configure extends React.Component { {lang.t('configure.comment-settings')} {lang.t('configure.embed-comment-stream')} {lang.t('configure.wordlist')} diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index e5670d169..3774ed5f9 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -81,9 +81,11 @@ class ModerationQueue extends React.Component { singleView={singleView} commentIds={ comments.get('ids') - .filter(id => !comments.get('byId') + .filter(id => + comments + .get('byId') .get(id) - .get('status')) + .get('status') === 'premod') } comments={comments.get('byId')} users={users.get('byId')} diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 3452e1c2f..23081d32d 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -44,7 +44,9 @@ "copy-and-paste": "Copy and paste code below into your CMS to embed your comment box in your articles", "moderate": "Moderate", "configure": "Configure", - "community": "Community" + "community": "Community", + "closed-comments-desc": "Write a message for closed threads", + "closed-comments-label": "Write a message..." } }, "es": { @@ -81,7 +83,9 @@ "copy-and-paste": "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus articulos", "moderate": "Moderar", "configure": "Configurar", - "community": "Comunidad" + "community": "Comunidad", + "closed-comments-desc": "Escribe un mensaje para cuando los comentarios se encuentran cerrados", + "closed-comments-label": "Escribe un mensaje..." } } } diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index de4abcce8..f90b96201 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -97,7 +97,7 @@ class CommentStream extends Component { const rootItem = this.props.items.assets && this.props.items.assets[rootItemId]; const {actions, users, comments} = this.props.items; const {loggedIn, user, showSignInDialog, signInOffset} = this.props.auth; - const {status} = this.props.config; + const {status, closedMessage} = this.props.config; const {activeTab} = this.state; const expandForLogin = showSignInDialog ? { minHeight: document.body.scrollHeight + 150 @@ -133,7 +133,7 @@ class CommentStream extends Component { author={user} /> - :

Comments are closed for this thread.

+ :

{closedMessage}

} {!loggedIn && } { diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/config.js index 6dc880434..e004fa1eb 100644 --- a/client/coral-framework/actions/config.js +++ b/client/coral-framework/actions/config.js @@ -5,6 +5,7 @@ import coralApi from '../helpers/response'; * Action name constants */ +export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; export const ADD_ITEM = 'ADD_ITEM'; diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js index 9620f5b97..7fbccaa49 100644 --- a/client/coral-framework/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -5,7 +5,8 @@ import * as actions from '../actions/config'; const initialState = Map({ features: Map({}), - status: 'open' + status: 'open', + closedMessage: '' }); export default (state = initialState, action) => { diff --git a/docker-compose.yml b/docker-compose.yml index d7a180277..16df60095 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: always ports: - "5000:5000" + - "2525:2525" environment: - "TALK_PORT=5000" - "TALK_MONGO_URL=mongodb://mongo" diff --git a/middleware/payload-filter.js b/middleware/payload-filter.js new file mode 100644 index 000000000..0cafd91fe --- /dev/null +++ b/middleware/payload-filter.js @@ -0,0 +1,73 @@ +// 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/asset.js b/models/asset.js index a0619576f..77b8ebff3 100644 --- a/models/asset.js +++ b/models/asset.js @@ -29,6 +29,14 @@ const AssetSchema = new Schema({ type: Schema.Types.Mixed, default: null }, + closedAt: { + type: Date, + default: null + }, + closedMessage: { + type: String, + default: null + }, title: String, description: String, image: String, @@ -36,11 +44,7 @@ const AssetSchema = new Schema({ subsection: String, author: String, publication_date: Date, - modified_date: Date, - status: { - type: String, - default: 'open' - } + modified_date: Date }, { versionKey: false, timestamps: { @@ -60,6 +64,13 @@ AssetSchema.index({ background: true }); +/** + * Returns true if the asset is closed, false else. + */ +AssetSchema.virtual('isClosed').get(function() { + return this.closedAt && this.closedAt.getTime() <= new Date().getTime(); +}); + /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). @@ -85,7 +96,7 @@ AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ // If the asset exists and has settings then return the merged object. if (asset && asset.settings) { - return Object.assign({}, settings, asset.settings); + settings.merge(asset.settings); } return settings; diff --git a/models/comment.js b/models/comment.js index af9aa4cb1..53e8bbfb0 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,5 +1,6 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const _ = require('lodash'); const uuid = require('uuid'); const Action = require('./action'); @@ -45,7 +46,7 @@ const CommentSchema = new Schema({ }, asset_id: String, author_id: String, - status: [StatusSchema], + status_history: [StatusSchema], parent_id: String }, { timestamps: { @@ -59,22 +60,7 @@ const CommentSchema = new Schema({ * output. */ CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.hide = '_id status'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * 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) { @@ -86,14 +72,31 @@ CommentSchema.options.toJSON.transform = (doc, ret, options) => { 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(); +}); + /** * Sets up a virtual getter function on a comment such that when you try and * access the `comment.last_status` it returns the last status in the array - * of status's on the comment, or `null` if there was no status. + * of status's on the comment, or `null` if there was no status_history. */ -CommentSchema.virtual('last_status').get(function() { - if (this.status && this.status.length > 0) { - return this.status[this.status.length - 1].type; +CommentSchema.virtual('status').get(function() { + + // Here we are taking advantage of the fact that when documents are inserted + // for the new status on a comment that they are always appended to the end + // of the list in the order that they are inserted, hence, the last status + // is always the most recent. + if (this.status_history && this.status_history.length > 0) { + return this.status_history[this.status_history.length - 1].type; } return null; @@ -123,7 +126,7 @@ CommentSchema.statics.publicCreate = (comment) => { body, asset_id, parent_id, - status: status ? [{ + status_history: status ? [{ type: status, created_at: new Date() }] : [], @@ -157,7 +160,7 @@ CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ */ CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ asset_id, - 'status.type': 'accepted' + 'status_history.type': 'accepted' }); /** @@ -169,10 +172,10 @@ CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ asset_id, $or: [ { - 'status.type': 'accepted' + 'status_history.type': 'accepted' }, { - status: { + status_history: { $size: 0 } } @@ -202,7 +205,7 @@ CommentSchema.statics.findIdsByActionType = (action_type) => Action .then((actions) => actions.map(a => a.item_id)); /** - * Find comments by their status. + * Find comments by their status_history. * @param {String} status the status of the comment to search for * @return {Promise} */ @@ -210,9 +213,9 @@ CommentSchema.statics.findByStatus = (status = false) => { let q = {}; if (status) { - q['status.type'] = status; + q['status_history.type'] = status; } else { - q.status = {$size: 0}; + q.status_history = {$size: 0}; } return Comment.find(q); @@ -269,7 +272,7 @@ CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { */ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { $push: { - status: { + status_history: { type: status, created_at: new Date(), assigned_by diff --git a/models/setting.js b/models/setting.js index 22b2b103f..c7589ca56 100644 --- a/models/setting.js +++ b/models/setting.js @@ -28,6 +28,16 @@ const SettingSchema = new Schema({ type: String, default: '' }, + closedTimeout: { + type: Number, + + // Two weeks default expiry. + default: 60 * 60 * 24 * 7 * 2 + }, + closedMessage: { + type: String, + default: '' + }, wordlist: [String] }, { timestamps: { @@ -36,6 +46,42 @@ 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. + */ +SettingSchema.method('merge', function(src) { + SettingSchema.eachPath((path) => { + + // Exclude internal fields... + if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) { + return; + } + + // If the source object contains the path, shallow copy it. + if (path in src) { + this[path] = src[path]; + } + }); +}); + +/** + * 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']); + } + + return this.toJSON(); +}); + /** * The Mongo Mongoose object. */ @@ -62,7 +108,9 @@ const EXPIRY_TIME = 60 * 2; * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector)); +SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => { + return Setting.findOne(selector); +}).then((setting) => new Setting(setting)); /** * This will update the settings object with whatever you pass in @@ -83,14 +131,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { .then(() => settings); }); -/** - * Filters the document to ensure that the resulting document is indeed ready - * for non authenticated users. - * @param {Object} settings the source settings object - * @return {Object} the filtered settings object - */ -SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); - /** * This is run once when the app starts to ensure settings are populated. * @return {Promise} null initialize the global settings object diff --git a/models/user.js b/models/user.js index 336486a54..10a3a5e05 100644 --- a/models/user.js +++ b/models/user.js @@ -1,5 +1,6 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); +const _ = require('lodash'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); @@ -105,7 +106,8 @@ UserSchema.index({ * output. */ UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = '_id password profiles roles disabled'; +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) => { @@ -117,20 +119,16 @@ UserSchema.options.toJSON.transform = (doc, ret, options) => { }; /** - * toObject overrides to remove the password field from the toObject - * output. + * Filters the object for the given user only allowing those with the allowed + * roles/permissions to access particular parameters. */ -UserSchema.options.toObject = {}; -UserSchema.options.toObject.hide = 'password'; -UserSchema.options.toObject.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); +UserSchema.method('filterForUser', function(user = false) { + if (!user || !user.roles.includes('admin')) { + return _.pick(this.toJSON(), ['id', 'displayName', 'settings', 'created_at', 'updated_at']); } - return ret; -}; + return this.toJSON(); +}); // Create the User model. const UserModel = mongoose.model('User', UserSchema); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 32b2cb4cc..353d1562b 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -80,7 +80,20 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { status = Promise.resolve('rejected'); } else { status = Asset - .rectifySettings(Asset.findById(asset_id)) + .rectifySettings(Asset.findById(asset_id).then((asset) => { + if (!asset) { + return Promise.reject(new Error('asset referenced is not found')); + } + + // 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 Error(`asset has commenting closed because: ${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. @@ -96,7 +109,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { })) .then((comment) => { // The comment was created! Send back the created comment. - res.status(201).send(comment); + res.status(201).json(comment); }) .catch((err) => { next(err); diff --git a/routes/api/index.js b/routes/api/index.js index 9b4f0432b..2c36e86bb 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,8 +1,12 @@ 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('/asset', authorization.needed('admin'), require('./asset')); router.use('/settings', authorization.needed('admin'), require('./settings')); router.use('/queue', authorization.needed('admin'), require('./queue')); diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 26947be09..8d70adbb4 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -1,21 +1,32 @@ const express = require('express'); const _ = require('lodash'); const scraper = require('../../../services/scraper'); +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 ErrInvalidAssetURL = new Error('asset_url is invalid'); +ErrInvalidAssetURL.status = 400; 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(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(decodeURIComponent(req.query.asset_url)) + Asset.findOrCreateByUrl(asset_url) // Add the found asset to the scraper if it's not already scraped. .then((asset) => { @@ -34,7 +45,7 @@ router.get('/', (req, res, next) => { // 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 = Object.assign({}, settings, asset.settings); + settings.merge(asset.settings); } // Fetch the appropriate comments stream. @@ -98,7 +109,7 @@ router.get('/', (req, res, next) => { comments, users, actions, - settings: Setting.public(settings) + settings }); }) .catch(error => { diff --git a/routes/api/user/index.js b/routes/api/user/index.js index 1765809ad..bd83563dc 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -26,26 +26,15 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { .limit(limit), User.count() ]) - .then(([data, count]) => { - const users = data.map((user) => { - const {id, displayName, created_at} = user; - return { - id, - displayName, - created_at, - profiles: user.toObject().profiles, - roles: user.toObject().roles - }; - }); + .then(([result, count]) => { res.json({ - result: users, + result, limit: Number(limit), count, page: Number(page), - totalPages: Math.ceil(count / limit) + totalPages: Math.ceil(count / (limit === 0 ? 1 : limit)) }); - }) .catch(next); }); @@ -53,8 +42,8 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { User .addRoleToUser(req.params.user_id, req.body.role) - .then(role => { - res.send(role); + .then(() => { + res.status(204).end(); }) .catch(next); }); @@ -65,13 +54,17 @@ router.post('/', (req, res, next) => { User .createLocalUser(email, password, displayName) .then(user => { - res.status(201).send(user); + + res.status(201).json(user); }) .catch(err => { next(err); }); }); +const ErrPasswordTooShort = new Error('password must be at least 8 characters'); +ErrPasswordTooShort.status = 400; + /** * expects 2 fields in the body of the request * 1) the token that was in the url of the email link {String} @@ -81,7 +74,7 @@ router.post('/update-password', (req, res, next) => { const {token, password} = req.body; if (!password || password.length < 8) { - return res.status(400).send('Password must be at least 8 characters'); + return next(ErrPasswordTooShort); } User.verifyPasswordResetToken(token) @@ -93,7 +86,8 @@ router.post('/update-password', (req, res, next) => { }) .catch(error => { console.error(error); - res.status(401).send('Not Authorized'); + + next(authorization.ErrNotAuthorized); }); }); @@ -133,10 +127,8 @@ router.post('/request-password-reset', (req, res, next) => { // if we fail on missing emails, it would reveal if people are registered or not. res.status(204).end(); }) - .catch(error => { - const errorMsg = typeof error === 'string' ? error : error.message; - - res.status(500).json({error: errorMsg}); + .catch((err) => { + next(err); }); }); @@ -150,10 +142,11 @@ router.put('/:user_id/bio', (req, res, next) => { User .addBio(user_id, bio) - .then(user => res.status(200).send(user)) - .catch(error => { - const errorMsg = typeof error === 'string' ? error : error.message; - res.status(500).json({error: errorMsg}); + .then(user => { + res.json(user); + }) + .catch((err) => { + next(err); }); }); diff --git a/services/mailer.js b/services/mailer.js index b7d621954..fe07f063b 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -3,7 +3,7 @@ const nodemailer = require('nodemailer'); const smtpRequiredProps = [ 'TALK_SMTP_USERNAME', 'TALK_SMTP_PASSWORD', - 'TALK_SMTP_PROVIDER' + 'TALK_SMTP_HOST' ]; smtpRequiredProps.forEach(prop => { @@ -13,9 +13,7 @@ smtpRequiredProps.forEach(prop => { }); const options = { - // list of providers here: - // https://github.com/nodemailer/nodemailer-wellknown#supported-services - service: process.env.TALK_SMTP_PROVIDER, + host: process.env.TALK_SMTP_HOST, auth: { user: process.env.TALK_SMTP_USERNAME, pass: process.env.TALK_SMTP_PASSWORD @@ -24,10 +22,8 @@ const options = { if (process.env.TALK_SMTP_PORT) { options.port = process.env.TALK_SMTP_PORT; -} - -if (process.env.TALK_SMTP_HOST) { - options.host = process.env.TALK_SMTP_HOST; +} else { + options.port = 25; } const defaultTransporter = nodemailer.createTransport(options); diff --git a/services/scraper.js b/services/scraper.js index 922ef77bc..10e0e4aa8 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -23,7 +23,7 @@ const scraper = { title: `Scrape for asset ${asset.id}`, asset_id: asset.id }) - .attempts(10) + .attempts(3) .delay(1000) .backoff({type: 'exponential'}) .save((err) => { @@ -106,7 +106,7 @@ const scraper = { // Handle errors that occur. .catch((err) => { - console.error(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err); + debug(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err); done(err); }); diff --git a/tests/models/comment.js b/tests/models/comment.js index a9c2f4787..3db7d67a2 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -11,14 +11,14 @@ describe('models.Comment', () => { const comments = [{ body: 'comment 10', asset_id: '123', - status: [], + status_history: [], parent_id: '', author_id: '123', id: '1' }, { body: 'comment 20', asset_id: '123', - status: [{ + status_history: [{ type: 'accepted' }], parent_id: '', @@ -27,14 +27,14 @@ describe('models.Comment', () => { }, { body: 'comment 30', asset_id: '456', - status: [], + status_history: [], parent_id: '', author_id: '456', id: '3' }, { body: 'comment 40', asset_id: '123', - status: [{ + status_history: [{ type: 'rejected' }], parent_id: '', @@ -43,7 +43,7 @@ describe('models.Comment', () => { }, { body: 'comment 50', asset_id: '1234', - status: [{ + status_history: [{ type: 'premod' }], parent_id: '', @@ -52,7 +52,7 @@ describe('models.Comment', () => { }, { body: 'comment 60', asset_id: '1234', - status: [{ + status_history: [{ type: 'premod' }], parent_id: '', @@ -99,8 +99,7 @@ describe('models.Comment', () => { expect(c).to.not.be.null; expect(c.id).to.not.be.null; expect(c.id).to.be.uuid; - expect(c.status).to.have.length(1); - expect(c.status[0]).to.have.property('type', 'accepted'); + expect(c.status).to.be.equal('accepted'); }); }); @@ -116,17 +115,15 @@ describe('models.Comment', () => { }]).then(([c1, c2, c3]) => { expect(c1).to.not.be.null; expect(c1.id).to.be.uuid; - expect(c1.status).to.have.length(1); - expect(c1.status[0]).to.have.property('type', 'accepted'); + expect(c1.status).to.be.equal('accepted'); expect(c2).to.not.be.null; expect(c2.id).to.be.uuid; - expect(c2.status).to.have.length(0); + expect(c2.status).to.be.null; expect(c3).to.not.be.null; expect(c3.id).to.be.uuid; - expect(c3.status).to.have.length(1); - expect(c3.status[0]).to.have.property('type', 'rejected'); + expect(c3.status).to.be.equal('rejected'); }); }); @@ -220,17 +217,16 @@ describe('models.Comment', () => { return Comment.findById(comment_id) .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(0); + 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.have.length(1); - expect(c.status[0]).to.have.property('type', 'rejected'); - expect(c.status[0]).to.have.property('assigned_by', '123'); + 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'); }); }); @@ -238,13 +234,13 @@ describe('models.Comment', () => { return Comment.pushStatus(comments[1].id, 'rejected', '123') .then(() => Comment.findById(comments[1].id)) .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(2); - expect(c.status[0]).to.have.property('type', 'accepted'); - expect(c.status[0]).to.have.property('assigned_by', null); + expect(c).to.have.property('status_history'); + 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[1]).to.have.property('type', 'rejected'); - expect(c.status[1]).to.have.property('assigned_by', '123'); + expect(c.status_history[1]).to.have.property('type', 'rejected'); + expect(c.status_history[1]).to.have.property('assigned_by', '123'); }); }); diff --git a/tests/models/setting.js b/tests/models/setting.js index 3e77297fc..8fd23d6a0 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -39,4 +39,18 @@ describe('models.Setting', () => { }); }); }); + + describe('#merge', () => { + it('should merge a settings object and its overrides', () => { + return Setting + .retrieve() + .then((settings) => { + let ovrSett = {moderation: 'post'}; + + settings.merge(ovrSett); + + expect(settings).to.have.property('moderation', 'post'); + }); + }); + }); }); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bf7a06ff6..37713e899 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -19,6 +19,12 @@ const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { + // Ensure that the settings are always available. + beforeEach(() => Promise.all([ + wordlist.insert(['bad words']), + Setting.init(settings) + ])); + describe('#get', () => { const comments = [{ body: 'comment 10', @@ -32,13 +38,13 @@ describe('/api/v1/comments', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }, { body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; @@ -75,11 +81,7 @@ describe('/api/v1/comments', () => { return Action.create(actions); }), - User.createLocalUsers(users), - wordlist.insert([ - 'bad words' - ]), - Setting.init(settings) + User.createLocalUsers(users) ]); }); @@ -142,11 +144,19 @@ describe('/api/v1/comments', () => { describe('#post', () => { + let asset_id; + + beforeEach(() => Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => { + + // Update the asset id. + asset_id = asset.id; + })); + 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': '1', 'parent_id': ''}) + .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'); @@ -157,12 +167,11 @@ describe('/api/v1/comments', () => { return chai.request(app) .post('/api/v1/comments') .set(passport.inject({roles: []})) - .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .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').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'rejected'); + expect(res.body).to.have.property('status', 'rejected'); }); }); @@ -184,10 +193,46 @@ describe('/api/v1/comments', () => { 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').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'premod'); + expect(res.body).to.have.property('status', 'premod'); }); }); + + 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.message).to.contain('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); + }); + }); }); }); @@ -301,20 +346,20 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: [] + status_history: [] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index d64423013..ce55836ab 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -21,7 +21,7 @@ describe('/api/v1/queue', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: [{ + status_history: [{ type: 'rejected' }] }, { @@ -29,14 +29,14 @@ describe('/api/v1/queue', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'premod' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index a128938ba..67088e37e 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -25,7 +25,7 @@ describe('/api/v1/stream', () => { body: 'comment 10', author_id: '', parent_id: '', - status: [{ + status_history: [{ type: 'accepted' }] }, { @@ -33,21 +33,21 @@ describe('/api/v1/stream', () => { body: 'comment 20', author_id: '', parent_id: '', - status: [] + status_history: [] }, { id: 'uio', body: 'comment 30', asset_id: 'asset', author_id: '456', parent_id: '', - status: [{ + status_history: [{ type: 'accepted' }] }, { id: 'hij', body: 'comment 40', asset_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }]; @@ -116,6 +116,16 @@ describe('/api/v1/stream', () => { }); }); + 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') @@ -126,6 +136,7 @@ describe('/api/v1/stream', () => { expect(res.body.comments.length).to.equal(1); expect(res.body.users.length).to.equal(1); expect(res.body.settings).to.have.property('moderation', 'pre'); + expect(res.body.settings).to.not.have.property('wordlist'); }); }); });