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') %>
+
+
+
+
+
+
+
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