From ee5316e6efcb99ba8c001845879d826e30524866 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 27 Mar 2018 18:28:14 -0600 Subject: [PATCH 01/44] initial download support --- graph/resolvers/comment.js | 7 +++ graph/typeDefs.graphql | 3 + package.json | 2 + routes/api/v1/account.js | 113 +++++++++++++++++++++++++++++++++++++ yarn.lock | 84 +++++++++++++++++++++++++-- 5 files changed, 204 insertions(+), 5 deletions(-) diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 7005701ec..2e4d9a08d 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,3 +1,4 @@ +const { URL } = require('url'); const { property } = require('lodash'); const { SEARCH_ACTIONS } = require('../../perms/constants'); const { decorateWithTags, decorateWithPermissionCheck } = require('./util'); @@ -55,6 +56,12 @@ const Comment = { editableUntil: editableUntil, }; }, + async url(comment, args, { loaders: { Assets } }) { + const asset = await Assets.getByID.load(comment.asset_id); + const assetURL = new URL(asset.url); + assetURL.searchParams.set('commentId', comment.id); + return assetURL.href; + }, }; // Decorate the Comment type resolver with a tags field. diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 2414faf1f..588ad08dc 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -547,6 +547,9 @@ type Comment { # Indicates if it has a parent hasParent: Boolean + + # url is the permalink to this particular Comment on the Asset. + url: String } # CommentConnection represents a paginable subset of a comment list. diff --git a/package.json b/package.json index 0253c7b0b..4436ec921 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "apollo-server-express": "^1.2.0", "apollo-utilities": "^1.0.3", "app-module-path": "^2.2.0", + "archiver": "^2.1.1", "autoprefixer": "^6.5.2", "babel-cli": "6.26.0", "babel-core": "6.26.0", @@ -92,6 +93,7 @@ "copy-webpack-plugin": "^4.0.0", "cross-spawn": "^5.1.0", "css-loader": "^0.28.5", + "csv-stringify": "^2.0.4", "dataloader": "^1.3.0", "debug": "3.1.0", "dialog-polyfill": "^0.4.9", diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 561134885..84b5e4d0a 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -127,4 +127,117 @@ router.put( } ); +const archiver = require('archiver'); +const stringify = require('csv-stringify'); +const moment = require('moment'); +const { pick, get, kebabCase } = require('lodash'); + +// 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'), + 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, csv) { + csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']); + + // Load the first batch's comments. + let connection = await loadCommentsBatch(ctx, csv); + + // As long as there's more comments, keep paginating. + while (connection.hasNextPage) { + connection = await loadCommentsBatch(ctx, csv, { + cursor: connection.endCursor, + }); + } + + csv.end(); +} + +// /download will send back a zipped archive of the users account. +router.get('/download', authorization.needed(), async (req, res, next) => { + try { + const result = await req.context.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().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); + + // Create all the csv writers that'll write the data to the archive. + const myCommentsCSV = stringify(); + + // Add all the streams as files to the archive. + archive.append(myCommentsCSV, { name: 'my_comments.csv' }); + + // 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(); + + // Load the comments csv up with the user's comments. + await loadComments(req.context, myCommentsCSV); + } catch (err) { + return next(err); + } +}); + module.exports = router; diff --git a/yarn.lock b/yarn.lock index fd501351e..199e329f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -445,6 +445,30 @@ aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" +archiver-utils@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-1.3.0.tgz#e50b4c09c70bf3d680e32ff1b7994e9f9d895174" + dependencies: + glob "^7.0.0" + graceful-fs "^4.1.0" + lazystream "^1.0.0" + lodash "^4.8.0" + normalize-path "^2.0.0" + readable-stream "^2.0.0" + +archiver@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.1.tgz#ff662b4a78201494a3ee544d3a33fe7496509ebc" + dependencies: + archiver-utils "^1.3.0" + async "^2.0.0" + buffer-crc32 "^0.2.1" + glob "^7.0.0" + lodash "^4.8.0" + readable-stream "^2.0.0" + tar-stream "^1.5.0" + zip-stream "^1.2.0" + archy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" @@ -611,7 +635,7 @@ async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" -async@^2.1.2, async@^2.1.4, async@^2.4.1, async@~2.6.0: +async@^2.0.0, async@^2.1.2, async@^2.1.4, async@^2.4.1, async@~2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" dependencies: @@ -1583,7 +1607,7 @@ bson@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/bson/-/bson-1.0.4.tgz#93c10d39eaa5b58415cbc4052f3e53e562b0b72c" -buffer-crc32@~0.2.3: +buffer-crc32@^0.2.1, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -2181,6 +2205,15 @@ component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" +compress-commons@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.2.tgz#524a9f10903f3a813389b0225d27c48bb751890f" + dependencies: + buffer-crc32 "^0.2.1" + crc32-stream "^2.0.0" + normalize-path "^2.0.0" + readable-stream "^2.0.0" + compressible@~2.0.11: version "2.0.11" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.11.tgz#16718a75de283ed8e604041625a2064586797d8a" @@ -2411,6 +2444,17 @@ cosmiconfig@^4.0.0: parse-json "^4.0.0" require-from-string "^2.0.1" +crc32-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-2.0.0.tgz#e3cdd3b4df3168dd74e3de3fbbcb7b297fe908f4" + dependencies: + crc "^3.4.4" + readable-stream "^2.0.0" + +crc@^3.4.4: + version "3.5.0" + resolved "https://registry.yarnpkg.com/crc/-/crc-3.5.0.tgz#98b8ba7d489665ba3979f59b21381374101a1964" + create-ecdh@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" @@ -2641,6 +2685,12 @@ cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0", "cssom@>= 0.3.2 < 0.4.0": dependencies: cssom "0.3.x" +csv-stringify@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-2.0.4.tgz#9ace220df98ffa7ca91314ac77ff8cba0a08c863" + dependencies: + lodash.get "~4.4.2" + cuid@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/cuid/-/cuid-1.3.8.tgz#4b875e0969bad764f7ec0706cf44f5fb0831f6b7" @@ -4297,7 +4347,7 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9: +graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -6289,6 +6339,12 @@ lazy-cache@^2.0.2: dependencies: set-getter "^0.1.0" +lazystream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" + dependencies: + readable-stream "^2.0.5" + lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -6613,7 +6669,7 @@ lodash.foreach@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" -lodash.get@4.4.2, lodash.get@^4.4.2: +lodash.get@4.4.2, lodash.get@^4.4.2, lodash.get@~4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" @@ -6753,7 +6809,7 @@ lodash.values@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.values/-/lodash.values-4.3.0.tgz#a3a6c2b0ebecc5c2cba1c17e6e620fe81b53d347" -"lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.5, lodash@^4.3.0, lodash@~4.17.4: +"lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.4: version "4.17.5" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" @@ -10792,6 +10848,15 @@ tar-stream@1.5.2, tar-stream@^1.1.2: readable-stream "^2.0.0" xtend "^4.0.0" +tar-stream@^1.5.0: + version "1.5.5" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.5.5.tgz#5cad84779f45c83b1f2508d96b09d88c7218af55" + dependencies: + bl "^1.0.0" + end-of-stream "^1.0.0" + readable-stream "^2.0.0" + xtend "^4.0.0" + tar@^2.0.0, tar@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" @@ -11859,3 +11924,12 @@ yauzl@^2.5.0: zen-observable-ts@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.4.4.tgz#c244c71eaebef79a985ccf9895bc90307a6e9712" + +zip-stream@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04" + dependencies: + archiver-utils "^1.3.0" + compress-commons "^1.2.0" + lodash "^4.8.0" + readable-stream "^2.0.0" From a808cdc1958d5f709325f19f0c9e7f6ae1b9e43e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 27 Mar 2018 18:47:34 -0600 Subject: [PATCH 02/44] refactored some request logic --- routes/api/v1/account.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 84b5e4d0a..23ef46a63 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -4,6 +4,10 @@ const UsersService = require('../../../services/users'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); const errors = require('../../../errors'); +const archiver = require('archiver'); +const stringify = require('csv-stringify'); +const moment = require('moment'); +const { pick, get, kebabCase } = require('lodash'); // Return the current logged in user. router.get('/', authorization.needed(), (req, res, next) => { @@ -127,11 +131,6 @@ router.put( } ); -const archiver = require('archiver'); -const stringify = require('csv-stringify'); -const moment = require('moment'); -const { pick, get, kebabCase } = require('lodash'); - // loadCommentsBatch will load a batch of the comments and write them to the // stream. async function loadCommentsBatch(ctx, csv, variables = {}) { @@ -179,7 +178,13 @@ async function loadCommentsBatch(ctx, csv, variables = {}) { // 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, csv) { +async function loadComments(ctx, archive) { + // 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: 'my_comments.csv' }); + csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']); // Load the first batch's comments. @@ -222,19 +227,13 @@ router.get('/download', authorization.needed(), async (req, res, next) => { // Pipe this to the response writer directly. archive.pipe(res); - // Create all the csv writers that'll write the data to the archive. - const myCommentsCSV = stringify(); - - // Add all the streams as files to the archive. - archive.append(myCommentsCSV, { name: 'my_comments.csv' }); + // Load the comments csv up with the user's comments. + await loadComments(req.context, archive); // 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(); - - // Load the comments csv up with the user's comments. - await loadComments(req.context, myCommentsCSV); } catch (err) { return next(err); } From 5650b7cf3af0a3fbb8d6d59a013fc375c0f35112 Mon Sep 17 00:00:00 2001 From: okbel Date: Wed, 28 Mar 2018 09:34:06 -0300 Subject: [PATCH 03/44] Adding proptypes --- .../src/routes/Install/components/Install.js | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Install/components/Install.js b/client/coral-admin/src/routes/Install/components/Install.js index 14afc12f5..823b32968 100644 --- a/client/coral-admin/src/routes/Install/components/Install.js +++ b/client/coral-admin/src/routes/Install/components/Install.js @@ -1,7 +1,8 @@ -import React, { Component } from 'react'; +import React from 'react'; import styles from './Install.css'; import { Wizard, WizardNav } from 'coral-ui'; import Layout from 'coral-admin/src/components/Layout'; +import PropTypes from 'prop-types'; import InitialStep from './Steps/InitialStep'; import AddOrganizationName from './Steps/AddOrganizationName'; @@ -9,7 +10,7 @@ import CreateYourAccount from './Steps/CreateYourAccount'; import PermittedDomainsStep from './Steps/PermittedDomainsStep'; import FinalStep from './Steps/FinalStep'; -export default class Install extends Component { +class Install extends React.Component { handleDomainsChange = value => { this.props.updatePermittedDomains(value); }; @@ -81,3 +82,18 @@ export default class Install extends Component { ); } } + +Install.propTypes = { + updatePermittedDomains: PropTypes.func.isRequired, + updateSettingsFormData: PropTypes.func.isRequired, + updateUserFormData: PropTypes.func.isRequired, + submitSettings: PropTypes.func.isRequired, + submitUser: PropTypes.func.isRequired, + install: PropTypes.object.isRequired, + nextStep: PropTypes.func.isRequired, + previousStep: PropTypes.func.isRequired, + goToStep: PropTypes.func.isRequired, + finishInstall: PropTypes.func.isRequired, +}; + +export default Install; From 480428b4a2fe1e19dd61bccf745827f79f4c54e9 Mon Sep 17 00:00:00 2001 From: okbel Date: Wed, 28 Mar 2018 09:44:02 -0300 Subject: [PATCH 04/44] PropTypes and adding field --- .../components/Steps/CreateYourAccount.js | 10 +++++ .../src/routes/Install/containers/Install.js | 37 +++++++++++++++---- locales/ar.yml | 1 + locales/da.yml | 1 + locales/de.yml | 1 + locales/en.yml | 1 + locales/es.yml | 1 + locales/fr.yml | 1 + locales/pt_BR.yml | 1 + 9 files changed, 47 insertions(+), 7 deletions(-) diff --git a/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js b/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js index 37387c446..723c4fb78 100644 --- a/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js +++ b/client/coral-admin/src/routes/Install/components/Steps/CreateYourAccount.js @@ -52,6 +52,16 @@ const InitialStep = props => { errorMsg={install.errors.confirmPassword} /> + + {!props.install.isLoading ? ( + + + + + + diff --git a/views/admin/confirm-email.ejs b/views/account/email/confirm.ejs similarity index 98% rename from views/admin/confirm-email.ejs rename to views/account/email/confirm.ejs index 4e59de1d8..8c726b4e5 100644 --- a/views/admin/confirm-email.ejs +++ b/views/account/email/confirm.ejs @@ -5,7 +5,7 @@ Email Verification - <%- include ../partials/head %> + <%- include ../../partials/head %>
diff --git a/views/admin/password-reset.ejs b/views/account/password/reset.ejs similarity index 98% rename from views/admin/password-reset.ejs rename to views/account/password/reset.ejs index 75770a15e..c551034b3 100644 --- a/views/admin/password-reset.ejs +++ b/views/account/password/reset.ejs @@ -5,7 +5,7 @@ Password Reset - <%- include ../partials/head %> + <%- include ../../partials/head %>
diff --git a/views/graphiql.ejs b/views/api/graphiql.ejs similarity index 100% rename from views/graphiql.ejs rename to views/api/graphiql.ejs diff --git a/views/article.ejs b/views/dev/article.ejs similarity index 98% rename from views/article.ejs rename to views/dev/article.ejs index 4e12fa04a..8abfc34af 100644 --- a/views/article.ejs +++ b/views/dev/article.ejs @@ -23,7 +23,7 @@

<%= title %>

<%= body %>

-

Admin - All Assets

+

Admin - All Assets

- - - diff --git a/yarn.lock b/yarn.lock index ba8e393c5..47ce8caa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,7 +68,7 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" -"@coralproject/eslint-config-talk@^0.1.0", "@coralproject/eslint-config-talk@^0.1.1": +"@coralproject/eslint-config-talk@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@coralproject/eslint-config-talk/-/eslint-config-talk-0.1.1.tgz#71991b4937a3ffe657128d7f1170da4b5fb75c9e" dependencies: @@ -174,13 +174,6 @@ accepts@^1.3.4, accepts@~1.3.4: mime-types "~2.1.16" negotiator "0.6.1" -accepts@~1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" - dependencies: - mime-types "~2.1.18" - negotiator "0.6.1" - acorn-dynamic-import@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4" @@ -235,10 +228,6 @@ acorn@^5.3.0: version "5.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.4.1.tgz#fdc58d9d17f4a4e98d102ded826a9b9759125102" -acorn@^5.5.0: - version "5.5.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" - addressparser@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746" @@ -1805,13 +1794,6 @@ caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" -casual@^1.5.19: - version "1.5.19" - resolved "https://registry.yarnpkg.com/casual/-/casual-1.5.19.tgz#66fac46f7ae463f468f5913eb139f9c41c58bbf2" - dependencies: - mersenne-twister "^1.0.1" - moment "^2.15.2" - center-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" @@ -2485,10 +2467,6 @@ crc32-stream@^2.0.0: crc "^3.4.4" readable-stream "^2.0.0" -crc@3.4.4: - version "3.4.4" - resolved "https://registry.yarnpkg.com/crc/-/crc-3.4.4.tgz#9da1e980e3bd44fc5c93bf5ab3da3378d85e466b" - crc@^3.4.4: version "3.5.0" resolved "https://registry.yarnpkg.com/crc/-/crc-3.5.0.tgz#98b8ba7d489665ba3979f59b21381374101a1964" @@ -2723,9 +2701,9 @@ cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0", "cssom@>= 0.3.2 < 0.4.0": dependencies: cssom "0.3.x" -csv-stringify@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-2.0.4.tgz#9ace220df98ffa7ca91314ac77ff8cba0a08c863" +csv-stringify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-3.0.0.tgz#ed2b4eaae5a3be382309f7864168458970307d7f" dependencies: lodash.get "~4.4.2" @@ -2988,7 +2966,7 @@ dns-prefetch-control@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2" -doctrine@^2.0.0, doctrine@^2.1.0: +doctrine@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" dependencies: @@ -3129,10 +3107,6 @@ ejs@2.5.7, ejs@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.7.tgz#cc872c168880ae3c7189762fd5ffc00896c9518a" -ejs@^2.5.8: - version "2.5.8" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.8.tgz#2ab6954619f225e6193b7ac5f7c39c48fefe4380" - electron-to-chromium@^1.2.7: version "1.3.26" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.26.tgz#996427294861a74d9c7c82b9260ea301e8c02d66" @@ -3428,49 +3402,6 @@ eslint-visitor-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" -eslint@^4.19.1: - version "4.19.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.19.1.tgz#32d1d653e1d90408854bfb296f076ec7e186a300" - dependencies: - ajv "^5.3.0" - babel-code-frame "^6.22.0" - chalk "^2.1.0" - concat-stream "^1.6.0" - cross-spawn "^5.1.0" - debug "^3.1.0" - doctrine "^2.1.0" - eslint-scope "^3.7.1" - eslint-visitor-keys "^1.0.0" - espree "^3.5.4" - esquery "^1.0.0" - esutils "^2.0.2" - file-entry-cache "^2.0.0" - functional-red-black-tree "^1.0.1" - glob "^7.1.2" - globals "^11.0.1" - ignore "^3.3.3" - imurmurhash "^0.1.4" - inquirer "^3.0.6" - is-resolvable "^1.0.0" - js-yaml "^3.9.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" - lodash "^4.17.4" - minimatch "^3.0.2" - mkdirp "^0.5.1" - natural-compare "^1.4.0" - optionator "^0.8.2" - path-is-inside "^1.0.2" - pluralize "^7.0.0" - progress "^2.0.0" - regexpp "^1.0.1" - require-uncached "^1.0.3" - semver "^5.3.0" - strip-ansi "^4.0.0" - strip-json-comments "~2.0.1" - table "4.0.2" - text-table "~0.2.0" - eslint@^4.5.0: version "4.13.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.13.1.tgz#0055e0014464c7eb7878caf549ef2941992b444f" @@ -3520,13 +3451,6 @@ espree@^3.5.2: acorn "^5.2.1" acorn-jsx "^3.0.0" -espree@^3.5.4: - version "3.5.4" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7" - dependencies: - acorn "^5.5.0" - acorn-jsx "^3.0.0" - esprima@3.x.x, esprima@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" @@ -3701,20 +3625,6 @@ exports-loader@^0.6.4: loader-utils "^1.0.2" source-map "0.5.x" -express-session@^1.15.6: - version "1.15.6" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.15.6.tgz#47b4160c88f42ab70fe8a508e31cbff76757ab0a" - dependencies: - cookie "0.3.1" - cookie-signature "1.0.6" - crc "3.4.4" - debug "2.6.9" - depd "~1.1.1" - on-headers "~1.0.1" - parseurl "~1.3.2" - uid-safe "~2.1.5" - utils-merge "1.0.1" - express-static-gzip@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/express-static-gzip/-/express-static-gzip-0.3.2.tgz#89ede84547a5717de3146315f62dc996c071a88d" @@ -3756,41 +3666,6 @@ express@4.16.0, express@^4.12.2: utils-merge "1.0.1" vary "~1.1.2" -express@^4.16.3: - version "4.16.3" - resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53" - dependencies: - accepts "~1.3.5" - array-flatten "1.1.1" - body-parser "1.18.2" - content-disposition "0.5.2" - content-type "~1.0.4" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~1.1.2" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.1.1" - fresh "0.5.2" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.2" - path-to-regexp "0.1.7" - proxy-addr "~2.0.3" - qs "6.5.1" - range-parser "~1.2.0" - safe-buffer "5.1.1" - send "0.16.2" - serve-static "1.13.2" - setprototypeof "1.1.0" - statuses "~1.4.0" - type-is "~1.6.16" - utils-merge "1.0.1" - vary "~1.1.2" - extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -3988,18 +3863,6 @@ finalhandler@1.1.0: statuses "~1.3.1" unpipe "~1.0.0" -finalhandler@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.2" - statuses "~1.4.0" - unpipe "~1.0.0" - find-cache-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f" @@ -5306,10 +5169,6 @@ ipaddr.js@1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.5.2.tgz#d4b505bde9946987ccf0fc58d9010ff9607e3fa0" -ipaddr.js@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.6.0.tgz#e3fa357b773da619f26e95f049d055c72796f86b" - is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" @@ -7172,10 +7031,6 @@ merge@^1.1.3: version "1.2.0" resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da" -mersenne-twister@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mersenne-twister/-/mersenne-twister-1.1.0.tgz#f916618ee43d7179efcf641bec4531eb9670978a" - metascraper-author@^3.9.2: version "3.9.2" resolved "https://registry.yarnpkg.com/metascraper-author/-/metascraper-author-3.9.2.tgz#ff2020ac428f59a875d655df3b0d4bea171fde19" @@ -7316,7 +7171,7 @@ miller-rabin@^4.0.0: version "1.30.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" -"mime-db@>= 1.33.0 < 2", mime-db@~1.33.0: +"mime-db@>= 1.33.0 < 2": version "1.33.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" @@ -7326,12 +7181,6 @@ mime-types@^2.1.10, mime-types@^2.1.12, mime-types@~2.1.15, mime-types@~2.1.16, dependencies: mime-db "~1.30.0" -mime-types@~2.1.18: - version "2.1.18" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" - dependencies: - mime-db "~1.33.0" - mime@1.4.1, mime@^1.3.4, mime@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" @@ -7464,10 +7313,6 @@ moment@^2.10.3: version "2.19.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167" -moment@^2.15.2: - version "2.22.0" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.0.tgz#7921ade01017dd45186e7fee5f424f0b8663a730" - mongodb-core@2.1.17: version "2.1.17" resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.1.17.tgz#a418b337a14a14990fb510b923dee6a813173df8" @@ -8425,15 +8270,6 @@ passport-oauth2@1.x.x, passport-oauth2@^1.1.2: uid2 "0.0.x" utils-merge "1.x.x" -passport-openidconnect@^0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/passport-openidconnect/-/passport-openidconnect-0.0.2.tgz#e488f8bdb386c9a9fd39c91d5ab8c880156e8153" - dependencies: - oauth "0.9.x" - passport-strategy "1.x.x" - request "^2.75.0" - webfinger "0.4.x" - passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" @@ -9158,13 +8994,6 @@ proxy-addr@~2.0.2: forwarded "~0.1.2" ipaddr.js "1.5.2" -proxy-addr@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.3.tgz#355f262505a621646b3130a728eb647e22055341" - dependencies: - forwarded "~0.1.2" - ipaddr.js "1.6.0" - proxy-agent@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-2.0.0.tgz#57eb5347aa805d74ec681cb25649dba39c933499" @@ -9415,10 +9244,6 @@ randexp@^0.4.2: discontinuous-range "1.0.0" ret "~0.1.10" -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - randomatic@^1.1.3: version "1.1.7" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c" @@ -9889,10 +9714,6 @@ regexp-clone@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" -regexpp@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-1.1.0.tgz#0e3516dd0b7904f413d2d4193dce4618c3a689ab" - regexpu-core@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" @@ -10043,33 +9864,6 @@ request@2.81.0: tunnel-agent "^0.6.0" uuid "^3.0.0" -request@^2.75.0: - version "2.85.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.85.0.tgz#5a03615a47c61420b3eb99b7dba204f83603e1fa" - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.6.0" - caseless "~0.12.0" - combined-stream "~1.0.5" - extend "~3.0.1" - forever-agent "~0.6.1" - form-data "~2.3.1" - har-validator "~5.0.3" - hawk "~6.0.2" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.17" - oauth-sign "~0.8.2" - performance-now "^2.1.0" - qs "~6.5.1" - safe-buffer "^5.1.1" - stringstream "~0.0.5" - tough-cookie "~2.3.3" - tunnel-agent "^0.6.0" - uuid "^3.1.0" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -10284,7 +10078,7 @@ sax@0.5.x: version "0.5.8" resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" -sax@>=0.1.1, sax@^1.1.4, sax@^1.2.1, sax@^1.2.4, sax@~1.2.1: +sax@^1.1.4, sax@^1.2.1, sax@^1.2.4, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -10423,7 +10217,7 @@ serve-static@1.13.0: parseurl "~1.3.2" send "0.16.0" -serve-static@1.13.2, serve-static@^1.10.0: +serve-static@^1.10.0: version "1.13.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" dependencies: @@ -10840,10 +10634,6 @@ stealthy-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" -step@0.0.x: - version "0.0.6" - resolved "https://registry.yarnpkg.com/step/-/step-0.0.6.tgz#143e7849a5d7d3f4a088fe29af94915216eeede2" - stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" @@ -11141,7 +10931,7 @@ symbol-observable@^1.0.2, symbol-observable@^1.0.3, symbol-observable@^1.0.4: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" -table@4.0.2, table@^4.0.1: +table@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36" dependencies: @@ -11470,13 +11260,6 @@ type-is@~1.6.15: media-typer "0.3.0" mime-types "~2.1.15" -type-is@~1.6.16: - version "1.6.16" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" - dependencies: - media-typer "0.3.0" - mime-types "~2.1.18" - typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -11551,12 +11334,6 @@ uid-number@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" -uid-safe@~2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - dependencies: - random-bytes "~1.0.0" - uid2@0.0.x: version "0.0.3" resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" @@ -11847,13 +11624,6 @@ watchpack@^1.4.0: chokidar "^1.7.0" graceful-fs "^4.1.2" -webfinger@0.4.x: - version "0.4.2" - resolved "https://registry.yarnpkg.com/webfinger/-/webfinger-0.4.2.tgz#3477a6d97799461896039fcffc650b73468ee76d" - dependencies: - step "0.0.x" - xml2js "0.1.x" - webidl-conversions@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-2.0.1.tgz#3bf8258f7d318c7443c36f2e169402a1a6703506" @@ -12090,12 +11860,6 @@ xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" -xml2js@0.1.x: - version "0.1.14" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.1.14.tgz#5274e67f5a64c5f92974cd85139e0332adc6b90c" - dependencies: - sax ">=0.1.1" - xml@^1.0.0, xml@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" From 333ef9837884ae9ec7b826e01b1593ed3386ec2e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 9 Apr 2018 15:58:31 -0600 Subject: [PATCH 21/44] 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 From 43e3cb8f10fbf21b54eb0f66b32c59eb1e11553d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 9 Apr 2018 16:07:05 -0600 Subject: [PATCH 22/44] cleanup of plugin code --- plugins/talk-plugin-profile-data/index.js | 306 +----------------- .../server/connect.js | 14 + .../server/constants.js | 3 + .../server/mutators.js | 66 ++++ .../server/resolvers.js | 20 ++ .../talk-plugin-profile-data/server/router.js | 183 +++++++++++ .../server/typeDefs.graphql | 19 ++ .../server/typeDefs.js | 7 + 8 files changed, 319 insertions(+), 299 deletions(-) create mode 100644 plugins/talk-plugin-profile-data/server/connect.js create mode 100644 plugins/talk-plugin-profile-data/server/constants.js create mode 100644 plugins/talk-plugin-profile-data/server/mutators.js create mode 100644 plugins/talk-plugin-profile-data/server/resolvers.js create mode 100644 plugins/talk-plugin-profile-data/server/router.js create mode 100644 plugins/talk-plugin-profile-data/server/typeDefs.graphql create mode 100644 plugins/talk-plugin-profile-data/server/typeDefs.js diff --git a/plugins/talk-plugin-profile-data/index.js b/plugins/talk-plugin-profile-data/index.js index 6990d00ad..7bfa81748 100644 --- a/plugins/talk-plugin-profile-data/index.js +++ b/plugins/talk-plugin-profile-data/index.js @@ -1,307 +1,15 @@ -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 - ); - }); -}; +const router = require('./server/router'); +const mutators = require('./server/mutators'); +const typeDefs = require('./server/typeDefs'); +const connect = require('./server/connect'); +const resolvers = require('./server/resolvers'); module.exports = { - mutators: ctx => ({ - User: { - requestDownloadLink: () => sendDownloadLink(ctx), - }, - }), + mutators, 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); - }, - }, - }, + resolvers, }; diff --git a/plugins/talk-plugin-profile-data/server/connect.js b/plugins/talk-plugin-profile-data/server/connect.js new file mode 100644 index 000000000..f09b2dc7b --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/connect.js @@ -0,0 +1,14 @@ +const path = require('path'); + +module.exports = connectors => { + const { services: { Mailer } } = connectors; + + // Setup the mail templates. + ['txt', 'html'].forEach(format => { + Mailer.templates.register( + path.join(__dirname, 'emails', `download.${format}.ejs`), + 'download', + format + ); + }); +}; diff --git a/plugins/talk-plugin-profile-data/server/constants.js b/plugins/talk-plugin-profile-data/server/constants.js new file mode 100644 index 000000000..6183e8bbe --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/constants.js @@ -0,0 +1,3 @@ +module.exports = { + DOWNLOAD_LINK_SUBJECT: 'download_link', +}; diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js new file mode 100644 index 000000000..e5d717978 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -0,0 +1,66 @@ +const moment = require('moment'); +const uuid = require('uuid/v4'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); + +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() } } + ); +} + +module.exports = ctx => ({ + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + }, +}); diff --git a/plugins/talk-plugin-profile-data/server/resolvers.js b/plugins/talk-plugin-profile-data/server/resolvers.js new file mode 100644 index 000000000..691907f8c --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -0,0 +1,20 @@ +const { get } = require('lodash'); + +module.exports = { + 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/server/router.js b/plugins/talk-plugin-profile-data/server/router.js new file mode 100644 index 000000000..5ef3b0cfc --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -0,0 +1,183 @@ +const path = require('path'); +const express = require('express'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { get, pick, kebabCase } = require('lodash'); +const moment = require('moment'); +const archiver = require('archiver'); +const stringify = require('csv-stringify'); + +async function verifyDownloadToken( + { connectors: { services: { Users } } }, + token +) { + const jwt = await Users.verifyToken(token, { + subject: DOWNLOAD_LINK_SUBJECT, + }); + + return jwt; +} + +// 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(); +} + +module.exports = router => { + // /account/download will render the download page. + router.get('/account/download', (req, res) => { + res.render(path.join(__dirname, '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); + } + } + ); +}; diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql new file mode 100644 index 000000000..0029111c3 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -0,0 +1,19 @@ +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 +} diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.js b/plugins/talk-plugin-profile-data/server/typeDefs.js new file mode 100644 index 000000000..ccadb70b0 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.js @@ -0,0 +1,7 @@ +const path = require('path'); +const fs = require('fs'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +); From 8a7d4c4fcc21f867f7071c1b401191899de9e1c5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 9 Apr 2018 16:14:25 -0600 Subject: [PATCH 23/44] readme update --- plugins/talk-plugin-profile-data/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/talk-plugin-profile-data/README.md b/plugins/talk-plugin-profile-data/README.md index fe10a817b..17f90c666 100644 --- a/plugins/talk-plugin-profile-data/README.md +++ b/plugins/talk-plugin-profile-data/README.md @@ -1,8 +1,10 @@ --- title: talk-plugin-profile-data layout: plugin +permalink: /plugin/talk-plugin-profile-data/ plugin: name: talk-plugin-profile-data + default: true provides: - Client - Server From afa592aa78a7ab0839c0c80a112f7253243bf8ee Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 9 Apr 2018 16:34:08 -0600 Subject: [PATCH 24/44] e2e fixes --- test/e2e/page_objects/embedStream.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/page_objects/embedStream.js b/test/e2e/page_objects/embedStream.js index be60fc8d0..de3a29973 100644 --- a/test/e2e/page_objects/embedStream.js +++ b/test/e2e/page_objects/embedStream.js @@ -28,7 +28,7 @@ module.exports = { return this.section.comments; }, navigateToAsset: function(asset) { - this.api.url(`${this.api.launchUrl}/assets/title/${asset}`); + this.api.url(`${this.api.launchUrl}/dev/assets/title/${asset}`); return this; }, switchToIframe: function() { @@ -44,7 +44,7 @@ module.exports = { }, ], url: function() { - return this.api.launchUrl; + return this.api.launchUrl + '/dev/'; }, elements: { iframe: `#${iframeId}`, From 1ceb1b4014bc16f2cee8d44495b0bf68948b8236 Mon Sep 17 00:00:00 2001 From: okbel Date: Tue, 10 Apr 2018 11:46:32 -0300 Subject: [PATCH 25/44] Save, and edit changes --- .../routes/Configure/components/Configure.js | 13 +++- .../components/OrganizationSettings.css | 27 +++++--- .../components/OrganizationSettings.js | 63 ++++++++++++++----- .../routes/Configure/containers/Configure.js | 5 +- locales/en.yml | 2 + locales/es.yml | 2 + 6 files changed, 88 insertions(+), 24 deletions(-) diff --git a/client/coral-admin/src/routes/Configure/components/Configure.js b/client/coral-admin/src/routes/Configure/components/Configure.js index 69ab4f06c..bcd48c21c 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.js +++ b/client/coral-admin/src/routes/Configure/components/Configure.js @@ -8,7 +8,14 @@ import SaveChangesDialog from './SaveChangesDialog'; class Configure extends React.Component { render() { - const { canSave, currentUser, root, savePending, settings } = this.props; + const { + canSave, + currentUser, + root, + savePending, + settings, + clearPending, + } = this.props; if (!can(currentUser, 'UPDATE_CONFIG')) { return

{t('configure.access_message')}

; @@ -17,6 +24,9 @@ class Configure extends React.Component { const passProps = { root, settings, + savePending, + clearPending, + canSave, }; return ( @@ -84,6 +94,7 @@ Configure.propTypes = { children: PropTypes.node.isRequired, saveDialog: PropTypes.bool, hideSaveDialog: PropTypes.func.isRequired, + clearPending: PropTypes.func.isRequired, }; export default Configure; diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css index 06ada55c4..d28fced83 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.css @@ -16,7 +16,7 @@ } .detailValue { - padding: 6px 10px; + padding: 6px 0; border: solid 1px transparent; display: block; font-size: 1em; @@ -24,6 +24,8 @@ } .editable { + padding-left: 10px; + padding-right: 10px; border-color: grey; } @@ -32,20 +34,31 @@ list-style: none; } -.editButton { - position: absolute; - right: 10px; - top: 10px; +.button, .button:disabled { + background: white; + border: solid 1px grey; } .actionBox { position: absolute; - right: 10px; - top: 10px; + right: 20px; + top: 20px; text-align: center; + width: 100px; } .cancelButton { padding: 10px; display: block; + color: #4f5c67; + font-weight: 500; + + &:hover { + cursor: pointer; + } +} + +.changedSave { + background-color: #00796B; + color: white; } \ No newline at end of file diff --git a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js index 18fe653e1..06c91926c 100644 --- a/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/OrganizationSettings.js @@ -17,6 +17,12 @@ class OrganizationSettings extends React.Component { })); }; + disableEditing = () => { + this.setState(() => ({ + editing: false, + })); + }; + updateName = event => { const updater = { organizationName: { $set: event.target.value } }; this.props.updatePending({ updater }); @@ -27,32 +33,56 @@ class OrganizationSettings extends React.Component { this.props.updatePending({ updater }); }; + cancelEditing = () => { + this.disableEditing(); + this.props.clearPending(); + }; + + save = async () => { + await this.props.savePending(); + this.disableEditing(); + }; + render() { - const { settings, slotPassthrough } = this.props; + const { settings, slotPassthrough, canSave } = this.props; return (

{t('configure.organization_info_copy')}

{t('configure.organization_info_copy_2')}

{!this.state.editing ? ( - +
+ +
) : (
- - Cancel + {canSave ? ( + + ) : ( + + )} + + {t('cancel')} +
)} - - {console.log(this.props.slotPassthrough)} -