diff --git a/routes/api/graph/loaders.js b/routes/api/graph/loaders.js index f5f0046a0..0bf11fcc8 100644 --- a/routes/api/graph/loaders.js +++ b/routes/api/graph/loaders.js @@ -1,5 +1,8 @@ const DataLoader = require('dataloader'); const _ = require('lodash'); +const url = require('url'); +const errors = require('../../../errors'); +const scraper = require('../../../services/scraper'); const Comment = require('../../../models/comment'); const User = require('../../../models/user'); @@ -72,7 +75,7 @@ const singleJoinBy = (ids, key) => (items) => { * Retrieves assets by an array of ids. * @param {Array} ids array of ids to lookup */ -const genAssetByID = (ids) => Asset.find({ +const genAssetsByID = (ids) => Asset.find({ id: { $in: ids } @@ -111,6 +114,32 @@ const genCommentsByParentID = (ids) => Comment.find({ } }).then(arrayJoinBy(ids, 'parent_id')); +/** + * This endpoint find or creates an asset at the given url when it is loaded. + * @param {String} asset_url the url passed in from the query + * @returns {Promise} resolves to the asset + */ +const findOrCreateAssetByURL = (asset_url) => { + + // Verify that the asset_url is parsable. + let parsed_asset_url = url.parse(asset_url); + if (!parsed_asset_url.protocol) { + return Promise.reject(errors.ErrInvalidAssetURL); + } + + return Asset.findOrCreateByUrl(asset_url) + .then((asset) => { + + // If the asset wasn't scraped before, scrape it! Otherwise just return + // the asset. + if (!asset.scraped) { + return scraper.create(asset).then(() => asset); + } + + return asset; + }); +}; + /** * Creates a set of loaders based on a GraphQL context. * @param {Object} context the context of the GraphQL request @@ -128,7 +157,12 @@ const createLoaders = (context) => ({ getByID: new DataLoader((ids) => User.findByIdArray(ids)) }, Assets: { - getByID: new DataLoader((ids) => genAssetByID(ids)), + + // TODO: decide whether we want to move these to mutators or not, as in fact + // this operation create a new asset if one isn't found. + getByURL: (url) => findOrCreateAssetByURL(url), + + getByID: new DataLoader((ids) => genAssetsByID(ids)), getAll: new SingletonResolver(() => Asset.find({})) }, Settings: new SingletonResolver(() => Settings.retrieve()) diff --git a/routes/api/graph/mutators.js b/routes/api/graph/mutators.js index e1b65a09e..b56ba8412 100644 --- a/routes/api/graph/mutators.js +++ b/routes/api/graph/mutators.js @@ -13,10 +13,44 @@ const Wordlist = require('../../../services/wordlist'); * @param {String} body body of the comment * @param {String} asset_id asset for the comment * @param {String} parent_id optional parent of the comment - * @param {Object} [wordlist={}] results for the wordlist analysis + * @param {String} [status=null] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = ({user}, {body, asset_id, parent_id = null}, wordlist = {}) => { +const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => { + return Comment.publicCreate({ + body, + asset_id, + parent_id, + status, + author_id: user.id + }); +}; + +/** + * Filters the comment object and outputs wordlist results. + * @param {Object} context graphql context + * @param {String} body body of a comment + * @return {Object} resolves to the wordlist results + */ +const filterNewComment = (context, {body}) => { + + // Create a new instance of the Wordlist. + const wl = new Wordlist(); + + // Load the wordlist and filter the comment content. + return wl.load().then(() => wl.scan('body', body)); +}; + +/** + * This resolves a given comment's status to take into account moderator actions + * are applied. + * @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 + */ +const resolveNewCommentStatus = (context, {asset_id, body}, 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 @@ -55,53 +89,71 @@ const createComment = ({user}, {body, asset_id, parent_id = null}, wordlist = {} }); } - return status.then((status) => Comment.publicCreate({ - body, - asset_id, - parent_id, - status, - author_id: user.id - })) - .then((comment) => { - if (wordlist.suspect) { - return Comment - .addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'}) - .then(() => comment); - } - - return comment; - }); + return status; }; /** - * Filters the comment object and outputs wordlist results. - * @param {Object} context graphql context - * @param {String} body body of a comment - * @return {Object} resolves to the wordlist results + * 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} commentInput the new comment to be created + * @return {Promise} resolves to a new comment */ -const filterNewComment = (context, {body}) => { +const createPublicComment = (context, commentInput) => { - // Create a new instance of the Wordlist. - const wl = new Wordlist(); + // First we filter the comment contents to ensure that we note any validation + // issues. + return filterNewComment(context, commentInput) - // Load the wordlist and filter the comment content. - return wl.load().then(() => wl.scan('body', body)); + // 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. + .then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist) + + // Then we actually create the comment with the new status. + .then((status) => createComment(context, commentInput, status)) + .then((comment) => { + + // If the comment was flagged as being suspect, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + if (wordlist.suspect) { + + // 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 createAction(null, { + item_id: comment.id, + item_type: 'comments', + action_type: 'flag', + metadata: { + field: 'body', + details: 'Matched suspect word filters.' + } + }).then(() => comment); + } + + // Finally, we return the comment. + return comment; + })); }; /** * Creates an action on a item. - * @param {Object} user the user performing the request + * @param {Object} user the user performing the request * @param {String} item_id id of the item to add the action to * @param {String} item_type type of the item * @param {String} action_type type of the action * @return {Promise} resolves to the action created */ -const createAction = ({user}, {item_id, item_type, action_type}) => { +const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => { return Action.insertUserAction({ item_id, item_type, user_id: user.id, - action_type + action_type, + metadata }); }; @@ -120,9 +172,9 @@ const deleteAction = ({user}, {id}) => { /** * Updates a users settings. - * @param {[type]} user [description] - * @param {[type]} bio [description] - * @return {[type]} [description] + * @param {Object} user the user performing the request + * @param {String} bio the new user bio + * @return {Promise} */ const updateUserSettings = ({user}, {bio}) => { return User.updateSettings(user.id, {bio}); @@ -136,9 +188,7 @@ module.exports = (context) => { if (context.user) { return { Comment: { - create: (comment) => filterNewComment(context, comment).then((wordlist) => { - return createComment(context, comment, wordlist); - }) + create: (comment) => createPublicComment(context, comment) }, Action: { create: (action) => createAction(context, action), diff --git a/routes/api/graph/resolvers/index.js b/routes/api/graph/resolvers/index.js index d4de137d5..6cb85a85f 100644 --- a/routes/api/graph/resolvers/index.js +++ b/routes/api/graph/resolvers/index.js @@ -2,8 +2,8 @@ const Action = require('./action'); const ActionSummary = require('./action_summary'); const Asset = require('./asset'); const Comment = require('./comment'); -const Mutation = require('./mutation'); -const Query = require('./Query'); +const RootMutation = require('./root_mutation'); +const RootQuery = require('./root_query'); const User = require('./user'); module.exports = { @@ -11,7 +11,7 @@ module.exports = { ActionSummary, Asset, Comment, - Mutation, - Query, + RootMutation, + RootQuery, User }; diff --git a/routes/api/graph/resolvers/query.js b/routes/api/graph/resolvers/query.js deleted file mode 100644 index 89cbc18c9..000000000 --- a/routes/api/graph/resolvers/query.js +++ /dev/null @@ -1,16 +0,0 @@ -const Query = { - assets(_, args, {loaders}) { - return loaders.Assets.getAll.load(); - }, - asset(_, {id}, {loaders}) { - return loaders.Assets.getByID.load(id); - }, - settings(_, args, {loaders}) { - return loaders.Settings.load(); - }, - me(_, args, {user}) { - return user; - } -}; - -module.exports = Query; diff --git a/routes/api/graph/resolvers/mutation.js b/routes/api/graph/resolvers/root_mutation.js similarity index 88% rename from routes/api/graph/resolvers/mutation.js rename to routes/api/graph/resolvers/root_mutation.js index ef95d0334..bf108671e 100644 --- a/routes/api/graph/resolvers/mutation.js +++ b/routes/api/graph/resolvers/root_mutation.js @@ -1,4 +1,4 @@ -const Mutation = { +const RootMutation = { createComment(_, {asset_id, parent_id, body}, {mutators}) { return mutators.Comment.create({asset_id, parent_id, body}); }, @@ -13,4 +13,4 @@ const Mutation = { } }; -module.exports = Mutation; +module.exports = RootMutation; diff --git a/routes/api/graph/resolvers/root_query.js b/routes/api/graph/resolvers/root_query.js new file mode 100644 index 000000000..4042ddb21 --- /dev/null +++ b/routes/api/graph/resolvers/root_query.js @@ -0,0 +1,25 @@ +const RootQuery = { + assets(_, args, {loaders}) { + return loaders.Assets.getAll.load(); + }, + asset(_, {id = null, url}, {loaders}) { + if (id) { + + // TODO: we may not always have a comment stream here, therefore, when we + // load it, we may also need to create with the url. This may also have to + // move the logic over to the mutators function as an upsert operation + // possibly. + return loaders.Assets.getByID.load(id); + } else { + return loaders.Assets.getByURL(url); + } + }, + settings(_, args, {loaders}) { + return loaders.Settings.load(); + }, + me(_, args, {user}) { + return user; + } +}; + +module.exports = RootQuery; diff --git a/routes/api/graph/typeDefs.js b/routes/api/graph/typeDefs.js index 5bacb1a2b..8e80f37a5 100644 --- a/routes/api/graph/typeDefs.js +++ b/routes/api/graph/typeDefs.js @@ -71,10 +71,12 @@ type Asset { currentUser: User } -type Query { +scalar URL + +type RootQuery { settings: Settings assets: [Asset] - asset(id: ID!): Asset + asset(id: ID, url: URL!): Asset me: User } @@ -94,7 +96,7 @@ input UpdateUserSettingsInput { bio: String! } -type Mutation { +type RootMutation { # creates a comment on the asset. createComment(asset_id: ID!, parent_id: ID, body: String!): Comment @@ -109,8 +111,8 @@ type Mutation { } schema { - query: Query - mutation: Mutation + query: RootQuery + mutation: RootMutation } `];