diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js index 748f80b0f..7ac18f907 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -8,7 +8,7 @@ const StorySearch = (props) => { const { root: { - assets = [] + assets, }, data: {loading} } = props; @@ -62,7 +62,7 @@ const StorySearch = (props) => { { loading ? - : assets.map((story, i) => { + : assets.nodes.map((story, i) => { const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date(); return { }) } - {assets.length === 0 &&
No results
} + {assets.nodes.length === 0 &&
No results
} diff --git a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js index 04472d219..04aab2d84 100644 --- a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js @@ -89,12 +89,14 @@ class StorySearchContainer extends React.Component { export const withAssetSearchQuery = withQuery(gql` query SearchStories($value: String = "") { assets(query: {value: $value, limit: 10}) { - id - title - url - created_at - closedAt - author + nodes { + id + title + url + created_at + closedAt + author + } } } `, { diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index d3279896c..273d39589 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -302,7 +302,7 @@ const fragments = { questionBoxEnable questionBoxContent questionBoxIcon - closeTimeout + closedTimeout closedMessage charCountEnable charCount diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index 956d573ef..b2e315ec5 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -25,8 +25,33 @@ const genAssetsByID = (context, ids) => AssetModel.find({ * @param {Object} query the query * @return {Promise} resolves the assets */ -const getAssetsByQuery = (context, query) => { - return AssetsService.search(query); +const getAssetsByQuery = async (context, query) => { + + // If we are requesting based on a limit, ask for one more than we want. + const limit = query.limit; + if (limit) { + query.limit += 1; + } + + const nodes = await AssetsService.search(query); + + // The hasNextPage is always handled the same (ask for one more than we need, + // if there is one more, than there is more). + let hasNextPage = false; + if (limit && nodes.length > limit) { + + // There was one more than we expected! Set hasNextPage = true and remove + // the last item from the array that we requested. + hasNextPage = true; + nodes.splice(limit, 1); + } + + return { + startCursor: nodes && nodes.length > 0 ? nodes[0].created_at : null, + endCursor: nodes && nodes.length > 0 ? nodes[nodes.length - 1].created_at : null, + hasNextPage, + nodes, + }; }; /** @@ -84,7 +109,7 @@ module.exports = (context) => ({ getByURL: (url) => findOrCreateAssetByURL(context, url), findByUrl: (url) => findByUrl(context, url), - search: (query) => getAssetsByQuery(context, query), + getByQuery: (query) => getAssetsByQuery(context, query), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), getForMetrics: () => getAssetsForMetrics(context), getAll: new util.SingletonResolver(() => AssetModel.find({})) 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/asset.js b/graph/mutators/asset.js new file mode 100644 index 000000000..462607f5f --- /dev/null +++ b/graph/mutators/asset.js @@ -0,0 +1,55 @@ +const errors = require('../../errors'); +const { + UPDATE_ASSET_SETTINGS, + UPDATE_ASSET_STATUS, +} = require('../../perms/constants'); + +const AssetsService = require('../../services/assets'); +const AssetModel = require('../../models/asset'); + +/** + * updateSettings will update the settings on an asset. + * + * @param {Object} ctx graphql context + * @param {String} id the asset's id to update + * @param {Object} settings the settings to update on the asset. + */ +const updateSettings = async (ctx, id, settings) => AssetsService.overrideSettings(id, settings); + +/** + * updateStatus will update the status of an asset. + * + * @param {Object} ctx graphql context + * @param {String} id the asset's id to update + * @param {Object} status the status to change on the asset relating to it's + * current state. + */ +const updateStatus = async (ctx, id, {closedAt, closedMessage}) => AssetModel.update({ + id, +}, { + $set: { + closedAt, + closedMessage + } +}); + +module.exports = (ctx) => { + let mutators = { + Asset: { + updateSettings: () => Promise.reject(errors.ErrNotAuthorized), + updateStatus: () => Promise.reject(errors.ErrNotAuthorized) + } + }; + + if (ctx.user) { + if (ctx.user.can(UPDATE_ASSET_SETTINGS)) { + mutators.Asset.updateSettings = (id, settings) => updateSettings(ctx, id, settings); + } + + if (ctx.user.can(UPDATE_ASSET_STATUS)) { + mutators.Asset.updateStatus = (id, status) => updateStatus(ctx, id, status); + } + } + + return mutators; +}; diff --git a/graph/mutators/index.js b/graph/mutators/index.js index e7e5df9e7..0f290c69a 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -3,6 +3,8 @@ const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); +const Asset = require('./asset'); +const Settings = require('./settings'); const Tag = require('./tag'); const Token = require('./token'); const User = require('./user'); @@ -14,6 +16,8 @@ let mutators = [ // Load in the core mutators. Comment, Action, + Asset, + 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 21e6c4787..df67b8e8a 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -29,6 +29,12 @@ const RootMutation = { stopIgnoringUser: async (_, {id}, {mutators: {User}}) => { await User.stopIgnoringUser({id}); }, + updateAssetSettings: async (_, {id, input: settings}, {mutators: {Asset}}) => { + await Asset.updateSettings(id, settings); + }, + updateAssetStatus: async (_, {id, input: status}, {mutators: {Asset}}) => { + await Asset.updateStatus(id, status); + }, setCommentStatus: async (_, {id, status}, {mutators: {Comment}, pubsub}) => { const comment = await Comment.setStatus({id, status}); if (status === 'ACCEPTED') { @@ -47,6 +53,12 @@ const RootMutation = { removeTag: async (_, {tag}, {mutators: {Tag}}) => { await Tag.remove(tag); }, + updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => { + await Settings.update(settings); + }, + updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => { + await Settings.updateWordlist(wordlist); + }, createToken: async (_, {input}, {mutators: {Token}}) => ({ token: await Token.create(input), }), diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 239b052c0..13e41a312 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -11,7 +11,7 @@ const RootQuery = { return null; } - return Assets.search(query); + return Assets.getByQuery(query); }, asset(_, query, {loaders: {Assets}}) { if (query.id) { 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) => { }; }; +/** + * 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 + decorateWithTags, + decorateWithPermissionCheck, }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c3cbc2e57..916c0ade5 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -164,7 +164,19 @@ input AssetsQuery { # Limit the number of results to be returned limit: Int = 10 + + # open filters assets that are open/closed/all. Not providing this parameter + # will return all the assets, true will return assets that are open, and false + # will return assets that are closed. + open: Boolean + + # sortOrder specifies the order of the sort for the returned Assets. + sortOrder: SORT_ORDER = DESC + + # Skip results from the last created_at timestamp. + cursor: Cursor } + ################################################################################ ## Tags ################################################################################ @@ -572,27 +584,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 } ################################################################################ @@ -648,6 +718,22 @@ type Asset { author: String } +# AssetConnection represents a paginable subset of a asset list. +type AssetConnection { + + # Indicates that there are more assets after this subset. + hasNextPage: Boolean! + + # Cursor of first asset in subset. + startCursor: Cursor + + # Cursor of last asset in subset. + endCursor: Cursor + + # Subset of assets. + nodes: [Asset!]! +} + ################################################################################ ## Errors ################################################################################ @@ -735,7 +821,7 @@ type RootQuery { comment(id: ID!): Comment # All assets. Requires the `ADMIN` role. - assets(query: AssetsQuery): [Asset] + assets(query: AssetsQuery): AssetConnection # Find or create an asset by url, or just find with the ID. asset(id: ID, url: String): Asset @@ -905,6 +991,56 @@ input RejectUsernameInput { message: String! } +# Configurable settings that can be overridden for the Asset. You must specify +# all fields that should be updated. +input AssetSettingsInput { + + # premodLinksEnable will put all comments that contain links into premod. + premodLinksEnable: Boolean + + # moderation is the moderation mode for the asset. + moderation: MODERATION_MODE + + # questionBoxEnable will enable the Question Boxs' content to be visable above + # the comment box. + questionBoxEnable: Boolean + + # questionBoxContent is the content of the Question Box. + questionBoxContent: String + + # questionBoxIcon is the icon for the Question Box. + questionBoxIcon: String +} + +# UpdateAssetStatusInput contains the input to change the status of a comment as +# it relates to being open/closed for commenting. +input UpdateAssetStatusInput { + + # closedAt is the time that the asset will be closed for commenting. If this + # is null or in the future, it will be open for commenting. + closedAt: Date + + # closedMessage is the message to be set on the asset when it is closed. If it + # is null, then the message will default to the globally set `closedMessage`. + closedMessage: String +} + +# UpdateAssetStatusResponse is the response returned with possibly some errors +# relating to the update status attempt. +type UpdateAssetStatusResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# UpdateAssetSettingsResponse is the response returned with possibly some errors +# relating to the update settings attempt. +type UpdateAssetSettingsResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # DeleteActionResponse is the response returned with possibly some errors # relating to the delete action attempt. type DeleteActionResponse implements Response { @@ -998,6 +1134,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 + + # 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 + + # 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 { @@ -1033,30 +1254,34 @@ type RevokeTokenResponse implements Response { type RootMutation { # Creates a comment on the asset. - createComment(input: CreateCommentInput!): CreateCommentResponse + createComment(input: CreateCommentInput!): CreateCommentResponse! # Creates a flag on an entity. - createFlag(flag: CreateFlagInput!): CreateFlagResponse + createFlag(flag: CreateFlagInput!): CreateFlagResponse! # Creates a don't agree action on an entity. - createDontAgree(dontagree: CreateDontAgreeInput!): CreateDontAgreeResponse + createDontAgree(dontagree: CreateDontAgreeInput!): CreateDontAgreeResponse! # Delete an action based on the action id. deleteAction(id: ID!): DeleteActionResponse # Edit a comment - editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse + editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse! # Sets User status. Requires the `ADMIN` role. + # Mutation is restricted. setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse # Suspends a user. Requires the `ADMIN` role. + # Mutation is restricted. suspendUser(input: SuspendUserInput!): SuspendUserResponse # Reject a username. Requires the `ADMIN` role. + # Mutation is restricted. rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse # Sets Comment status. Requires the `ADMIN` role. + # Mutation is restricted. setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse # Add a tag. @@ -1065,16 +1290,35 @@ type RootMutation { # Removes a tag. removeTag(tag: ModifyTagInput!): ModifyTagResponse + # Updates settings on a given asset. + # Mutation is restricted. + updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse + + # Updates the status of an asset allowing you to close/reopen an asset for + # commenting. + # Mutation is restricted. + updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse + + # 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 # CreateToken will create a token that is attached to the current user. + # Mutation is restricted. createToken(input: CreateTokenInput!): CreateTokenResponse! # RevokeToken will revoke an existing token. + # Mutation is restricted. revokeToken(input: RevokeTokenInput!): RevokeTokenResponse - # Stop Ignoring comments by another user + # Stop Ignoring comments by another user. stopIgnoringUser(id: ID!): StopIgnoringUserResponse } diff --git a/perms/constants.js b/perms/constants.js index 383f36daf..070fe80bf 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -16,6 +16,10 @@ module.exports = { UPDATE_CONFIG: 'UPDATE_CONFIG', CREATE_TOKEN: 'CREATE_TOKEN', REVOKE_TOKEN: 'REVOKE_TOKEN', + UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS', + UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', + UPDATE_SETTINGS: 'UPDATE_SETTINGS', + UPDATE_WORDLIST: 'UPDATE_WORDLIST', // queries SEARCH_ASSETS: 'SEARCH_ASSETS', @@ -27,6 +31,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..2e8718ed1 100644 --- a/perms/mutationReducer.js +++ b/perms/mutationReducer.js @@ -4,33 +4,26 @@ 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']); + return check(user, ['ADMIN', 'MODERATOR', 'STAFF']); + case types.UPDATE_USER_ROLES: + case types.REJECT_USERNAME: + case types.SET_USER_STATUS: + case types.SUSPEND_USER: + case types.SET_COMMENT_STATUS: case types.UPDATE_CONFIG: + case types.UPDATE_SETTINGS: + case types.UPDATE_WORDLIST: + case types.UPDATE_ASSET_SETTINGS: + case types.UPDATE_ASSET_STATUS: 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/plugins/talk-plugin-offtopic/client/components/OffTopicCheckbox.js b/plugins/talk-plugin-offtopic/client/components/OffTopicCheckbox.js index 7b59d87d6..7f4ec3503 100644 --- a/plugins/talk-plugin-offtopic/client/components/OffTopicCheckbox.js +++ b/plugins/talk-plugin-offtopic/client/components/OffTopicCheckbox.js @@ -29,12 +29,13 @@ export default class OffTopicCheckbox extends React.Component { } render() { + const checked = this.props.tags.indexOf(this.label) >= 0; return (
{ !this.props.isReply ? ( ) : null diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index a306b36b4..87014b86e 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -1,7 +1,6 @@ const express = require('express'); const router = express.Router(); -const scraper = require('../../../services/scraper'); const errors = require('../../../errors'); const AssetsService = require('../../../services/assets'); @@ -88,25 +87,6 @@ router.get('/:asset_id', async (req, res, next) => { } }); -// Adds the asset id to the queue to be scraped. -router.post('/:asset_id/scrape', async (req, res, next) => { - try { - - // Send back the asset. - let asset = await AssetsService.findById(req.params.asset_id); - if (!asset) { - return next(errors.ErrNotFound); - } - - let job = await scraper.create(asset); - - // Send the job back for monitoring. - res.status(201).json(job); - } catch (e) { - return next(e); - } -}); - router.put('/:asset_id/settings', async (req, res, next) => { try { await AssetsService.overrideSettings(req.params.asset_id, req.body); diff --git a/services/assets.js b/services/assets.js index 74bc1aa3a..34c8ff465 100644 --- a/services/assets.js +++ b/services/assets.js @@ -108,19 +108,57 @@ module.exports = class AssetsService { * @param {String} value string to search by. * @return {Promise} */ - static search({value, skip, limit} = {}) { - if (!value) { - return AssetsService.all(skip, limit); - } else { - return AssetModel - .find({ - $text: { - $search: value - } - }) - .skip(skip) - .limit(limit); + static search({value, limit, open, sortOrder, cursor} = {}) { + let assets = AssetModel.find({}); + + if (value) { + assets.merge({ + $text: { + $search: value + } + }); } + + if (open != null) { + if (open) { + assets.merge({ + $or: [ + { + closedAt: null + }, + { + closedAt: { + $gt: Date.now() + } + } + ] + }); + } else { + assets.merge({ + closedAt: { + $lt: Date.now() + } + }); + } + } + + if (cursor) { + if (sortOrder === 'DESC') { + assets.merge({ + created_at: { + $lt: cursor, + }, + }); + } else { + assets.merge({ + created_at: { + $gt: cursor, + }, + }); + } + } + + return assets.sort({created_at: sortOrder === 'DESC' ? -1 : 1}).limit(limit); } /** @@ -185,10 +223,9 @@ module.exports = class AssetsService { // That's it! } - static all(skip = null, limit = null) { + static all(limit = undefined) { return AssetModel .find({}) - .skip(skip) .limit(limit); } }; 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/updateAssetSettings.js b/test/server/graph/mutations/updateAssetSettings.js new file mode 100644 index 000000000..306209c74 --- /dev/null +++ b/test/server/graph/mutations/updateAssetSettings.js @@ -0,0 +1,73 @@ +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 AssetModel = require('../../../../models/asset'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateAssetSettings', () => { + let asset; + beforeEach(async () => { + await SettingsService.init(); + asset = await AssetModel.create({url: 'http://new.test.com/'}); + }); + + const QUERY = ` + mutation UpdateAssetStatus($id: ID!, $settings: AssetSettingsInput!) { + updateAssetSettings(id: $id, input: $settings) { + errors { + translation_key + } + } + } +`; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles ? roles.join(', ') : '', async () => { + const user = new UserModel({roles}); + const ctx = new Context({user}); + + const settings = { + premodLinksEnable: false, + moderation: 'POST', + questionBoxEnable: true, + questionBoxContent: 'Question?', + questionBoxIcon: '', + }; + + const res = await graphql(schema, QUERY, {}, ctx, { + id: asset.id, + settings, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateAssetSettings.errors).to.not.be.empty; + expect(res.data.updateAssetSettings.errors[0]).to.have.property('translation_key', error); + } else { + if (res.data.updateAssetSettings && res.data.updateAssetSettings.errors) { + console.error(res.data.updateAssetSettings.errors); + } + expect(res.data.updateAssetSettings).to.be.null; + + const retrievedAsset = await AssetModel.findOne({id: asset.id}); + Object.keys(settings).forEach((key) => { + expect(retrievedAsset.settings).to.have.property(key, settings[key]); + }); + } + }); + }); + }); +}); diff --git a/test/server/graph/mutations/updateAssetStatus.js b/test/server/graph/mutations/updateAssetStatus.js new file mode 100644 index 000000000..77be55976 --- /dev/null +++ b/test/server/graph/mutations/updateAssetStatus.js @@ -0,0 +1,70 @@ +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 AssetModel = require('../../../../models/asset'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateAssetStatus', () => { + let asset; + beforeEach(async () => { + await SettingsService.init(); + asset = await AssetModel.create({url: 'http://new.test.com/'}); + }); + + const QUERY = ` + mutation UpdateAssetStatus($id: ID!, $status: UpdateAssetStatusInput!) { + updateAssetStatus(id: $id, input: $status) { + errors { + translation_key + } + } + } + `; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles ? roles.join(', ') : '', async () => { + const user = new UserModel({roles}); + const ctx = new Context({user}); + + const closedAt = (new Date()).toISOString(); + const closedMessage = 'my closed message!'; + + const res = await graphql(schema, QUERY, {}, ctx, { + id: asset.id, + status: { + closedAt, + closedMessage, + }, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateAssetStatus.errors).to.not.be.empty; + expect(res.data.updateAssetStatus.errors[0]).to.have.property('translation_key', error); + } else { + if (res.data.updateAssetStatus && res.data.updateAssetStatus.errors) { + console.error(res.data.updateAssetStatus.errors); + } + expect(res.data.updateAssetStatus).to.be.null; + + const retrievedAsset = await AssetModel.findOne({id: asset.id}); + expect(retrievedAsset.closedAt).to.not.be.null; + expect(retrievedAsset).to.have.property('closedMessage', closedMessage); + } + }); + }); + }); +}); diff --git a/test/server/graph/mutations/updateSettings.js b/test/server/graph/mutations/updateSettings.js new file mode 100644 index 000000000..c1ec678d6 --- /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 && res.data.updateSettings.errors) { + console.error(res.data.updateSettings.errors); + } + expect(res.data.updateSettings).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..3a392a074 --- /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 && res.data.updateWordlist.errors) { + console.error(res.data.updateWordlist.errors); + } + expect(res.data.updateWordlist).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/asset.js b/test/server/graph/queries/asset.js index ba605dfd7..b49eb7429 100644 --- a/test/server/graph/queries/asset.js +++ b/test/server/graph/queries/asset.js @@ -74,12 +74,10 @@ describe('graph.queries.asset', () => { expect(asset.nodes).to.have.length(2); expect(asset.hasNextPage).to.be.false; - expect(asset.nodes[0]).to.have.property('id', comments[1].id); - expect(asset.nodes[1]).to.have.property('id', comments[0].id); + expect(asset.nodes.map(({id}) => id)).to.have.members(comments.slice(0, 2).map(({id}) => id)); expect(otherAsset.nodes).to.have.length(2); expect(otherAsset.hasNextPage).to.be.false; - expect(otherAsset.nodes[0]).to.have.property('id', comments[3].id); - expect(otherAsset.nodes[1]).to.have.property('id', comments[2].id); + expect(otherAsset.nodes.map(({id}) => id)).to.have.members(comments.slice(2, 4).map(({id}) => id)); for (let node of asset.nodes) { for (let otherNode of otherAsset.nodes) { 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; + } + }); + }); + }); + }); + +}); diff --git a/test/server/routes/api/assets/index.js b/test/server/routes/api/assets/index.js index daccd5511..855b30271 100644 --- a/test/server/routes/api/assets/index.js +++ b/test/server/routes/api/assets/index.js @@ -3,7 +3,7 @@ const passport = require('../../../passport'); const app = require('../../../../../app'); const chai = require('chai'); -chai.should(); +chai.use(require('chai-as-promised')); chai.use(require('chai-http')); const expect = chai.expect; @@ -13,139 +13,125 @@ const SettingsService = require('../../../../../services/settings'); describe('/api/v1/assets', () => { - beforeEach(() => { + beforeEach(async () => { const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['test.com']}}; - return SettingsService.init(settings).then(() => { - return AssetModel.create([ - { - url: 'https://coralproject.net/news/asset1', - title: 'Asset 1', - description: 'term1', - closedAt: Date.now() - }, - { - url: 'https://coralproject.net/news/asset2', - title: 'Asset 2', - description: 'term2', - closedAt: null - } - ]); - }); + await SettingsService.init(settings); + + await AssetModel.create([ + { + url: 'https://coralproject.net/news/asset1', + title: 'Asset 1', + description: 'term1', + closedAt: Date.now() + }, + { + url: 'https://coralproject.net/news/asset2', + title: 'Asset 2', + description: 'term2', + closedAt: null + } + ]); }); describe('#get', () => { - it('should return all assets without a search query', () => { - return chai.request(app) + it('should return all assets without a search query', async () => { + const res = await chai.request(app) .get('/api/v1/assets') - .set(passport.inject({roles: ['ADMIN']})) - .then((res) => { - const body = res.body; + .set(passport.inject({roles: ['ADMIN']})); - expect(body).to.have.property('count', 2); - expect(body).to.have.property('result'); + const body = res.body; - const assets = body.result; + expect(body).to.have.property('count', 2); + expect(body).to.have.property('result'); - expect(assets).to.have.length(2); - }); + const assets = body.result; + + expect(assets).to.have.length(2); }); - it('should return assets that we search for', () => { - return chai.request(app) + it('should return assets that we search for', async () => { + const res = await chai.request(app) .get('/api/v1/assets?search=term2') - .set(passport.inject({roles: ['ADMIN']})) - .then((res) => { - const body = res.body; + .set(passport.inject({roles: ['ADMIN']})); - expect(body).to.have.property('count', 1); - expect(body).to.have.property('result'); + const body = res.body; - const assets = body.result; + expect(body).to.have.property('count', 1); + expect(body).to.have.property('result'); - expect(assets).to.have.length(1); + const assets = body.result; - const asset = assets[0]; + expect(assets).to.have.length(1); - expect(asset).to.have.property('url', 'https://coralproject.net/news/asset2'); - expect(asset).to.have.property('title', 'Asset 2'); - }); + const asset = assets[0]; + + expect(asset).to.have.property('url', 'https://coralproject.net/news/asset2'); + expect(asset).to.have.property('title', 'Asset 2'); }); - it('should not return assets that we do not search for', () => { - return chai.request(app) + it('should not return assets that we do not search for', async () => { + const res = await chai.request(app) .get('/api/v1/assets?search=term3') - .set(passport.inject({roles: ['ADMIN']})) - .then((res) => { - const body = res.body; + .set(passport.inject({roles: ['ADMIN']})); + const body = res.body; - expect(body).to.have.property('count', 0); - expect(body).to.have.property('result'); + expect(body).to.have.property('count', 0); + expect(body).to.have.property('result'); - expect(body.result).to.be.empty; - }); + expect(body.result).to.be.empty; }); - it('should return only closed assets', () => { - return chai.request(app) + it('should return only closed assets', async () => { + const res = await chai.request(app) .get('/api/v1/assets?filter=closed') - .set(passport.inject({roles: ['ADMIN']})) - .then((res) => { - const body = res.body; + .set(passport.inject({roles: ['ADMIN']})); + const body = res.body; - expect(body).to.have.property('count', 1); - expect(body).to.have.property('result'); + expect(body).to.have.property('count', 1); + expect(body).to.have.property('result'); - const assets = body.result; + const assets = body.result; - expect(assets[0]).to.have.property('title', 'Asset 1'); - }); + expect(assets[0]).to.have.property('title', 'Asset 1'); }); - it('should return only opened assets', () => { - return chai.request(app) + it('should return only opened assets', async () => { + const res = await chai.request(app) .get('/api/v1/assets?filter=open') - .set(passport.inject({roles: ['ADMIN']})) - .then((res) => { - const body = res.body; + .set(passport.inject({roles: ['ADMIN']})); + const body = res.body; - expect(body).to.have.property('count', 1); - expect(body).to.have.property('result'); + expect(body).to.have.property('count', 1); + expect(body).to.have.property('result'); - const assets = body.result; + const assets = body.result; - expect(assets[0]).to.have.property('title', 'Asset 2'); - }); + expect(assets[0]).to.have.property('title', 'Asset 2'); }); }); describe('#put', () => { - it('should close the asset', function() { + it('should close the asset', async () => { const today = Date.now(); - return AssetsService.findOrCreateByUrl('http://test.com') - .then((asset) => { - expect(asset).to.have.property('isClosed', false); - expect(asset).to.have.property('closedAt', null); + const asset = await AssetsService.findOrCreateByUrl('http://test.com'); + expect(asset).to.have.property('isClosed', false); + expect(asset).to.have.property('closedAt', null); - return chai.request(app) - .put(`/api/v1/assets/${asset.id}/status`) - .set(passport.inject({roles: ['ADMIN']})) - .send({closedAt: today}); - }) - .then((res) => { + const res = await chai.request(app) + .put(`/api/v1/assets/${asset.id}/status`) + .set(passport.inject({roles: ['ADMIN']})) + .send({closedAt: today}); - expect(res).to.have.status(204); + expect(res).to.have.status(204); - return AssetsService.findByUrl('http://test.com'); - }) - .then((asset) => { - expect(asset).to.have.property('isClosed', true); - expect(asset).to.have.property('closedAt').and.to.not.equal(null); - }); + const closedAsset = await AssetsService.findByUrl('http://test.com'); + expect(closedAsset).to.have.property('isClosed', true); + expect(closedAsset).to.have.property('closedAt').and.to.not.equal(null); }); });