From 3129a6615f747f2af4ceeaf78873525fe455847d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Apr 2018 09:37:20 -0600 Subject: [PATCH] added direct download mutation --- graph/connectors.js | 4 ++ .../server/emails/download.html.ejs | 2 +- .../server/emails/download.txt.ejs | 2 +- .../server/mutators.js | 71 ++++++++++++++----- .../server/resolvers.js | 3 + .../talk-plugin-profile-data/server/router.js | 14 +++- .../server/typeDefs.graphql | 14 ++++ 7 files changed, 87 insertions(+), 23 deletions(-) diff --git a/graph/connectors.js b/graph/connectors.js index 3ba71fbfb..3ad3024ea 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -10,6 +10,9 @@ const secrets = require('../secrets'); // Errors. const errors = require('../errors'); +// URLs. +const url = require('../url'); + // Graph. const { getBroker } = require('./subscriptions/broker'); const { getPubsub } = require('./subscriptions/pubsub'); @@ -58,6 +61,7 @@ const defaultConnectors = { errors, config, secrets, + url, models: { Action, Asset, diff --git a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs index 00b6ad372..974ea71f4 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs @@ -1 +1 @@ -

<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>

+

<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>

diff --git a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs index 138ea1088..940d62bad 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> - <%= BASE_URL %>account/download#<%= token %> + <%= downloadLandingURL %> diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js index 5a897a2e1..09c9445ef 100644 --- a/plugins/talk-plugin-profile-data/server/mutators.js +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -1,17 +1,41 @@ const moment = require('moment'); const uuid = require('uuid/v4'); const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors'); +const { URL } = require('url'); + +// generateDownloadLinks will generate a signed set of links for a given user to +// download an archive of their data. +async function generateDownloadLinks(ctx, userID) { + const { connectors: { url: { BASE_URL }, secrets } } = ctx; + + // Generate a token for the download link. + const token = await secrets.jwt.sign( + { user: userID }, + { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } + ); + + // Generate the url that a user can land on. + const downloadLandingURL = new URL('account/download', BASE_URL); + downloadLandingURL.hash = token; + + // Generate the url that the API calls to download the actual zip. + const downloadFileURL = new URL('api/v1/account/download', BASE_URL); + downloadFileURL.searchParams.set('token', token); + + return { + downloadLandingURL: downloadLandingURL.href, + downloadFileURL: downloadFileURL.href, + }; +} + +async function sendDownloadLink(ctx) { + const { + user, + loaders: { Settings }, + connectors: { services: { Users, I18n, Limit }, models: { User } }, + } = ctx; -async function sendDownloadLink({ - user, - loaders: { Settings }, - connectors: { - errors, - secrets, - services: { Users, I18n, Limit }, - models: { User }, - }, -}) { // downloadLinkLimiter can be used to limit downloads for the user's data to // once every 7 days. const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d'); @@ -20,7 +44,7 @@ async function sendDownloadLink({ // 7 days. const attempts = await downloadLinkLimiter.get(user.id); if (attempts && attempts >= 1) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // Check if the lastAccountDownload time is within 7 days. @@ -30,7 +54,7 @@ async function sendDownloadLink({ .add(7, 'days') .isAfter(moment()) ) { - throw errors.ErrMaxRateLimit; + throw new ErrMaxRateLimit(); } // The account currently does not have a download link, let's record the @@ -38,21 +62,18 @@ async function sendDownloadLink({ // now. await downloadLinkLimiter.test(user.id); - // Generate a token for the download link. - const token = await secrets.jwt.sign( - { user: user.id }, - { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } - ); - const now = new Date(); + // Generate the download links. + const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id); + const { organizationName } = await Settings.load('organizationName'); // Send the download link via the user's attached email account. await Users.sendEmail(user, { template: 'download', locals: { - token, + downloadLandingURL, organizationName, now, }, @@ -66,8 +87,20 @@ async function sendDownloadLink({ ); } +// downloadUser will return the download file url that can be used to directly +// download the archive. +async function downloadUser(ctx, userID) { + const { downloadFileURL } = await generateDownloadLinks(ctx, userID); + return downloadFileURL; +} + module.exports = ctx => ({ User: { requestDownloadLink: () => sendDownloadLink(ctx), + download: + // Only ADMIN users can execute an account download. + ctx.user && ctx.user.role === 'ADMIN' + ? userID => downloadUser(ctx, userID) + : () => 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..7a261772f 100644 --- a/plugins/talk-plugin-profile-data/server/resolvers.js +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -5,6 +5,9 @@ module.exports = { requestDownloadLink: async (_, args, { mutators: { User } }) => { await User.requestDownloadLink(); }, + downloadUser: async (_, { id }, { mutators: { User } }) => ({ + archiveURL: await User.download(id), + }), }, User: { lastAccountDownload: (user, args, { user: currentUser }) => { diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js index 2b0628753..2a2e0161a 100644 --- a/plugins/talk-plugin-profile-data/server/router.js +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -101,11 +101,21 @@ module.exports = router => { // /api/v1/account/download will send back a zipped archive of the users // account. - router.post( + router.all( '/api/v1/account/download', express.urlencoded({ extended: false }), async (req, res, next) => { - const { token = null, check = false } = req.body; + let { token = null, check = false } = req.body; + + if (!token) { + // If the token wasn't found in the body, then we should check the query + // to see if it was passed that way. + token = req.query.token; + } + + if (!token) { + return res.status(400).end(); + } if (check) { // This request is checking to see if the token is valid. diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql index 0029111c3..aa3d78adc 100644 --- a/plugins/talk-plugin-profile-data/server/typeDefs.graphql +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -11,9 +11,23 @@ type RequestDownloadLinkResponse implements Response { errors: [UserError!] } +type DownloadUserResponse implements Response { + + # archiveURL is the link that can be used within the next 1 hour to download a + # users archive. + archiveURL: String + + # 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 + + # downloadUser will provide an account download for the indicated User. This + # mutation requires the ADMIN role. + downloadUser(id: ID!): DownloadUserResponse }