diff --git a/graph/connectors.js b/graph/connectors.js index a27b9d38c..976f05b71 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -24,6 +24,7 @@ const Limit = require('../services/limit'); const Mailer = require('../services/mailer'); const Metadata = require('../services/metadata'); const Migration = require('../services/migration'); +const Moderation = require('../services/moderation'); const Mongoose = require('../services/mongoose'); const Passport = require('../services/passport'); const Plugins = require('../services/plugins'); @@ -62,6 +63,7 @@ const connectors = { Mailer, Metadata, Migration, + Moderation, Mongoose, Passport, Plugins, diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 6106f819c..7e0607287 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,13 +1,11 @@ const errors = require('../../errors'); const ActionModel = require('../../models/action'); -const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); const TagsService = require('../../services/tags'); const CommentsService = require('../../services/comments'); const KarmaService = require('../../services/karma'); const merge = require('lodash/merge'); -const linkify = require('linkify-it')().tlds(require('tlds')); -const Wordlist = require('../../services/wordlist'); + const { CREATE_COMMENT, SET_COMMENT_STATUS, @@ -15,10 +13,6 @@ const { EDIT_COMMENT, } = require('../../perms/constants'); const debug = require('debug')('talk:graph:mutators:comment'); -const { - DISABLE_AUTOFLAG_SUSPECT_WORDS, - IGNORE_FLAGS_AGAINST_STAFF, -} = require('../../config'); const resolveTagsForComment = async ( { user, loaders: { Tags } }, @@ -188,279 +182,27 @@ const createComment = async ( return comment; }; -/** - * Filters the comment object and outputs wordlist results. - * @param {Object} context graphql context - * @param {String} body body of a comment - * @param {String} [asset_id] id of asset comment is posted on - * @return {Object} resolves to the wordlist results - */ -const filterNewComment = async (context, { body, asset_id }) => { - // Load the settings. - const [settings, asset] = await Promise.all([ - context.loaders.Settings.load(), - context.loaders.Assets.getByID.load(asset_id), - ]); - - // Create a new instance of the Wordlist. - const wl = new Wordlist(); - - // Load the wordlist. - wl.upsert(settings.wordlist); - - // Load the wordlist and filter the comment content. - return [ - // Scan the word. - wl.scan('body', body), - - // Return the asset's settings. - await AssetsService.rectifySettings(asset, settings), - ]; -}; - -/** - * moderationPhases is an array of phases carried out in order until a status is - * returned. - */ -const moderationPhases = [ - // This phase checks to see if the comment is long enough. - (context, comment) => { - // Check to see if the body is too short, if it is, then complain about it! - if (comment.body.length < 2) { - throw errors.ErrCommentTooShort; - } - }, - - // This phase checks to see if the asset being processed is closed or not. - (context, comment, { asset }) => { - // Check to see if the asset has closed commenting... - if (asset.isClosed) { - throw new errors.ErrAssetCommentingClosed(asset.closedMessage); - } - }, - - // This phase checks the comment against the wordlist. - (context, comment, { wordlist }) => { - // Decide the status based on whether or not the current asset/settings - // has pre-mod enabled or not. If the comment was rejected based on the - // wordlist, then reject it, otherwise if the moderation setting is - // premod, set it to `premod`. - if (wordlist.banned) { - // Add the flag related to Trust to the comment. - return { - status: 'REJECTED', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'BANNED_WORD', - metadata: {}, - }, - ], - }; - } - - // If the comment has a suspect word or a link, we need to add a - // flag to it to indicate that it needs to be looked at. - // Otherwise just return the new comment. - - // If the wordlist has matched the suspect word filter and we haven't disabled - // auto-flagging suspect words, then we should flag the comment! - if (wordlist.suspect && !DISABLE_AUTOFLAG_SUSPECT_WORDS) { - // TODO: this is kind of fragile, we should refactor this to resolve - // all these const's that we're using like 'COMMENTS', 'FLAG' to be - // defined in a checkable schema. - return { - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'SUSPECT_WORD', - metadata: {}, - }, - ], - }; - } - }, - - // This phase checks to see if the comment's length exceeds maximum. - (context, comment, { assetSettings: { charCountEnable, charCount } }) => { - // Reject if the comment is too long - if (charCountEnable && comment.body.length > charCount) { - // Add the flag related to Trust to the comment. - return { - status: 'REJECTED', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'BODY_COUNT', - metadata: { - count: comment.body.length, - }, - }, - ], - }; - } - }, - - // If a given user is a staff member, always approve their comment. - context => { - if (IGNORE_FLAGS_AGAINST_STAFF && context.user && context.user.isStaff()) { - return { - status: 'ACCEPTED', - }; - } - }, - - // This phase checks the comment if it has any links in it if the check is - // enabled. - (context, comment, { assetSettings: { premodLinksEnable } }) => { - if (premodLinksEnable && linkify.test(comment.body)) { - // Add the flag related to Trust to the comment. - return { - status: 'SYSTEM_WITHHELD', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'LINKS', - metadata: { - links: comment.body, - }, - }, - ], - }; - } - }, - - // This phase checks to see if the user making the comment is allowed to do so - // considering their reliability (Trust) status. - context => { - if (context.user && context.user.metadata) { - // If the user is not a reliable commenter (passed the unreliability - // threshold by having too many rejected comments) then we can change the - // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's - // comments away from the public eye until a moderator can manage them. This of - // course can only be applied if the comment's current status is `NONE`, - // we don't want to interfere if the comment was rejected. - if ( - KarmaService.isReliable('comment', context.user.metadata.trust) === - false - ) { - // Add the flag related to Trust to the comment. - return { - status: 'SYSTEM_WITHHELD', - actions: [ - { - action_type: 'FLAG', - user_id: null, - group_id: 'TRUST', - metadata: { - trust: context.user.metadata.trust, - }, - }, - ], - }; - } - } - }, - - // This phase checks to see if the comment was already prescribed a status. - (context, comment) => { - // If the status was already defined, don't redefine it. It's only defined - // when specific external conditions exist, we don't want to override that. - if (comment.status && comment.status.length > 0) { - return { - status: comment.status, - }; - } - }, - - // This phase checks to see if the settings have premod enabled, if they do, - // the comment is premod, otherwise, it's just none. - (context, comment, { assetSettings: { moderation } }) => { - // If the settings say that we're in premod mode, then the comment is in - // premod status. - if (moderation === 'PRE') { - return { - status: 'PREMOD', - }; - } - - return { - status: 'NONE', - }; - }, -]; - -/** - * This resolves a given comment's status and actions. - * @param {Object} context graphql context - * @param {String} body body of the comment - * @param {String} [asset_id] asset for the comment - * @param {Object} [wordlist={}] the results of the wordlist scan - * @return {Promise} resolves to the comment's status and actions - */ -const resolveCommentModeration = async (context, comment) => { - // First we filter the comment contents to ensure that we note any validation - // issues. - let [wordlist, settings] = await filterNewComment(context, comment); - - // Get the asset from the loader. - const asset = await context.loaders.Assets.getByID.load(comment.asset_id); - if (!asset) { - // And leave now if this asset wasn't found. - throw errors.ErrNotFound; - } - - // Combine the asset and the settings to get the asset settings. - const assetSettings = await AssetsService.rectifySettings(asset, settings); - - let actions = comment.actions || []; - - // Loop over all the moderation phases and see if we've resolved the status. - for (const phase of moderationPhases) { - const result = await phase(context, comment, { - asset, - assetSettings, - settings, - wordlist, - }); - - if (result) { - if (result.actions) { - actions.push(...result.actions); - } - - // If this result contained a status, then we've finished resolving - // phases! - if (result.status) { - return { status: result.status, actions }; - } - } - } -}; - /** * createPublicComment is designed to create a comment from a public source. It * validates the comment, and performs some automated moderator actions based on * the settings. - * @param {Object} context the graphql context + * @param {Object} ctx the graphql context * @param {Object} commentInput the new comment to be created * @return {Promise} resolves to a new comment */ -const createPublicComment = async (context, comment) => { +const createPublicComment = async (ctx, comment) => { + const { connectors: { services: { Moderation } } } = ctx; + // We then take the wordlist and the comment into consideration when // considering what status to assign the new comment, and resolve the new // status to set the comment to. - let { actions, status } = await resolveCommentModeration(context, comment); + let { actions, status } = await Moderation.process(ctx, comment); // Assign status to comment. comment.status = status; // Then we actually create the comment with the new status. - const result = await createComment(context, comment); + const result = await createComment(ctx, comment); // Create all the actions that were determined during the moderation check // phase. @@ -522,18 +264,20 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => { * @param {Object} edit describes how to edit the comment * @param {String} edit.body the new Comment body */ -const edit = async (context, { id, asset_id, edit: { body } }) => { +const edit = async (ctx, { id, asset_id, edit: { body } }) => { + const { connectors: { services: { Moderation } } } = ctx; + // Build up the new comment we're setting. We need to check this with // moderation now. let comment = { id, asset_id, body }; // Determine the new status of the comment. - const { actions, status } = await resolveCommentModeration(context, comment); + const { actions, status } = await Moderation.process(ctx, comment); // Execute the edit. comment = await CommentsService.edit({ id, - author_id: context.user.id, + author_id: ctx.user.id, body, status, }); @@ -543,7 +287,7 @@ const edit = async (context, { id, asset_id, edit: { body } }) => { await createActions(comment.id, actions); // Publish the edited comment via the subscription. - context.pubsub.publish('commentEdited', comment); + ctx.pubsub.publish('commentEdited', comment); return comment; }; diff --git a/models/asset.js b/models/asset.js index 68ca08bbf..6fdea3b78 100644 --- a/models/asset.js +++ b/models/asset.js @@ -2,6 +2,7 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; const uuid = require('uuid'); const TagLinkSchema = require('./schema/tag_link'); +const get = require('lodash/get'); const AssetSchema = new Schema( { @@ -45,8 +46,8 @@ const AssetSchema = new Schema( // the base settings from the base Settings object. This is to be accessed // always after running `rectifySettings` against it. settings: { - type: Schema.Types.Mixed, default: {}, + type: Object, }, // Tags are added by the self or by administrators. @@ -85,9 +86,12 @@ AssetSchema.index( * Returns true if the asset is closed, false else. */ AssetSchema.virtual('isClosed').get(function() { - return Boolean( - this.closedAt && this.closedAt.getTime() <= new Date().getTime() - ); + const closedAt = get(this, 'closedAt', null); + if (closedAt === null) { + return false; + } + + return closedAt.getTime() <= new Date().getTime(); }); const Asset = mongoose.model('Asset', AssetSchema); diff --git a/services/moderation/index.js b/services/moderation/index.js new file mode 100644 index 000000000..4d87e190f --- /dev/null +++ b/services/moderation/index.js @@ -0,0 +1,130 @@ +const errors = require('../../errors'); +const get = require('lodash/get'); + +// Load in the phases to use. +const { + wordlist, + commentLength, + assetClosed, + karma, + staff, + links, + premod, +} = require('./phases'); + +// This phase checks to see if the comment was already prescribed a status. This +// essentially provides a hook for plugins to inject their own comments. +const applyPreexisting = (ctx, comment) => { + const status = get(comment, 'status'); + + // If the status was already defined, don't redefine it. It's only defined + // when specific external conditions exist, we don't want to override that. + if (status) { + return { + status, + }; + } +}; + +// Applies the defaulted status. +const applyStatus = status => () => ({ status }); + +/** + * phases is an array of moderation phases carried out in order until a status is + * returned. + */ +const phases = [ + commentLength, + assetClosed, + wordlist, + staff, + links, + karma, + applyPreexisting, + premod, + applyStatus('NONE'), +]; + +/** + * compose will create a moderation pipeline for which is executable with the + * passed actions. + * + * @param {Array} phases the set of moderation phases to pass the comment and + * their options through. + */ +const compose = phases => async (ctx, comment, options) => { + const actions = get(comment, 'actions', []); + + // Loop over all the moderation phases and see if we've resolved the status. + for (const phase of phases) { + const result = await phase(ctx, comment, options); + if (result) { + if (result.actions) { + actions.push(...result.actions); + } + + // If this result contained a status, then we've finished resolving + // phases! + if (result.status) { + return { status: result.status, actions }; + } + } + } +}; + +/** + * fetchOptions will generate the options used by the moderation service to + * determine the end status. + * + * @param {Object} ctx graph context + * @param {Object} comment comment object to use + */ +const fetchOptions = async (ctx, comment) => { + const { + connectors: { services: { Assets: AssetsService } }, + loaders: { Settings, Assets }, + } = ctx; + + // Load the settings. + const settings = await Settings.load(); + + // Pull the asset id out of the comment. + const assetID = get(comment, 'asset_id', null); + if (assetID === null) { + // And leave now if this asset wasn't found. + throw errors.ErrNotFound; + } + + // Load the asset. + const asset = await Assets.getByID.load(assetID); + if (!asset) { + // And leave now if this asset wasn't found. + throw errors.ErrNotFound; + } + + // Combine the asset and the settings to get the asset settings. + asset.settings = await AssetsService.rectifySettings(asset, settings); + + // Create the options that will be consumed by the phases. + return { + asset, + settings, + }; +}; + +/** + * process the comment and return moderation details. + * + * @param {Object} ctx graphql context + * @param {Object} comment comment to perform the moderation phases on + */ +const process = async (ctx, comment) => { + // Fetch the options to use for the moderation phases. + const options = await fetchOptions(ctx, comment); + + // Compose a moderation pipeline from the moderation phases and execute it on + // the comment. + return compose(phases)(ctx, comment, options); +}; + +module.exports.process = process; diff --git a/services/moderation/phases/assetClosed.js b/services/moderation/phases/assetClosed.js new file mode 100644 index 000000000..075028764 --- /dev/null +++ b/services/moderation/phases/assetClosed.js @@ -0,0 +1,9 @@ +const { ErrAssetCommentingClosed } = require('../../../errors'); + +// This phase checks to see if the asset being processed is closed or not. +module.exports = (ctx, comment, { asset }) => { + // Check to see if the asset has closed commenting... + if (asset.isClosed) { + throw new ErrAssetCommentingClosed(asset.closedMessage); + } +}; diff --git a/services/moderation/phases/commentLength.js b/services/moderation/phases/commentLength.js new file mode 100644 index 000000000..925115326 --- /dev/null +++ b/services/moderation/phases/commentLength.js @@ -0,0 +1,31 @@ +const { ErrCommentTooShort } = require('../../../errors'); + +// This phase checks to see if the comment is long enough. +module.exports = ( + ctx, + comment, + { asset: { settings: { charCountEnable, charCount } } } +) => { + // Check to see if the body is too short, if it is, then complain about it! + if (comment.body.length < 2) { + throw ErrCommentTooShort; + } + + // Reject if the comment is too long + if (charCountEnable && comment.body.length > charCount) { + // Add the flag related to Trust to the comment. + return { + status: 'REJECTED', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'BODY_COUNT', + metadata: { + count: comment.body.length, + }, + }, + ], + }; + } +}; diff --git a/services/moderation/phases/index.js b/services/moderation/phases/index.js new file mode 100644 index 000000000..a1c2e7bf5 --- /dev/null +++ b/services/moderation/phases/index.js @@ -0,0 +1,7 @@ +module.exports.wordlist = require('./wordlist'); +module.exports.commentLength = require('./commentLength'); +module.exports.assetClosed = require('./assetClosed'); +module.exports.karma = require('./karma'); +module.exports.staff = require('./staff'); +module.exports.links = require('./links'); +module.exports.premod = require('./premod'); diff --git a/services/moderation/phases/karma.js b/services/moderation/phases/karma.js new file mode 100644 index 000000000..ad55b37a7 --- /dev/null +++ b/services/moderation/phases/karma.js @@ -0,0 +1,33 @@ +const get = require('lodash/get'); + +// This phase checks to see if the user making the comment is allowed to do so +// considering their reliability (Trust) status. +module.exports = ctx => { + const { connectors: { services: { Karma } } } = ctx; + const trust = get(ctx, 'user.metadata.trust', null); + + if (trust !== null) { + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's + // comments away from the public eye until a moderator can manage them. This of + // course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if (Karma.isReliable('comment', trust) === false) { + // Add the flag related to Trust to the comment. + return { + status: 'SYSTEM_WITHHELD', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'TRUST', + metadata: { + trust, + }, + }, + ], + }; + } + } +}; diff --git a/services/moderation/phases/links.js b/services/moderation/phases/links.js new file mode 100644 index 000000000..0aed4ecd9 --- /dev/null +++ b/services/moderation/phases/links.js @@ -0,0 +1,26 @@ +const linkify = require('linkify-it')().tlds(require('tlds')); + +// This phase checks the comment if it has any links in it if the check is +// enabled. +module.exports = ( + ctx, + comment, + { asset: { settings: { premodLinksEnable } } } +) => { + if (premodLinksEnable && linkify.test(comment.body)) { + // Add the flag related to Trust to the comment. + return { + status: 'SYSTEM_WITHHELD', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'LINKS', + metadata: { + links: comment.body, + }, + }, + ], + }; + } +}; diff --git a/services/moderation/phases/premod.js b/services/moderation/phases/premod.js new file mode 100644 index 000000000..cbd3627b1 --- /dev/null +++ b/services/moderation/phases/premod.js @@ -0,0 +1,15 @@ +// This phase checks to see if the settings have premod enabled, if they do, +// the comment is premod, otherwise, it's just none. +module.exports = (ctx, comment, { asset: { settings: { moderation } } }) => { + // If the settings say that we're in premod mode, then the comment is in + // premod status. + if (moderation === 'PRE') { + return { + status: 'PREMOD', + }; + } + + return { + status: 'NONE', + }; +}; diff --git a/services/moderation/phases/staff.js b/services/moderation/phases/staff.js new file mode 100644 index 000000000..4b6b74946 --- /dev/null +++ b/services/moderation/phases/staff.js @@ -0,0 +1,10 @@ +const { IGNORE_FLAGS_AGAINST_STAFF } = require('../../../config'); + +// If a given user is a staff member, always approve their comment. +module.exports = ctx => { + if (IGNORE_FLAGS_AGAINST_STAFF && ctx.user && ctx.user.isStaff()) { + return { + status: 'ACCEPTED', + }; + } +}; diff --git a/services/moderation/phases/wordlist.js b/services/moderation/phases/wordlist.js new file mode 100644 index 000000000..b71e0361e --- /dev/null +++ b/services/moderation/phases/wordlist.js @@ -0,0 +1,56 @@ +const { DISABLE_AUTOFLAG_SUSPECT_WORDS } = require('../../../config'); + +// This phase checks the comment against the wordlist. +module.exports = async (ctx, comment, { settings }) => { + const { connectors: { services: { Wordlist } } } = ctx; + + // Create a new instance of the Wordlist. + const wl = new Wordlist(); + + // Load the wordlist. + wl.upsert(settings.wordlist); + + // Scan the comment body for wordlist violations. + const { banned = null, suspect = null } = wl.scan('body', comment.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`. + if (banned) { + // Add the flag related to Trust to the comment. + return { + status: 'REJECTED', + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'BANNED_WORD', + metadata: {}, + }, + ], + }; + } + + // If the comment has a suspect word or a link, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + + // If the wordlist has matched the suspect word filter and we haven't disabled + // auto-flagging suspect words, then we should flag the comment! + if (suspect && !DISABLE_AUTOFLAG_SUSPECT_WORDS) { + // TODO: this is kind of fragile, we should refactor this to resolve + // all these const's that we're using like 'COMMENTS', 'FLAG' to be + // defined in a checkable schema. + return { + actions: [ + { + action_type: 'FLAG', + user_id: null, + group_id: 'SUSPECT_WORD', + metadata: {}, + }, + ], + }; + } +}; diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index 8a5a98aa1..720297e12 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -49,6 +49,9 @@ describe('graph.mutations.createComment', () => { return graphql(schema, query, {}, context).then( ({ data, errors }) => { + if (errors) { + console.error(errors); + } expect(errors).to.be.undefined; if (error) { expect(data.createComment).to.have.property('comment').null; @@ -98,7 +101,9 @@ describe('graph.mutations.createComment', () => { async () => { const context = new Context({ user }); const { data, errors } = await graphql(schema, query, {}, context); - + if (errors) { + console.error(errors); + } expect(errors).to.be.undefined; if (error) { expect(data.createComment).to.have.property('comment').null;