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/README.md b/plugins/talk-plugin-profile-data/README.md index 17f90c666..1d019a79b 100644 --- a/plugins/talk-plugin-profile-data/README.md +++ b/plugins/talk-plugin-profile-data/README.md @@ -20,4 +20,5 @@ their profile tab in the comment stream. Once clicked, an email will be sent that contains a download link. Only one link can be generated every 7 days, and the link will be valid for 24 hours. -The downloaded zip file will contain the users comments in a CSV format. +The downloaded zip file will contain all the users comments in a CSV format +including those that have been rejected, withheld, or still in premod. 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 00061c173..2a2e0161a 100644 --- a/plugins/talk-plugin-profile-data/server/router.js +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -20,14 +20,15 @@ async function verifyDownloadToken( // loadCommentsBatch will load a batch of the comments and write them to the // stream. -async function loadCommentsBatch(ctx, csv, variables = {}) { +async function loadCommentsBatch(ctx, csv, variables) { let result = await ctx.graphql( ` - query GetMyComments($cursor: Cursor) { - me { + query GetMyComments($userID: ID!, $cursor: Cursor) { + user(id: $userID) { comments(query: { limit: 100, - cursor: $cursor + cursor: $cursor, + statuses: null }) { hasNextPage endCursor @@ -50,7 +51,7 @@ async function loadCommentsBatch(ctx, csv, variables = {}) { throw result.errors; } - for (const comment of get(result, 'data.me.comments.nodes', [])) { + for (const comment of get(result, 'data.user.comments.nodes', [])) { csv.write([ comment.id, moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'), @@ -60,12 +61,12 @@ async function loadCommentsBatch(ctx, csv, variables = {}) { ]); } - return pick(result.data.me.comments, ['hasNextPage', 'endCursor']); + return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']); } // loadComments will load batches of the comments and write them to the csv // stream. Once the comments have finished writing, it will close the stream. -async function loadComments(ctx, archive, latestContentDate) { +async function loadComments(ctx, userID, archive, latestContentDate) { // Create all the csv writers that'll write the data to the archive. const csv = stringify(); @@ -78,12 +79,14 @@ async function loadComments(ctx, archive, latestContentDate) { // from the token. let connection = await loadCommentsBatch(ctx, csv, { cursor: latestContentDate, + userID, }); // As long as there's more comments, keep paginating. while (connection.hasNextPage) { connection = await loadCommentsBatch(ctx, csv, { cursor: connection.endCursor, + userID, }); } @@ -98,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. @@ -120,7 +133,7 @@ module.exports = router => { return; } - const { connectors: { services: { Users } } } = req.context; + const { connectors: { graph: { Context }, errors } } = req.context; try { // Pull the userID and the date that the token was issued out of the @@ -130,25 +143,31 @@ module.exports = router => { token ); + // Create a system context used to get all comments for that user. + const ctx = Context.forSystem(); + + // Get the current user's username. We need it for the generated filenames. + const result = await ctx.graphql( + `query GetUser($userID: ID!) { + user(id: $userID) { username } + }`, + { userID } + ); + if (result.errors) { + throw result.errors; + } + + const user = get(result, 'data.user'); + if (!user) { + throw new errors.ErrNotFound(); + } + // Unpack the date that the token was issued, and use it as a source for the // earliest comment we should include in the download. const latestContentDate = new Date(iat * 1000); - // Grab the user that we're generating the export from. We'll use it to - // create a new context. - const user = await Users.findById(userID); - - // Base a new context off of the new user. - const ctx = req.context.masqueradeAs(user); - - // Get the current user's username. We need it for the generated filenames. - const result = await ctx.graphql('{ me { username } }'); - if (result.errors) { - throw result.errors; - } - const username = get(result, 'data.me.username'); - // Generate the filename of the file that the user will download. + const username = get(user, 'username'); const filename = `talk-${kebabCase(username)}-${kebabCase( moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss') )}.zip`; @@ -167,7 +186,7 @@ module.exports = router => { archive.pipe(res); // Load the comments csv up with the user's comments. - await loadComments(ctx, archive, latestContentDate); + await loadComments(ctx, userID, archive, latestContentDate); // Mark the end of adding files, no more files can be added after this. Once // all the stream readers have finished writing, and have closed, the 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 } diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml index 6637c6958..d1059d470 100644 --- a/plugins/talk-plugin-profile-data/translations.yml +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -1,7 +1,7 @@ en: download_landing: download_your_account: "Download Your Comment History" - download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma seperated value (or .csv) file that you can easily import into your favorite spreadsheet application." + download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application." all_information_included: "For each of your comments the following information is included:" information_included: date: "When you wrote the comment"