From 8f219c47a763c6a4db19e06b5ce45288b2dc994e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 19 May 2017 12:23:53 -0600 Subject: [PATCH] Tag server impl --- PLUGINS.md | 24 ++++++ graph/loaders/index.js | 2 + graph/loaders/tags.js | 22 +++++ graph/mutators/comment.js | 74 +++++++++++----- graph/mutators/tag.js | 60 ++----------- plugins.default.json | 3 +- plugins/coral-plugin-offtopic/index.js | 13 ++- .../server/typeDefs.graphql | 4 - services/tags.js | 85 ++++++++++++++++++- test/server/graph/mutations/addTag.js | 2 +- 10 files changed, 209 insertions(+), 80 deletions(-) create mode 100644 graph/loaders/tags.js delete mode 100644 plugins/coral-plugin-offtopic/server/typeDefs.graphql diff --git a/PLUGINS.md b/PLUGINS.md index 0d17ccd55..4462f8577 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -307,6 +307,30 @@ module.exports = { } ``` +#### Field: `tags` + +The tags hook allows a plugin to define tags that are code controlled (added +or enabled by code). Below is an example pulled from the core off topic plugin +on how to create a hook for the `OFF_TOPIC` name: + +```js +[ + { + name: 'OFF_TOPIC', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } +] +``` + +You can refer to `models/schema/tag.js` for the available schema to match when +creating models to enable/disable specific features. + #### Field: `passport` ```js diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 26727940f..8bc170c6d 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -6,6 +6,7 @@ const Assets = require('./assets'); const Comments = require('./comments'); const Metrics = require('./metrics'); const Settings = require('./settings'); +const Tags = require('./tags'); const Users = require('./users'); const plugins = require('../../services/plugins'); @@ -18,6 +19,7 @@ let loaders = [ Comments, Metrics, Settings, + Tags, Users, // Load the plugin loaders from the manager. diff --git a/graph/loaders/tags.js b/graph/loaders/tags.js new file mode 100644 index 000000000..23ab6fd43 --- /dev/null +++ b/graph/loaders/tags.js @@ -0,0 +1,22 @@ +const DataLoader = require('dataloader'); +const TagsService = require('../../services/tags'); + +/** + * Get all the tags for the context for the dataloader. + */ +const genAll = (context, queries) => { + return Promise.all(queries.map(({id, item_type, asset_id}) => { + return TagsService.getAll({id, item_type, asset_id}); + })); +}; + +/** + * Creates a set of loaders based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of loaders + */ +module.exports = (context) => ({ + Tags: { + getAll: new DataLoader((queries) => genAll(context, queries)) + } +}); diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 193753275..75e7d9fc0 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -2,11 +2,61 @@ const errors = require('../../errors'); const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); +const TagsService = require('../../services/tags'); const CommentsService = require('../../services/comments'); const linkify = require('linkify-it')(); - const Wordlist = require('../../services/wordlist'); +const debug = require('debug')('talk:graph:mutators:tags'); +const plugins = require('../../services/plugins'); + +const pluginTags = plugins.get('server', 'tags').reduce((acc, {plugin, tags}) => { + debug(`added plugin '${plugin.name}'`); + + acc = acc.concat(tags); + + return acc; +}, []); + +const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => { + const item_type = 'COMMENTS'; + + // Handle Tags + if (tags.length) { + + // Get the global list of tags from the dataloader. + let globalTags = await Tags.getAll.load({ + item_type, + asset_id + }); + if (!Array.isArray(globalTags)) { + globalTags = []; + } + + globalTags = globalTags.concat(pluginTags); + + // Merge in the tags for the given comment. + tags = tags.map((name) => { + + // Resolve the TagLink that we can use for the comment. + let {tagLink} = TagsService.resolveLink(user, globalTags, {name, item_type}); + + // Return the tagLink for tag insertion. + return tagLink; + }); + } + + // Add the staff tag for comments created as a staff member. + if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) { + tags.push(TagsService.newTagLink(user, { + name: 'STAFF', + item_type + })); + } + + return tags; +}; + /** * Creates a new comment. * @param {Object} user the user performing the request @@ -16,25 +66,11 @@ const Wordlist = require('../../services/wordlist'); * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = async ({user, loaders: {Comments}, pubsub}, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => { +const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => { + const {user, loaders: {Comments}, pubsub} = context; - // Handle Tags - if (tags.length) { - tags = tags.map((tag) => ({ - tag: { - name: tag - } - })); - } - - // Add the staff tag for comments created as a staff member. - if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) { - tags.push({ - tag: { - name: 'STAFF' - } - }); - } + // Resolve the tags for the comment. + tags = await resolveTagsForComment(context, {asset_id, tags}); let comment = await CommentsService.publicCreate({ body, diff --git a/graph/mutators/tag.js b/graph/mutators/tag.js index 3182a7d00..0c93d8ea0 100644 --- a/graph/mutators/tag.js +++ b/graph/mutators/tag.js @@ -4,62 +4,18 @@ const errors = require('../../errors'); /** * Modifies the targeted model with the specified operation to add/remove a tag. */ -const modify = async ({user}, operation, {name, id, item_type, asset_id}) => { +const modify = async ({user, loaders: {Tags}}, operation, {name, id, item_type, asset_id}) => { - // Try to find the tag in the global list. This will contain the permission - // information if it's found. - let tag = await TagsService.get({name, id, item_type, asset_id}); + // Get the global list of tags from the dataloader. + const tags = await Tags.getAll.load({id, item_type, asset_id}); - // Create the new tagLink that will be created to interact to the comment. - let tagLink = { - tag, - assigned_by: user.id, - created_at: new Date() - }; - - // If the tag was found, we need to ensure that the current user can indeed - // modify this tag on the comment. - if (tag) { - - // If the tag has roles defined, and the current user has at least one of - // the required roles, then modify the tag without checking for ownership. - if (tag.permissions && tag.permissions.roles && tag.permissions.roles.some((role) => user.roles.include(role))) { - return operation(id, item_type, tagLink, false); - } - - // If the permissions allow for self assignment, then ensure that the query - // is compose with that in mind. - if (tag.permissions && tag.permissions.self) { - - // Otherwise, we assume that we have to check to see that the user indeed - // owns the resource before allowing the tag to get modified. - return operation(id, item_type, tagLink, true); - } - - throw errors.ErrNotAuthorized; - } - - // Only admin/moderators can modify unique tags, these are tags that are not - // in the global list. - if (!(user.hasRoles('ADMIN') || user.hasRoles('MODERATOR'))) { - throw errors.ErrNotAuthorized; - } - - // Generate the tag in the event now that we have to create the tag for this - // specific comment. - tagLink.tag = { - name, - permissions: { - public: true, - self: false, - roles: [] - }, - models: [item_type], - created_at: new Date() - }; + // Resolve the TagLink that should be used to insert to the user. This will + // addtionally return with an ownership property that can be used to determine + // that the user who adds this tag must also be the owner of the resource. + let {tagLink, ownership} = TagsService.resolveLink(user, tags, {name, item_type}); // Actually modify the tag on the model. - return operation(id, item_type, tagLink, false); + return operation(id, item_type, tagLink, ownership); }; module.exports = (context) => { diff --git a/plugins.default.json b/plugins.default.json index b3443af48..7af4eb4fe 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,7 +1,8 @@ { "server": [ "coral-plugin-respect", - "coral-plugin-facebook-auth" + "coral-plugin-facebook-auth", + "coral-plugin-offtopic" ], "client": [ "coral-plugin-respect" diff --git a/plugins/coral-plugin-offtopic/index.js b/plugins/coral-plugin-offtopic/index.js index 51ad1c24e..9988686ff 100644 --- a/plugins/coral-plugin-offtopic/index.js +++ b/plugins/coral-plugin-offtopic/index.js @@ -2,5 +2,16 @@ const {readFileSync} = require('fs'); const path = require('path'); module.exports = { - typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8') + tags: [ + { + name: 'OFF_TOPIC', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } + ] }; diff --git a/plugins/coral-plugin-offtopic/server/typeDefs.graphql b/plugins/coral-plugin-offtopic/server/typeDefs.graphql deleted file mode 100644 index 48ba577cf..000000000 --- a/plugins/coral-plugin-offtopic/server/typeDefs.graphql +++ /dev/null @@ -1,4 +0,0 @@ -## Extending TAG_TYPE by adding OFF_TOPIC Tag -enum TAG_TYPE { - OFF_TOPIC -} diff --git a/services/tags.js b/services/tags.js index 3ee3de121..7653434c9 100644 --- a/services/tags.js +++ b/services/tags.js @@ -5,6 +5,8 @@ const UserModel = require('../models/user'); const AssetsService = require('./assets'); const SettingsService = require('./settings'); +const errors = require('../errors'); + const updateModel = async (item_type, query, update) => { // Get the model to update with. @@ -43,7 +45,7 @@ class TagsService { /** * Retrives a global tag from the settings based on the input_type. */ - static async get({name, id, item_type, asset_id = null}) { + static async getAll({id, item_type, asset_id = null}) { // Extract the settings from the database. let settings; @@ -66,7 +68,86 @@ class TagsService { let {tags = []} = settings; // Return the first tag that matches the requested form. - return tags.find((tag) => tag.name === name && tag.models.include(item_type)); + return tags; + } + + /** + * Resolves the tagLink and ownership verification requirements that should be + * used when trying to perform tag adding/removing operations. + */ + static resolveLink(user, tags, {name, item_type}) { + + // Try to find the tag in the global list. This will contain the permission + // information if it's found. + let tag = tags.find((tag) => { + return tag.name === name && Array.isArray(tag.models) && tag.models.includes(item_type); + }); + + // Create the new tagLink that will be created to interact to the comment. + let tagLink = { + tag, + assigned_by: user.id, + created_at: new Date() + }; + + // If the tag was found, we need to ensure that the current user can indeed + // modify this tag on the comment. + if (tag) { + + // If the tag has roles defined, and the current user has at least one of + // the required roles, then modify the tag without checking for ownership. + if (tag.permissions && tag.permissions.roles && tag.permissions.roles.some((role) => user.roles.include(role))) { + return {tagLink, ownership: false}; + } + + // If the permissions allow for self assignment, then ensure that the query + // is compose with that in mind. + if (tag.permissions && tag.permissions.self) { + + // Otherwise, we assume that we have to check to see that the user indeed + // owns the resource before allowing the tag to get modified. + return {tagLink, ownership: true}; + } + + throw errors.ErrNotAuthorized; + } + + // Only admin/moderators can modify unique tags, these are tags that are not + // in the global list. + if (!(user.hasRoles('ADMIN') || user.hasRoles('MODERATOR'))) { + throw errors.ErrNotAuthorized; + } + + // Generate the tag in the event now that we have to create the tag for this + // specific comment. + tagLink = TagsService.newTagLink(user, {name, item_type}); + + // Actually modify the tag on the model. + return {tagLink, ownership: false}; + } + + static newTag({name, item_type}) { + return { + name, + permissions: { + public: true, + self: false, + roles: [] + }, + models: [item_type], + created_at: new Date() + }; + } + + /** + * Creates a new TagLink based on the input user and the tag data. + */ + static newTagLink(user, tag) { + return { + tag: TagsService.newTag(tag), + assigned_by: user.id, + created_at: new Date() + }; } /** diff --git a/test/server/graph/mutations/addTag.js b/test/server/graph/mutations/addTag.js index e6639d49c..17e268cea 100644 --- a/test/server/graph/mutations/addTag.js +++ b/test/server/graph/mutations/addTag.js @@ -12,7 +12,7 @@ describe('graph.mutations.addTag', () => { let comment, asset; beforeEach(async () => { await SettingsService.init(); - + asset = new AssetModel({url: 'http://new.test.com/'}); await asset.save();