From 333ef9837884ae9ec7b826e01b1593ed3386ec2e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 9 Apr 2018 15:58:31 -0600 Subject: [PATCH] Added new plugin --- .gitignore | 1 + docs/source/_data/plugins.yml | 5 + .../source/plugin/talk-plugin-profile-data.md | 1 + plugins/talk-plugin-profile-data/README.md | 21 ++ .../client/.eslintrc.json | 3 + .../components/DownloadCommentHistory.css | 12 + .../components/DownloadCommentHistory.js | 74 +++++ .../containers/DownloadCommentHistory.js | 39 +++ .../client/graphql.js | 18 + .../talk-plugin-profile-data/client/index.js | 11 + .../client/mutations.js | 19 ++ .../client/translations.yml | 12 + plugins/talk-plugin-profile-data/index.js | 307 ++++++++++++++++++ plugins/talk-plugin-profile-data/package.json | 12 + .../server/emails/download.html.ejs | 1 + .../server/emails/download.txt.ejs | 3 + .../server/views/download.ejs | 47 +++ .../talk-plugin-profile-data/translations.yml | 10 + 18 files changed, 596 insertions(+) create mode 120000 docs/source/plugin/talk-plugin-profile-data.md create mode 100644 plugins/talk-plugin-profile-data/README.md create mode 100644 plugins/talk-plugin-profile-data/client/.eslintrc.json create mode 100644 plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css create mode 100644 plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js create mode 100644 plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js create mode 100644 plugins/talk-plugin-profile-data/client/graphql.js create mode 100644 plugins/talk-plugin-profile-data/client/index.js create mode 100644 plugins/talk-plugin-profile-data/client/mutations.js create mode 100644 plugins/talk-plugin-profile-data/client/translations.yml create mode 100644 plugins/talk-plugin-profile-data/index.js create mode 100644 plugins/talk-plugin-profile-data/package.json create mode 100644 plugins/talk-plugin-profile-data/server/emails/download.html.ejs create mode 100644 plugins/talk-plugin-profile-data/server/emails/download.txt.ejs create mode 100644 plugins/talk-plugin-profile-data/server/views/download.ejs create mode 100644 plugins/talk-plugin-profile-data/translations.yml diff --git a/.gitignore b/.gitignore index 1235e0db0..e8cb92653 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ plugins/* !plugins/talk-plugin-offtopic !plugins/talk-plugin-permalink !plugins/talk-plugin-profile-settings +!plugins/talk-plugin-profile-data !plugins/talk-plugin-remember-sort !plugins/talk-plugin-respect !plugins/talk-plugin-slack-notifications diff --git a/docs/source/_data/plugins.yml b/docs/source/_data/plugins.yml index 4c3621fcb..a5621b0d6 100644 --- a/docs/source/_data/plugins.yml +++ b/docs/source/_data/plugins.yml @@ -81,6 +81,11 @@ description: Shows a Link button on comments for direct-linking to a comment. tags: - default +- name: talk-plugin-profile-data + description: Enables users to manage their own data within Talk. + tags: + - default + - gdpr - name: talk-plugin-remember-sort description: Remembers the sort selection made by a user. - name: talk-plugin-respect diff --git a/docs/source/plugin/talk-plugin-profile-data.md b/docs/source/plugin/talk-plugin-profile-data.md new file mode 120000 index 000000000..022085ed6 --- /dev/null +++ b/docs/source/plugin/talk-plugin-profile-data.md @@ -0,0 +1 @@ +../../../plugins/talk-plugin-profile-data/README.md \ No newline at end of file diff --git a/plugins/talk-plugin-profile-data/README.md b/plugins/talk-plugin-profile-data/README.md new file mode 100644 index 000000000..fe10a817b --- /dev/null +++ b/plugins/talk-plugin-profile-data/README.md @@ -0,0 +1,21 @@ +--- +title: talk-plugin-profile-data +layout: plugin +plugin: + name: talk-plugin-profile-data + provides: + - Client + - Server +--- + +Provides a series of profile data management utilities to users via their +profile tab. + +## Download My Profile + +Enables the ability for users to download their profile data in a zip file from +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. diff --git a/plugins/talk-plugin-profile-data/client/.eslintrc.json b/plugins/talk-plugin-profile-data/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css new file mode 100644 index 000000000..55ea39969 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css @@ -0,0 +1,12 @@ +.button { + margin: 0; + + i { + font-size: inherit; + vertical-align: sub; + } +} + +.most_recent { + color: #808080; +} diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js new file mode 100644 index 000000000..06fa1fa89 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { t } from 'plugin-api/beta/client/services'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import styles from './DownloadCommentHistory.css'; + +export const readableDuration = durAsHours => { + const durAsDays = Math.ceil(durAsHours / 24); + + return durAsHours > 23 + ? durAsDays > 1 + ? t('download_request.days', durAsDays) + : t('download_request.day', durAsDays) + : durAsHours > 1 + ? t('download_request.hours', durAsHours) + : t('download_request.hour', durAsHours); +}; + +class DownloadCommentHistory extends Component { + static propTypes = { + requestDownloadLink: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + }; + + render() { + const { + root: { me: { lastAccountDownload } }, + requestDownloadLink, + } = this.props; + + const now = new Date(); + const lastAccountDownloadDate = + lastAccountDownload && new Date(lastAccountDownload); + const hoursLeft = lastAccountDownloadDate + ? Math.ceil( + 7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6 + ) + : 0; + const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0; + + return ( +
+

{t('download_request.section_title')}

+

+ {t('download_request.you_will_get_a_copy')}{' '} + {t('download_request.download_rate')}. +

+ {lastAccountDownloadDate && ( +

+ {t('download_request.most_recent_request')}:{' '} + {lastAccountDownloadDate.toLocaleString()} +

+ )} + {canRequestDownload ? ( + + ) : ( + + )} +
+ ); + } +} + +export default DownloadCommentHistory; diff --git a/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js new file mode 100644 index 000000000..96dbf6975 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose, gql } from 'react-apollo'; +import DownloadCommentHistory from '../components/DownloadCommentHistory'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import { withRequestDownloadLink } from '../mutations'; + +class DownloadCommentHistoryContainer extends Component { + static propTypes = { + requestDownloadLink: PropTypes.func.isRequired, + root: PropTypes.object.isRequired, + }; + + render() { + return ( + + ); + } +} + +const enhance = compose( + withFragments({ + root: gql` + fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery { + __typename + me { + id + lastAccountDownload + } + } + `, + }), + withRequestDownloadLink +); + +export default enhance(DownloadCommentHistoryContainer); diff --git a/plugins/talk-plugin-profile-data/client/graphql.js b/plugins/talk-plugin-profile-data/client/graphql.js new file mode 100644 index 000000000..b24c31568 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/graphql.js @@ -0,0 +1,18 @@ +import update from 'immutability-helper'; + +export default { + mutations: { + DownloadCommentHistory: () => ({ + updateQueries: { + CoralEmbedStream_Profile: previousData => + update(previousData, { + me: { + lastAccountDownload: { + $set: new Date().toISOString(), + }, + }, + }), + }, + }), + }, +}; diff --git a/plugins/talk-plugin-profile-data/client/index.js b/plugins/talk-plugin-profile-data/client/index.js new file mode 100644 index 000000000..fee9f2129 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/index.js @@ -0,0 +1,11 @@ +import DownloadCommentHistory from './containers/DownloadCommentHistory'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + profileSettings: [DownloadCommentHistory], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-profile-data/client/mutations.js b/plugins/talk-plugin-profile-data/client/mutations.js new file mode 100644 index 000000000..a370c9ff0 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/mutations.js @@ -0,0 +1,19 @@ +import { withMutation } from 'plugin-api/beta/client/hocs'; +import { gql } from 'react-apollo'; + +export const withRequestDownloadLink = withMutation( + gql` + mutation DownloadCommentHistory { + requestDownloadLink { + errors { + translation_key + } + } + } + `, + { + props: ({ mutate }) => ({ + requestDownloadLink: () => mutate({ variables: {} }), + }), + } +); diff --git a/plugins/talk-plugin-profile-data/client/translations.yml b/plugins/talk-plugin-profile-data/client/translations.yml new file mode 100644 index 000000000..ee6dc4e51 --- /dev/null +++ b/plugins/talk-plugin-profile-data/client/translations.yml @@ -0,0 +1,12 @@ +en: + download_request: + section_title: "Download My Comment History" + you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make" + download_rate: "one download request every 7 days" + most_recent_request: "Your most recent request" + request: "Request Comment History" + rate_limit: "You can submit another Comment History request in {0}" + hours: "{0} hours" + days: "{0} days" + hour: "{0} hour" + day: "{0} day" diff --git a/plugins/talk-plugin-profile-data/index.js b/plugins/talk-plugin-profile-data/index.js new file mode 100644 index 000000000..6990d00ad --- /dev/null +++ b/plugins/talk-plugin-profile-data/index.js @@ -0,0 +1,307 @@ +const express = require('express'); +const path = require('path'); +const { get, pick, kebabCase } = require('lodash'); +const moment = require('moment'); +const uuid = require('uuid/v4'); +const archiver = require('archiver'); +const stringify = require('csv-stringify'); + +const DOWNLOAD_LINK_SUBJECT = 'download_link'; + +async function verifyDownloadToken( + { connectors: { services: { Users } } }, + token +) { + const jwt = await Users.verifyToken(token, { + subject: DOWNLOAD_LINK_SUBJECT, + }); + + return jwt; +} + +async function sendDownloadLink({ + user, + 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'); + + // Check that the user has not already requested a download within the last + // 7 days. + const attempts = await downloadLinkLimiter.get(user.id); + if (attempts && attempts >= 1) { + throw errors.ErrMaxRateLimit; + } + + // Check if the lastAccountDownload time is within 7 days. + if ( + user.lastAccountDownload && + moment(user.lastAccountDownload) + .add(7, 'days') + .isAfter(moment()) + ) { + throw errors.ErrMaxRateLimit; + } + + // The account currently does not have a download link, let's record the + // download. This will throw an error if a race ocurred and we should stop + // 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 } + ); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'download', + locals: { + token, + }, + subject: I18n.t('email.download.subject'), + }); + + // Amend the lastAccountDownload on the user. + await User.update( + { id: user.id }, + { $set: { 'metadata.lastAccountDownload': new Date() } } + ); +} + +// loadCommentsBatch will load a batch of the comments and write them to the +// stream. +async function loadCommentsBatch(ctx, csv, variables = {}) { + let result = await ctx.graphql( + ` + query GetMyComments($cursor: Cursor) { + me { + comments(query: { + limit: 100, + cursor: $cursor + }) { + hasNextPage + endCursor + nodes { + id + created_at + asset { + url + } + body + url + } + } + } + } + `, + variables + ); + if (result.errors) { + throw result.errors; + } + + for (const comment of get(result, 'data.me.comments.nodes', [])) { + csv.write([ + comment.id, + moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'), + get(comment, 'asset.url'), + comment.url, + comment.body, + ]); + } + + return pick(result.data.me.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) { + // Create all the csv writers that'll write the data to the archive. + const csv = stringify(); + + // Add all the streams as files to the archive. + archive.append(csv, { name: 'talk-export/my_comments.csv' }); + + csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']); + + // Load the first batch's comments from the latest date that we were provided + // from the token. + let connection = await loadCommentsBatch(ctx, csv, { + cursor: latestContentDate, + }); + + // As long as there's more comments, keep paginating. + while (connection.hasNextPage) { + connection = await loadCommentsBatch(ctx, csv, { + cursor: connection.endCursor, + }); + } + + csv.end(); +} + +const router = router => { + // /account/download will render the download page. + router.get('/account/download', (req, res) => { + res.render(path.join(__dirname, 'server/views/download')); + }); + + // /api/v1/account/download will send back a zipped archive of the users + // account. + router.post( + '/api/v1/account/download', + express.urlencoded({ extended: false }), + async (req, res, next) => { + const { token = null, check = false } = req.body; + + if (check) { + // This request is checking to see if the token is valid. + try { + // Verify the token + await verifyDownloadToken(req.context, token); + } catch (err) { + // Log out the error, slurp it and send out the predefined error to the + // error handler. + console.error(err); + return next(new Error('invalid token')); + } + + res.status(204).end(); + + // Don't continue to pass it onto the next middleware, as we've only been + // asked to verify the token. + return; + } + + const { connectors: { services: { Users } } } = req.context; + + try { + // Pull the userID and the date that the token was issued out of the + // provided token. + const { user: userID, iat } = await verifyDownloadToken( + req.context, + token + ); + + // 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 filename = `talk-${kebabCase(username)}-${kebabCase( + moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss') + )}.zip`; + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename=${filename}`, + }); + + // Create the zip archive we'll use to write all the exported files to. + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + + // Pipe this to the response writer directly. + archive.pipe(res); + + // Load the comments csv up with the user's comments. + await loadComments(ctx, 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 + // archiver will close which will finish the HTTP request. + archive.finalize(); + } catch (err) { + return next(err); + } + } + ); +}; + +const typeDefs = ` + type User { + + # lastAccountDownload is the date that the user last requested a comment + # download. + lastAccountDownload: Date + } + + type RequestDownloadLinkResponse 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 + } +`; + +const connect = connectors => { + const { services: { Mailer } } = connectors; + + // Setup the mail templates. + ['txt', 'html'].forEach(format => { + Mailer.templates.register( + path.join(__dirname, 'server', 'emails', `download.${format}.ejs`), + 'download', + format + ); + }); +}; + +module.exports = { + mutators: ctx => ({ + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + }, + }), + router, + connect, + typeDefs, + translations: path.join(__dirname, 'translations.yml'), + resolvers: { + RootMutation: { + requestDownloadLink: async (_, args, { mutators: { User } }) => { + await User.requestDownloadLink(); + }, + }, + User: { + lastAccountDownload: (user, args, { user: currentUser }) => { + // If the current user is not the requesting user, and the user is not + // an admin, return nothing. + if (user.id !== currentUser.id && user.role !== 'ADMIN') { + return null; + } + + return get(user, 'metadata.lastAccountDownload', null); + }, + }, + }, +}; diff --git a/plugins/talk-plugin-profile-data/package.json b/plugins/talk-plugin-profile-data/package.json new file mode 100644 index 000000000..ed5a29050 --- /dev/null +++ b/plugins/talk-plugin-profile-data/package.json @@ -0,0 +1,12 @@ +{ + "name": "@coralproject/talk-plugin-profile-data", + "version": "1.0.0", + "description": "Adds profile data management for Talk", + "main": "index.js", + "license": "Apache-2.0", + "private": false, + "dependencies": { + "archiver": "^2.1.1", + "csv-stringify": "^3.0.0" + } +} diff --git a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs new file mode 100644 index 000000000..c6aeb8e1e --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs @@ -0,0 +1 @@ +

<%= t('email.download.download_link_ready') %> <%= 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 new file mode 100644 index 000000000..4f719bc06 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -0,0 +1,3 @@ +<%= t('email.download.download_link_ready') %> + + <%= BASE_URL %>account/download#<%= token %> diff --git a/plugins/talk-plugin-profile-data/server/views/download.ejs b/plugins/talk-plugin-profile-data/server/views/download.ejs new file mode 100644 index 000000000..53ee8a4e9 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/views/download.ejs @@ -0,0 +1,47 @@ + + + + + <%= t('download_landing.download_your_account') %> + + + <%- include(root + '/partials/head') %> + + +
+
+
+ <%= t('download_landing.click_to_download') %> + +
+
+ + + + diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml new file mode 100644 index 000000000..42afe2313 --- /dev/null +++ b/plugins/talk-plugin-profile-data/translations.yml @@ -0,0 +1,10 @@ +en: + download_landing: + download_your_account: "Download Your Account" + click_to_download: "Click below to download your account" + confirm: "Download" + email: + download: + subject: "Your download link is ready" # TODO: replace + download_link_ready: "Your download link is now ready, visit the following to download an archive of your account:" # TODO: replace + download_archive: "Download Archive" # TODO: replace