diff --git a/graph/loaders/settings.js b/graph/loaders/settings.js index 83545821d..2d6189b7d 100644 --- a/graph/loaders/settings.js +++ b/graph/loaders/settings.js @@ -1,5 +1,5 @@ const SettingsService = require('../../services/settings'); -const util = require('./util'); +const {SingletonResolver} = require('./util'); /** * Creates a set of loaders based on a GraphQL context. @@ -7,5 +7,5 @@ const util = require('./util'); * @return {Object} object of loaders */ module.exports = () => ({ - Settings: new util.SingletonResolver(() => SettingsService.retrieve()) + Settings: new SingletonResolver(() => SettingsService.retrieve()) }); diff --git a/graph/mutators/index.js b/graph/mutators/index.js index e7e5df9e7..7f57a773f 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); +const Settings = require('./settings'); const Tag = require('./tag'); const Token = require('./token'); const User = require('./user'); @@ -14,6 +15,7 @@ let mutators = [ // Load in the core mutators. Comment, Action, + Settings, Tag, Token, User, diff --git a/graph/mutators/settings.js b/graph/mutators/settings.js new file mode 100644 index 000000000..f725d791d --- /dev/null +++ b/graph/mutators/settings.js @@ -0,0 +1,33 @@ +const errors = require('../../errors'); + +const { + UPDATE_SETTINGS, + UPDATE_WORDLIST, +} = require('../../perms/constants'); + +const SettingsService = require('../../services/settings'); + +const update = async (ctx, settings) => SettingsService.update(settings); + +const updateWordlist = async (ctx, wordlist) => SettingsService.updateWordlist(wordlist); + +module.exports = (ctx) => { + let mutators = { + Settings: { + update: () => Promise.reject(errors.ErrNotAuthorized), + updateWordlist: () => Promise.reject(errors.ErrNotAuthorized) + } + }; + + if (ctx.user) { + if (ctx.user.can(UPDATE_SETTINGS)) { + mutators.Settings.update = (id, settings) => update(ctx, id, settings); + } + + if (ctx.user.can(UPDATE_WORDLIST)) { + mutators.Settings.updateWordlist = (id, status) => updateWordlist(ctx, id, status); + } + } + + return mutators; +}; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 685c26a17..c11f657cc 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -50,6 +50,12 @@ const RootMutation = { removeTag(_, {tag}, {mutators: {Tag}}) { return wrapResponse(null)(Tag.remove(tag)); }, + updateSettings(_, {input: settings}, {mutators: {Settings}}) { + return wrapResponse(null)(Settings.update(settings)); + }, + updateWordlist(_, {input: wordlist}, {mutators: {Settings}}) { + return wrapResponse(null)(Settings.updateWordlist(wordlist)); + }, createToken(_, {input}, {mutators: {Token}}) { return wrapResponse('token')(Token.create(input)); }, diff --git a/graph/resolvers/settings.js b/graph/resolvers/settings.js index 72608a9c4..30e614c38 100644 --- a/graph/resolvers/settings.js +++ b/graph/resolvers/settings.js @@ -1,3 +1,21 @@ +const { + VIEW_PROTECTED_SETTINGS, +} = require('../../perms/constants'); + +const {decorateWithPermissionCheck} = require('./util'); + const Settings = {}; +// PROTECTED_SETTINGS are the settings keys that must be protected for only some +// eyes. +const PROTECTED_SETTINGS = { + 'premodLinksEnable': [VIEW_PROTECTED_SETTINGS], + 'autoCloseStream': [VIEW_PROTECTED_SETTINGS], + 'wordlist': [VIEW_PROTECTED_SETTINGS], + 'domains': [VIEW_PROTECTED_SETTINGS], +}; + +// decorate the fields on the settings resolver with a permission check. +decorateWithPermissionCheck(Settings, PROTECTED_SETTINGS); + module.exports = Settings; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index ef3c70865..cf3162679 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -13,6 +13,31 @@ const decorateWithTags = (typeResolver) => { }; }; -module.exports = { - decorateWithTags +/** + * decorateWithPermissionCheck will decorate the field resolver with + * permission checks. + * + * @param {Object} typeResolver the type resolver + * @param {Object} protect the object with field -> Array of permissions + */ +const decorateWithPermissionCheck = (typeResolver, protect) => { + for (const [field, permissions] of Object.entries(protect)) { + let fieldResolver = (obj) => obj[field]; + if (field in typeResolver) { + fieldResolver = typeResolver[field]; + } + + typeResolver[field] = (obj, args, ctx, info) => { + if (!ctx.user || !ctx.user.can(...permissions)) { + return null; + } + + return fieldResolver(obj, args, ctx, info); + }; + } +}; + +module.exports = { + decorateWithTags, + decorateWithPermissionCheck, }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 9d8e25f12..982ee04bd 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -572,27 +572,85 @@ enum MODERATION_MODE { POST } -# Site wide global settings. +# Wordlist describes all the available wordlists. +type Wordlist { + + # banned words will by default reject the comment if it is found. + banned: [String!]! + + # suspect words will simply flag the comment. + suspect: [String!]! +} + +# Domains describes all the available lists of domains. +type Domains { + + # whitelist is the list of domains that the embed is allowed to render on. + whitelist: [String!]! +} + +# Settings stores the global settings for a given installation. type Settings { - # Moderation mode for the site. + # moderation is the moderation mode for all Asset's on the site. moderation: MODERATION_MODE! # Enables a requirement for email confirmation before a user can login. requireEmailConfirmation: Boolean + # infoBoxEnable will enable the Info Box content visible above the question + # box. infoBoxEnable: Boolean + + # infoBoxContent is the content of the Info Box. infoBoxContent: String - premodLinksEnable: Boolean + + # questionBoxEnable will enable the Question Box's content to be visible above + # the comment box. questionBoxEnable: Boolean + + # questionBoxContent is the content of the Question Box. questionBoxContent: String + + # premodLinksEnable will put all comments that contain links into premod. + premodLinksEnable: Boolean + + # questionBoxIcon is the icon for the Question Box. questionBoxIcon: String - closeTimeout: Int + + # autoCloseStream when true will auto close the stream when the `closeTimeout` + # amount of seconds have been reached. + autoCloseStream: Boolean + + # customCssUrl is the URL of the custom CSS used to display on the frontend. + customCssUrl: String + + # closedTimeout is the amount of seconds from the created_at timestamp that a + # given asset will be considered closed. + closedTimeout: Int + + # closedMessage is the message shown to the user when the given Asset is + # closed. closedMessage: String + + # editCommentWindowLength is the length of time (in milliseconds) after a + # comment is posted that it can still be edited by the author. + editCommentWindowLength: Int + + # charCountEnable is true when the character count restriction is enabled. charCountEnable: Boolean + + # charCount is the maximum number of characters a comment may be. charCount: Int + # organizationName is the name of the organization. organizationName: String + + # wordlist will return a given list of words. + wordlist: Wordlist + + # domains will return a given list of domains. + domains: Domains } ################################################################################ @@ -998,6 +1056,91 @@ type EditCommentResponse implements Response { errors: [UserError!] } +# UpdateSettingsInput is the input used to input the global site settings. This +# will override the existing settings, so all fields must be included. +input UpdateSettingsInput { + + # moderation is the moderation mode for all Asset's on the site. + moderation: MODERATION_MODE + + # Enables a requirement for email confirmation before a user can login. + requireEmailConfirmation: Boolean + + # infoBoxEnable will enable the Info Box content visible above the question + # box. + infoBoxEnable: Boolean + + # infoBoxContent is the content of the Info Box. + infoBoxContent: String + + # questionBoxEnable will enable the Question Box's content to be visible above + # the comment box. + questionBoxEnable: Boolean + + # questionBoxContent is the content of the Question Box. + questionBoxContent: String + + # premodLinksEnable will put all comments that contain links into premod. + premodLinksEnable: Boolean + + # questionBoxIcon is the icon for the Question Box. + questionBoxIcon: String + + # autoCloseStream when true will auto close the stream when the `closeTimeout` + # amount of seconds have been reached. + autoCloseStream: Boolean + + # customCssUrl is the URL of the custom CSS used to display on the frontend. + customCssUrl: String + + # closeTimeout is the amount of seconds from the created_at timestamp that a + # given asset will be considered closed. + closeTimeout: Int + + # closedMessage is the message shown to the user when the given Asset is + # closed. + closedMessage: String + + # charCountEnable is true when the character count restriction is enabled. + charCountEnable: Boolean + + # charCount is the maximum number of characters a comment may be. + charCount: Int + + # organizationName is the name of the organization. + organizationName: String + + # editCommentWindowLength is the length of time (in milliseconds) after a + # comment is posted that it can still be edited by the author. + editCommentWindowLength: Int +} + +# UpdateSettingsResponse contains any errors that were rendered as a result +# of the mutation. +type UpdateSettingsResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# UpdateWordlistInput is the list of words that composes the Wordlist. +input UpdateWordlistInput { + + # banned words will by default reject the comment if it is found. + banned: [String!]! + + # suspect words will simply flag the comment. + suspect: [String!]! +} + +# UpdateWordlistResponse contains any errors that were rendered as a result +# of the mutation. +type UpdateWordlistResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # CreateTokenInput contains the input to create the token. input CreateTokenInput { @@ -1065,6 +1208,14 @@ type RootMutation { # Removes a tag. removeTag(tag: ModifyTagInput!): ModifyTagResponse! + # updateSettings will update the global settings. + # Mutation is restricted. + updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse! + + # updateWordlist will update the given Wordlist. + # Mutation is restricted. + updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse! + # Ignore comments by another user ignoreUser(id: ID!): IgnoreUserResponse diff --git a/perms/constants.js b/perms/constants.js index 383f36daf..8ed1e2b4e 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -16,6 +16,8 @@ module.exports = { UPDATE_CONFIG: 'UPDATE_CONFIG', CREATE_TOKEN: 'CREATE_TOKEN', REVOKE_TOKEN: 'REVOKE_TOKEN', + UPDATE_SETTINGS: 'UPDATE_SETTINGS', + UPDATE_WORDLIST: 'UPDATE_WORDLIST', // queries SEARCH_ASSETS: 'SEARCH_ASSETS', @@ -27,6 +29,7 @@ module.exports = { LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO', + VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS', // subscriptions SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED', diff --git a/perms/index.js b/perms/index.js index be6e7a2cd..a6b459efe 100644 --- a/perms/index.js +++ b/perms/index.js @@ -41,11 +41,8 @@ const findGrant = (user, perms) => { */ module.exports = (user, ...perms) => { - // make sure all the passed permissions are not typos - const missingPerms = perms.filter((perm) => { - return allPermissions.indexOf(perm) === -1; - }); - + // Make sure all the passed permissions are not typos. + const missingPerms = perms.filter((perm) => !allPermissions.includes(perm)); if (missingPerms.length > 0) { throw new Error(`${missingPerms.join(' ')} are not valid permissions.`); } diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js index 897217348..1ea46e9cf 100644 --- a/perms/mutationReducer.js +++ b/perms/mutationReducer.js @@ -4,33 +4,23 @@ const types = require('./constants'); module.exports = (user, perm) => { switch (perm) { case types.CREATE_COMMENT: - return true; case types.CREATE_ACTION: - return true; case types.DELETE_ACTION: - return true; case types.EDIT_NAME: - return true; case types.EDIT_COMMENT: return true; case types.UPDATE_USER_ROLES: - return check(user, ['ADMIN']); case types.REJECT_USERNAME: - return check(user, ['ADMIN', 'MODERATOR']); case types.SET_USER_STATUS: - return check(user, ['ADMIN', 'MODERATOR']); case types.SUSPEND_USER: - return check(user, ['ADMIN', 'MODERATOR']); case types.SET_COMMENT_STATUS: - return check(user, ['ADMIN', 'MODERATOR']); case types.ADD_COMMENT_TAG: - return check(user, ['ADMIN', 'MODERATOR']); case types.REMOVE_COMMENT_TAG: - return check(user, ['ADMIN', 'MODERATOR']); case types.UPDATE_CONFIG: + case types.UPDATE_SETTINGS: + case types.UPDATE_WORDLIST: return check(user, ['ADMIN', 'MODERATOR']); case types.CREATE_TOKEN: - return check(user, ['ADMIN']); case types.REVOKE_TOKEN: return check(user, ['ADMIN']); default: diff --git a/perms/queryReducer.js b/perms/queryReducer.js index 0b76c225a..b71c3d964 100644 --- a/perms/queryReducer.js +++ b/perms/queryReducer.js @@ -21,6 +21,8 @@ module.exports = (user, perm) => { return check(user, ['ADMIN', 'MODERATOR']); case types.VIEW_SUSPENSION_INFO: return check(user, ['ADMIN', 'MODERATOR']); + case types.VIEW_PROTECTED_SETTINGS: + return check(user, ['ADMIN', 'MODERATOR']); default: break; } diff --git a/services/settings.js b/services/settings.js index 2bb15319d..3033701d8 100644 --- a/services/settings.js +++ b/services/settings.js @@ -42,6 +42,19 @@ module.exports = class SettingsService { }); } + /** + * updateWordlist will update the wordlists. + * + * @param {Object} wordlist the Wordlist object + */ + static updateWordlist(wordlist) { + return SettingModel.findOneAndUpdate(selector, { + $set: { + wordlist, + }, + }); + } + /** * This is run once when the app starts to ensure settings are populated. */ diff --git a/test/server/graph/mutations/updateSettings.js b/test/server/graph/mutations/updateSettings.js new file mode 100644 index 000000000..27269b70a --- /dev/null +++ b/test/server/graph/mutations/updateSettings.js @@ -0,0 +1,74 @@ +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const UserModel = require('../../../../models/user'); +const SettingsService = require('../../../../services/settings'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateSettings', () => { + beforeEach(async () => { + await SettingsService.init(); + }); + + const QUERY = ` + mutation UpdateSettings($settings: UpdateSettingsInput!) { + updateSettings(input: $settings) { + errors { + translation_key + } + } + } +`; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {error: 'NOT_AUTHORIZED', roles: []}, + {roles: ['ADMIN']}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles ? roles.join(', ') : '', async () => { + let user; + if (roles != null) { + user = new UserModel({roles}); + } + const ctx = new Context({user}); + + const newSettings = { + premodLinksEnable: false, + moderation: 'POST', + questionBoxEnable: true, + questionBoxContent: 'Question?', + questionBoxIcon: '', + }; + + const res = await graphql(schema, QUERY, {}, ctx, { + settings: newSettings, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateSettings.errors).to.not.be.empty; + expect(res.data.updateSettings.errors[0]).to.have.property('translation_key', error); + } else { + if (res.data.updateSettings.errors) { + console.error(res.data.updateSettings.errors); + } + expect(res.data.updateSettings.errors).to.be.null; + + const retrievedSettings = await SettingsService.retrieve(); + Object.keys(newSettings).forEach((key) => { + expect(retrievedSettings).to.have.property(key, newSettings[key]); + }); + } + }); + }); + }); +}); diff --git a/test/server/graph/mutations/updateWordlist.js b/test/server/graph/mutations/updateWordlist.js new file mode 100644 index 000000000..b2f2e4abb --- /dev/null +++ b/test/server/graph/mutations/updateWordlist.js @@ -0,0 +1,82 @@ +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const UserModel = require('../../../../models/user'); +const SettingsService = require('../../../../services/settings'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateWordlist', () => { + beforeEach(async () => { + await SettingsService.init(); + }); + + const QUERY = ` + mutation UpdateWordlist($wordlist: UpdateWordlistInput!) { + updateWordlist(input: $wordlist) { + errors { + translation_key + } + } + } +`; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {error: 'NOT_AUTHORIZED', roles: []}, + {roles: ['ADMIN']}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles && roles.length > 0 ? roles.join(', ') : '', async () => { + let user; + if (roles != null) { + user = new UserModel({roles}); + } + const ctx = new Context({user}); + + const wordlist = { + banned: [ + 'happy', + ], + suspect: [ + 'sad', + ], + }; + + const res = await graphql(schema, QUERY, {}, ctx, { + wordlist, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateWordlist.errors).to.not.be.empty; + expect(res.data.updateWordlist.errors[0]).to.have.property('translation_key', error); + + const {wordlist: retrievedWordlist} = await SettingsService.retrieve(); + expect(retrievedWordlist).to.have.property('banned'); + expect(retrievedWordlist.banned).to.have.members([]); + expect(retrievedWordlist).to.have.property('suspect'); + expect(retrievedWordlist.suspect).to.have.members([]); + } else { + if (res.data.updateWordlist.errors) { + console.error(res.data.updateWordlist.errors); + } + expect(res.data.updateWordlist.errors).to.be.null; + + const {wordlist: retrievedWordlist} = await SettingsService.retrieve(); + expect(retrievedWordlist).to.have.property('banned'); + expect(retrievedWordlist.banned).to.have.members(wordlist.banned); + expect(retrievedWordlist).to.have.property('suspect'); + expect(retrievedWordlist.suspect).to.have.members(wordlist.suspect); + } + }); + }); + }); +}); diff --git a/test/server/graph/queries/settings.js b/test/server/graph/queries/settings.js new file mode 100644 index 000000000..338999145 --- /dev/null +++ b/test/server/graph/queries/settings.js @@ -0,0 +1,97 @@ +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const SettingsService = require('../../../../services/settings'); +const UserModel = require('../../../../models/user'); + +const {expect} = require('chai'); + +const defaultSettings = { + organizationName: 'The Coral Project' +}; + +describe('graph.queries.settings', () => { + let settings; + beforeEach(async () => { + settings = await SettingsService.init(defaultSettings); + }); + + const QUERY = ` + { + settings { + moderation + requireEmailConfirmation + infoBoxEnable + infoBoxContent + questionBoxEnable + questionBoxContent + premodLinksEnable + questionBoxIcon + autoCloseStream + customCssUrl + closedTimeout + closedMessage + charCountEnable + charCount + organizationName + wordlist { + banned + suspect + } + domains { + whitelist + } + } + } + `; + + describe('context with different user roles', () => { + + const BLACKLISTED_PROPERTIES = [ + 'premodLinksEnable', + 'autoCloseStream', + 'wordlist', + 'domains', + ]; + + [ + {bl: true}, + {bl: true, roles: []}, + {bl: false, roles: ['ADMIN']}, + {bl: false, roles: ['ADMIN', 'MODERATOR']}, + {bl: false, roles: ['MODERATOR']}, + ].forEach(({bl, roles}) => { + it(roles && roles.length > 0 ? roles.join(', ') : '', async () => { + let user; + if (roles != null) { + user = new UserModel({roles}); + } + + const ctx = new Context({user}); + + const res = await graphql(schema, QUERY, {}, ctx); + if (res.errors) { + console.error(res.errors); + } + + expect(res.errors).to.be.empty; + expect(res.data.settings).to.be.object; + Object.keys(res.data.settings).forEach((key) => { + if (bl && BLACKLISTED_PROPERTIES.includes(key)) { + expect(res.data.settings).to.have.property(key, null); + return; + } + + if (typeof settings[key] !== 'object') { + expect(res.data.settings).to.have.property(key, settings[key]); + } else { + expect(res.data.settings).to.have.property(key); + expect(res.data.settings[key]).to.not.be.null; + } + }); + }); + }); + }); + +});