From 2adec1bc344137ba2383b62820c70c30542658ec Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 18 Apr 2018 13:13:41 -0600 Subject: [PATCH] added deletion graph endpoints --- graph/mutators/user.js | 5 +- perms/constants/mutation.js | 2 +- perms/reducers/mutation.js | 1 + plugin-api/beta/server/getReactionConfig.js | 15 +--- plugins/talk-plugin-profile-data/package.json | 1 + .../server/connect.js | 88 ++++++++++++++++++- .../talk-plugin-profile-data/server/errors.js | 27 +++++- .../server/mutators.js | 64 ++++++++++++-- .../server/resolvers.js | 18 ++++ .../server/typeDefs.graphql | 38 ++++++++ 10 files changed, 235 insertions(+), 24 deletions(-) diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 6c673111b..0343e1f2e 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -8,7 +8,7 @@ const { SET_USER_BAN_STATUS, SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, - DELETE_USER, + DELETE_OTHER_USER, } = require('../../perms/constants'); const setUserUsernameStatus = async (ctx, id, status) => { @@ -155,6 +155,7 @@ module.exports = ctx => { setUsername: () => Promise.reject(new ErrNotAuthorized()), stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()), del: () => Promise.reject(new ErrNotAuthorized()), + delSelf: () => Promise.reject(new ErrNotAuthorized()), }, }; @@ -191,7 +192,7 @@ module.exports = ctx => { setUserSuspensionStatus(ctx, id, until, message); } - if (ctx.user.can(DELETE_USER)) { + if (ctx.user.can(DELETE_OTHER_USER)) { mutators.User.del = id => delUser(ctx, id); } } diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index d2ebe73f1..dba126ceb 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -18,5 +18,5 @@ module.exports = { UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS', UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', UPDATE_SETTINGS: 'UPDATE_SETTINGS', - DELETE_USER: 'DELETE_USER', + DELETE_OTHER_USER: 'DELETE_OTHER_USER', }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 73ee7ef28..7e23b274b 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -36,6 +36,7 @@ module.exports = (user, perm) => { case types.UPDATE_USER_ROLES: case types.CREATE_TOKEN: case types.REVOKE_TOKEN: + case types.DELETE_OTHER_USER: return check(user, ['ADMIN']); default: diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index da075d76c..b6e0a5c1d 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -2,24 +2,11 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants'); const { ErrNotFound, ErrAlreadyExists } = require('../../../errors'); const pluralize = require('pluralize'); const sc = require('snake-case'); -// const { CREATE_MONGO_INDEXES } = require('../../../config'); function getReactionConfig(reaction) { + // Ensure that the reaction is a lowercase string. reaction = reaction.toLowerCase(); - // if (CREATE_MONGO_INDEXES) { - // // Create the index on the comment model based on the reaction config. - // CommentModel.collection.createIndex( - // { - // created_at: 1, - // [`action_counts.${sc(reaction)}`]: 1, - // }, - // { - // background: true, - // } - // ); - // } - const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); const REACTION = reaction.toUpperCase(); diff --git a/plugins/talk-plugin-profile-data/package.json b/plugins/talk-plugin-profile-data/package.json index ed5a29050..4329aba7e 100644 --- a/plugins/talk-plugin-profile-data/package.json +++ b/plugins/talk-plugin-profile-data/package.json @@ -7,6 +7,7 @@ "private": false, "dependencies": { "archiver": "^2.1.1", + "cron": "^1.3.0", "csv-stringify": "^3.0.0" } } diff --git a/plugins/talk-plugin-profile-data/server/connect.js b/plugins/talk-plugin-profile-data/server/connect.js index f09b2dc7b..eab76828f 100644 --- a/plugins/talk-plugin-profile-data/server/connect.js +++ b/plugins/talk-plugin-profile-data/server/connect.js @@ -1,7 +1,13 @@ const path = require('path'); +const moment = require('moment'); +const { CronJob } = require('cron'); module.exports = connectors => { - const { services: { Mailer } } = connectors; + const { + services: { Mailer }, + models: { User }, + graph: { Context }, + } = connectors; // Setup the mail templates. ['txt', 'html'].forEach(format => { @@ -11,4 +17,84 @@ module.exports = connectors => { format ); }); + + // Setup the cron job that will scan for accounts to delete every 30 minutes. + new CronJob({ + cronTime: '0,30 * * * *', + timeZone: 'America/New_York', + start: true, + runOnInit: true, + onTick: async () => { + // Create the context we'll use to perform user deletions. + const ctx = Context.forSystem(); + + // rescheduledDeletionDate is the date in the future that we'll set the + // user's account to be deleted on if this delete fails. + const rescheduledDeletionDate = moment() + .add(1, 'hours') + .toDate(); + + try { + // Keep running for each user we can pull. + while (true) { + // We'll find any user that has an account deletion date before now + // and update the user such that their deletion time is 1 hour from + // now. This will ensure that only one instance can pull the same + // user at a time, and if the delete fails, it will be retried an + // hour from now. If the deletion was successful, well, it can't be + // retried because the reference to the scheduledDeletionDate will + // get deleted along with the user. + const user = await User.findOneAndUpdate( + { + 'metadata.scheduledDeletionDate': { $lte: new Date() }, + }, + { + $set: { + 'metadata.scheduledDeletionDate': rescheduledDeletionDate, + }, + } + ); + if (!user) { + // There are no more users that meet the search criteria! We're + // done! + ctx.log.info('no more users are scheduled for deletion'); + break; + } + + ctx.log.info( + { + userID: user.id, + scheduledDeletionDate: user.metadata.scheduledDeletionDate, + }, + 'starting user delete' + ); + + // Delete the user using the existing graph call. + const { data, errors } = await ctx.graphql( + ` + mutation DeleteUser($user_id: ID!) { + delUser(id: $user_id) { + errors { + translation_key + } + } + } + `, + { user_id: user.id } + ); + if (errors) { + throw errors; + } + + if (data.errors) { + throw data.errors; + } + + ctx.log.info({ userID: user.id }, 'user was deleted successfully'); + } + } catch (err) { + ctx.log.error({ err }, 'could not handle user deletions'); + } + }, + }); }; diff --git a/plugins/talk-plugin-profile-data/server/errors.js b/plugins/talk-plugin-profile-data/server/errors.js index 6261ebe89..f205c1f6e 100644 --- a/plugins/talk-plugin-profile-data/server/errors.js +++ b/plugins/talk-plugin-profile-data/server/errors.js @@ -15,4 +15,29 @@ class ErrDownloadToken extends TalkError { } } -module.exports = { ErrDownloadToken }; +// ErrDeletionAlreadyScheduled is returned when a user requests that their +// account get deleted when their account is already scheduled for deletion. +class ErrDeletionAlreadyScheduled extends TalkError { + constructor() { + super('Deletion is already scheduled', { + translation_key: 'DELETION_ALREADY_SCHEDULED', + status: 400, + }); + } +} +// ErrDeletionNotScheduled is returned when a user requests that their +// account deletion to be canceled when it was not scheduled for deletion. +class ErrDeletionNotScheduled extends TalkError { + constructor() { + super('Deletion was not scheduled', { + translation_key: 'DELETION_NOT_SCHEDULED', + status: 400, + }); + } +} + +module.exports = { + ErrDownloadToken, + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +}; diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js index 5a897a2e1..dafc7dc42 100644 --- a/plugins/talk-plugin-profile-data/server/mutators.js +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -1,6 +1,12 @@ +const { get } = require('lodash'); const moment = require('moment'); const uuid = require('uuid/v4'); const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +} = require('./errors'); +const { ErrNotAuthorized } = require('errors'); async function sendDownloadLink({ user, @@ -66,8 +72,56 @@ async function sendDownloadLink({ ); } -module.exports = ctx => ({ - User: { - requestDownloadLink: () => sendDownloadLink(ctx), - }, -}); +// requestDeletion will schedule the current user to have their account deleted +// by setting the `scheduledDeletionDate` on the user 12 hours from now. +async function requestDeletion({ user, connectors: { models: { User } } }) { + // Ensure the user doesn't already have a deletion scheduled. + if (get(user, 'metadata.scheduledDeletionDate')) { + throw new ErrDeletionAlreadyScheduled(); + } + + // Get the date in the future 12 hours from now. + const scheduledDeletionDate = moment() + .add(12, 'hours') + .toDate(); + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { $set: { 'metadata.scheduledDeletionDate': scheduledDeletionDate } } + ); + + return scheduledDeletionDate; +} + +// cancelDeletion will unset the scheduled deletion date on the user account +// that is used to indicate that the user was scheduled for deletion. +async function cancelDeletion({ user, connectors: { models: { User } } }) { + // Ensure the user has a deletion scheduled. + if (!get(user, 'metadata.scheduledDeletionDate', null)) { + throw new ErrDeletionNotScheduled(); + } + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { $unset: { 'metadata.scheduledDeletionDate': 1 } } + ); +} + +module.exports = ctx => + ctx.user + ? { + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + requestDeletion: () => requestDeletion(ctx), + cancelDeletion: () => cancelDeletion(ctx), + }, + } + : { + User: { + requestDownloadLink: () => Promise.reject(new ErrNotAuthorized()), + requestDeletion: () => Promise.reject(new ErrNotAuthorized()), + cancelDeletion: () => Promise.reject(new ErrNotAuthorized()), + }, + }; diff --git a/plugins/talk-plugin-profile-data/server/resolvers.js b/plugins/talk-plugin-profile-data/server/resolvers.js index 691907f8c..a90c617e2 100644 --- a/plugins/talk-plugin-profile-data/server/resolvers.js +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -5,6 +5,12 @@ module.exports = { requestDownloadLink: async (_, args, { mutators: { User } }) => { await User.requestDownloadLink(); }, + requestAccountDeletion: async (_, args, { mutators: { User } }) => ({ + scheduledDeletionDate: await User.requestDeletion(), + }), + cancelAccountDeletion: async (_, args, { mutators: { User } }) => { + await User.cancelDeletion(); + }, }, User: { lastAccountDownload: (user, args, { user: currentUser }) => { @@ -16,5 +22,17 @@ module.exports = { return get(user, 'metadata.lastAccountDownload', null); }, + scheduledDeletionDate: (user, args, { user: currentUser }) => { + // If the current user is not the requesting user, and the user is not + // an admin or a moderator, return nothing. + if ( + user.id !== currentUser.id && + !['ADMIN', 'MODERATOR'].includes(user.role) + ) { + return null; + } + + return get(user, 'metadata.scheduledDeletionDate', null); + }, }, }; diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql index 0029111c3..c785fb2cc 100644 --- a/plugins/talk-plugin-profile-data/server/typeDefs.graphql +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -3,17 +3,55 @@ type User { # lastAccountDownload is the date that the user last requested a comment # download. lastAccountDownload: Date + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date } +# RequestDownloadLinkResponse contains the account download errors relating to +# the request for an account download. type RequestDownloadLinkResponse implements Response { # An array of errors relating to the mutation that occurred. errors: [UserError!] } +# RequestAccountDeletionResponse contains the account deletion schedule errors +# relating to schedulding an account for deletion. +type RequestAccountDeletionResponse implements Response { + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# CancelAccountDeletionResponse contains the account deletion errors relating to +# canceling an account deletion that was scheduled. +type CancelAccountDeletionResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + type RootMutation { # requestDownloadLink will request a download link be sent to the primary # users email address. requestDownloadLink: RequestDownloadLinkResponse + + # requestAccountDeletion requests that the current account get deleted. The + # mutation will return the date that the account is scheduled to be deleted. + requestAccountDeletion: RequestAccountDeletionResponse + + # cancelAccountDeletion will cancel a pending account deletion that was + # previously scheduled. + cancelAccountDeletion: CancelAccountDeletionResponse }