From ee5316e6efcb99ba8c001845879d826e30524866 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 27 Mar 2018 18:28:14 -0600 Subject: [PATCH 01/15] 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/15] 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 c904d0f0c04a14fad975e1b873df534158f0f87d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 29 Mar 2018 14:59:24 -0600 Subject: [PATCH 03/15] improved routing, cleaned mail --- graph/context.js | 7 + graph/mutators/user.js | 38 ++-- graph/resolvers/root_mutation.js | 3 + graph/typeDefs.graphql | 10 + locales/en.yml | 8 + perms/constants/mutation.js | 1 + perms/reducers/mutation.js | 1 + routes/account/index.js | 16 ++ routes/admin/index.js | 8 - routes/api/v1/account.js | 106 +++++++---- routes/api/v1/graph.js | 2 +- routes/{assets/index.js => dev/assets.js} | 6 +- routes/dev/index.js | 25 +++ routes/index.js | 21 +-- services/mailer/templates/download.html.ejs | 1 + services/mailer/templates/download.txt.ejs | 3 + .../mailer/templates/email-confirm.html.ejs | 2 +- .../mailer/templates/email-confirm.txt.ejs | 2 +- .../mailer/templates/password-reset.html.ejs | 2 +- .../mailer/templates/password-reset.txt.ejs | 2 +- services/users.js | 173 ++++++++++-------- views/account/download.ejs | 47 +++++ .../email/confirm.ejs} | 2 +- .../password/reset.ejs} | 2 +- views/{ => api}/graphiql.ejs | 0 views/{ => dev}/article.ejs | 2 +- views/{ => dev}/articles.ejs | 2 +- 27 files changed, 323 insertions(+), 169 deletions(-) create mode 100644 routes/account/index.js rename routes/{assets/index.js => dev/assets.js} (93%) create mode 100644 routes/dev/index.js create mode 100644 services/mailer/templates/download.html.ejs create mode 100644 services/mailer/templates/download.txt.ejs create mode 100644 views/account/download.ejs rename views/{admin/confirm-email.ejs => account/email/confirm.ejs} (98%) rename views/{admin/password-reset.ejs => account/password/reset.ejs} (98%) rename views/{ => api}/graphiql.ejs (100%) rename views/{ => dev}/article.ejs (98%) rename views/{ => dev}/articles.ejs (66%) diff --git a/graph/context.js b/graph/context.js index e9ac7ea61..9c356f0e7 100644 --- a/graph/context.js +++ b/graph/context.js @@ -137,6 +137,13 @@ class Context { ); } + /** + * masqueradeAs will allow a given context to be copied to a new user. + */ + masqueradeAs(user) { + return new Context(merge({}, this, { user })); + } + /** * forSystem returns a system context object that can be used for internal * operations. diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 9d31e6dbd..d9e3374a5 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,5 +1,5 @@ const errors = require('../../errors'); -const UsersService = require('../../services/users'); +const Users = require('../../services/users'); const migrationHelpers = require('../../services/migration/helpers'); const { CHANGE_USERNAME, @@ -9,10 +9,11 @@ const { SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, DELETE_USER, + REQUEST_DOWNLOAD_LINK, } = require('../../perms/constants'); const setUserUsernameStatus = async (ctx, id, status) => { - const user = await UsersService.setUsernameStatus(id, status, ctx.user.id); + const user = await Users.setUsernameStatus(id, status, ctx.user.id); if (status === 'REJECTED') { ctx.pubsub.publish('usernameRejected', user); } else if (status === 'APPROVED') { @@ -21,12 +22,7 @@ const setUserUsernameStatus = async (ctx, id, status) => { }; const setUserBanStatus = async (ctx, id, status = false, message = null) => { - const user = await UsersService.setBanStatus( - id, - status, - ctx.user.id, - message - ); + const user = await Users.setBanStatus(id, status, ctx.user.id, message); if (user.banned) { ctx.pubsub.publish('userBanned', user); } @@ -38,38 +34,33 @@ const setUserSuspensionStatus = async ( until = null, message = null ) => { - const user = await UsersService.setSuspensionStatus( - id, - until, - ctx.user.id, - message - ); + const user = await Users.setSuspensionStatus(id, until, ctx.user.id, message); if (user.suspended) { ctx.pubsub.publish('userSuspended', user); } }; const ignoreUser = ({ user }, userToIgnore) => { - return UsersService.ignoreUsers(user.id, [userToIgnore.id]); + return Users.ignoreUsers(user.id, [userToIgnore.id]); }; const stopIgnoringUser = ({ user }, userToStopIgnoring) => { - return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); + return Users.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); }; const changeUsername = async (ctx, id, username) => { - const user = await UsersService.changeUsername(id, username, ctx.user.id); + const user = await Users.changeUsername(id, username, ctx.user.id); const previousUsername = ctx.user.username; ctx.pubsub.publish('usernameChanged', { previousUsername, user }); return user; }; const setUsername = async (ctx, id, username) => { - return UsersService.setUsername(id, username, ctx.user.id); + return Users.setUsername(id, username, ctx.user.id); }; const setRole = (ctx, id, role) => { - return UsersService.setRole(id, role); + return Users.setRole(id, role); }; /** @@ -153,6 +144,10 @@ const delUser = async (ctx, id) => { await user.remove(); }; +// requestDownloadLink will request the current user's account be available via +// a download link sent to their email address. +const requestDownloadLink = async ({ user }) => Users.sendDownloadLink(user); + module.exports = ctx => { let mutators = { User: { @@ -165,6 +160,7 @@ module.exports = ctx => { setUsername: () => Promise.reject(errors.ErrNotAuthorized), stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized), del: () => Promise.reject(errors.ErrNotAuthorized), + requestDownloadLink: () => Promise.reject(errors.ErrNotAuthorized), }, }; @@ -204,6 +200,10 @@ module.exports = ctx => { if (ctx.user.can(DELETE_USER)) { mutators.User.del = id => delUser(ctx, id); } + + if (ctx.user.can(REQUEST_DOWNLOAD_LINK)) { + mutators.User.requestDownloadLink = () => requestDownloadLink(ctx); + } } return mutators; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 2838f0f99..eea296196 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -139,6 +139,9 @@ const RootMutation = { delUser: async (_, { id }, { mutators: { User } }) => { await User.del(id); }, + requestDownloadLink: async (_, args, { mutators: { User } }) => { + await User.requestDownloadLink(); + }, }; module.exports = RootMutation; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 588ad08dc..c86db06df 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -1435,6 +1435,12 @@ type DelUserResponse implements Response { errors: [UserError!] } +type RequestDownloadLinkResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -1535,6 +1541,10 @@ type RootMutation { # delUser will delete the user with the specified id. delUser(id: ID!): DelUserResponse + + # requestDownloadLink will request a download link be sent to the primary + # users email address. + requestDownloadLink: RequestDownloadLinkResponse } type UsernameChangedPayload { diff --git a/locales/en.yml b/locales/en.yml index 08d64ddc8..b33ba24b2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -201,6 +201,10 @@ en: we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email." if_you_did: "If you did," please_click: "please click here to reset password" + 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 embedlink: copy: "Copy to Clipboard" error: @@ -479,3 +483,7 @@ en: admin_sidebar: view_options: "View Options" sort_comments: "Sort Comments" + download_landing: + download_your_account: "Download Your Account" + click_to_download: "Click below to download your account" + confirm: "Download" diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index d2ebe73f1..94736b419 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -19,4 +19,5 @@ module.exports = { UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', UPDATE_SETTINGS: 'UPDATE_SETTINGS', DELETE_USER: 'DELETE_USER', + REQUEST_DOWNLOAD_LINK: 'REQUEST_DOWNLOAD_LINK', }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 73ee7ef28..ab01da4e4 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -13,6 +13,7 @@ module.exports = (user, perm) => { case types.CREATE_ACTION: case types.DELETE_ACTION: case types.EDIT_COMMENT: + case types.REQUEST_DOWNLOAD_LINK: // Anyone can do these things if they aren't suspended, banned, or blocked // as they're editing their username. return !['UNSET', 'REJECTED', 'CHANGED'].includes( diff --git a/routes/account/index.js b/routes/account/index.js new file mode 100644 index 000000000..575ae7883 --- /dev/null +++ b/routes/account/index.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); + +router.get('/email/confirm', (req, res) => { + res.render('account/email/confirm'); +}); + +router.get('/password/reset', (req, res) => { + res.render('account/password/reset'); +}); + +router.get('/download', (req, res) => { + res.render('account/download'); +}); + +module.exports = router; diff --git a/routes/admin/index.js b/routes/admin/index.js index 66f9c123d..d00ef8642 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,14 +1,6 @@ const express = require('express'); const router = express.Router(); -router.get('/confirm-email', (req, res) => { - res.render('admin/confirm-email'); -}); - -router.get('/password-reset', (req, res) => { - res.render('admin/password-reset'); -}); - router.get('*', (req, res) => { res.render('admin'); }); diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js index 23ef46a63..afeaafdb6 100644 --- a/routes/api/v1/account.js +++ b/routes/api/v1/account.js @@ -178,17 +178,20 @@ 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, archive) { +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: 'my_comments.csv' }); + archive.append(csv, { name: 'talk-export/my_comments.csv' }); csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']); - // Load the first batch's comments. - let connection = await loadCommentsBatch(ctx, csv); + // 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) { @@ -201,42 +204,67 @@ async function loadComments(ctx, archive) { } // /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; +router.post( + '/download', + express.urlencoded({ extended: false }), + tokenCheck(UsersService.verifyDownloadToken, new Error('invalid token')), + async (req, res, next) => { + try { + const { token } = req.body; + + // Pull the userID and the date that the token was issued out of the + // provided token. + const { user: userID, iat } = await UsersService.verifyDownloadToken( + 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 UsersService.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 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); - - // 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(); - } catch (err) { - return next(err); } -}); +); module.exports = router; diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js index 7d13d4c92..40e87415b 100644 --- a/routes/api/v1/graph.js +++ b/routes/api/v1/graph.js @@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions)); if (process.env.NODE_ENV !== 'production') { // Interactive graphiql interface. router.use('/iql', staticTemplate, (req, res) => { - res.render('graphiql', { + res.render('api/graphiql', { endpointURL: 'api/v1/graph/ql', }); }); diff --git a/routes/assets/index.js b/routes/dev/assets.js similarity index 93% rename from routes/assets/index.js rename to routes/dev/assets.js index 35fb6e9ed..cb1f27e43 100644 --- a/routes/assets/index.js +++ b/routes/dev/assets.js @@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => { return next(errors.ErrNotFound); } - res.render('article', { + res.render('dev/article', { title: asset.title, asset_id: asset.id, asset_url: asset.url, @@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => { }); router.get('/title/:asset_title', (req, res) => { - return res.render('article', { + return res.render('dev/article', { title: req.params.asset_title.split('-').join(' '), asset_url: '', asset_id: null, @@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => { try { const assets = await Assets.all(skip, limit); - res.render('articles', { + res.render('dev/articles', { assets: assets, }); } catch (err) { diff --git a/routes/dev/index.js b/routes/dev/index.js new file mode 100644 index 000000000..ac72db254 --- /dev/null +++ b/routes/dev/index.js @@ -0,0 +1,25 @@ +const express = require('express'); +const url = require('url'); +const router = express.Router(); + +const { MOUNT_PATH } = require('../../url'); +const SetupService = require('../../services/setup'); +const staticTemplate = require('../../middleware/staticTemplate'); + +router.use('/assets', staticTemplate, require('./assets')); +router.get('/', staticTemplate, async (req, res) => { + try { + await SetupService.isAvailable(); + return res.redirect(url.resolve(MOUNT_PATH, 'admin/install')); + } catch (e) { + return res.render('dev/article', { + title: 'Coral Talk', + asset_url: '', + asset_id: '', + body: '', + basePath: '/static/embed/stream', + }); + } +}); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index 489a5e8b5..a05cedee2 100644 --- a/routes/index.js +++ b/routes/index.js @@ -75,6 +75,7 @@ router.use(compression()); //============================================================================== router.use('/admin', staticTemplate, require('./admin')); +router.use('/account', staticTemplate, require('./account')); router.use('/login', staticTemplate, require('./login')); router.use('/embed', staticTemplate, require('./embed')); @@ -114,22 +115,14 @@ router.use('/api', require('./api')); //============================================================================== if (process.env.NODE_ENV !== 'production') { - router.use('/assets', staticTemplate, require('./assets')); - router.get('/', staticTemplate, async (req, res) => { - try { - await SetupService.isAvailable(); - return res.redirect('/admin/install'); - } catch (e) { - return res.render('article', { - title: 'Coral Talk', - asset_url: '', - asset_id: '', - body: '', - basePath: '/static/embed/stream', - }); - } + // In development, mount the /dev routes, as well as redirect the root url to + // the development route. + router.use('/dev', require('./dev')); + router.get('/', (req, res) => { + res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302); }); } else { + // In production, optionally redirect to the install if not ran, or the admin. router.get('/', async (req, res, next) => { try { await SetupService.isAvailable(); diff --git a/services/mailer/templates/download.html.ejs b/services/mailer/templates/download.html.ejs new file mode 100644 index 000000000..c6aeb8e1e --- /dev/null +++ b/services/mailer/templates/download.html.ejs @@ -0,0 +1 @@ +

<%= t('email.download.download_link_ready') %> <%= t('email.download.download_archive') %>

diff --git a/services/mailer/templates/download.txt.ejs b/services/mailer/templates/download.txt.ejs new file mode 100644 index 000000000..4f719bc06 --- /dev/null +++ b/services/mailer/templates/download.txt.ejs @@ -0,0 +1,3 @@ +<%= t('email.download.download_link_ready') %> + + <%= BASE_URL %>account/download#<%= token %> diff --git a/services/mailer/templates/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs index 76a8ea47b..396386e21 100644 --- a/services/mailer/templates/email-confirm.html.ejs +++ b/services/mailer/templates/email-confirm.html.ejs @@ -1,3 +1,3 @@

<%= t('email.confirm.has_been_requested') %> <%= email %>.

-

<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

+

<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>

<%= t('email.confirm.if_you_did_not') %>

diff --git a/services/mailer/templates/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs index b3cf28a01..41fabae46 100644 --- a/services/mailer/templates/email-confirm.txt.ejs +++ b/services/mailer/templates/email-confirm.txt.ejs @@ -4,6 +4,6 @@ <%= t('email.confirm.to_confirm') %> - <%= BASE_URL %>admin/confirm-email#<%= token %> + <%= BASE_URL %>account/email/confirm#<%= token %> <%= t('email.confirm.if_you_did_not') %> diff --git a/services/mailer/templates/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs index c0ec4ea46..502781440 100644 --- a/services/mailer/templates/password-reset.html.ejs +++ b/services/mailer/templates/password-reset.html.ejs @@ -1,2 +1,2 @@

<%= t('email.password_reset.we_received_a_request') %>
-<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

+<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.

diff --git a/services/mailer/templates/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs index e8db4bab2..2b5e60a9b 100644 --- a/services/mailer/templates/password-reset.txt.ejs +++ b/services/mailer/templates/password-reset.txt.ejs @@ -1,3 +1,3 @@ <%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>: -<%= BASE_URL %>admin/password-reset#<%= token %> +<%= BASE_URL %>account/password/reset#<%= token %> diff --git a/services/users.js b/services/users.js index 0617d5158..cecbc32c5 100644 --- a/services/users.js +++ b/services/users.js @@ -5,38 +5,43 @@ const { difference, sample, some, merge, random } = require('lodash'); const { ROOT_URL } = require('../config'); const { jwt: JWT_SECRET } = require('../secrets'); const debug = require('debug')('talk:services:users'); -const UserModel = require('../models/user'); +const User = require('../models/user'); const RECAPTCHA_WINDOW = '10m'; // 10 minutes. const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 5 incorrect attempts, recaptcha will be required. -const ActionsService = require('./actions'); +const Actions = require('./actions'); const mailer = require('./mailer'); const i18n = require('./i18n'); const Wordlist = require('./wordlist'); const DomainList = require('./domain_list'); +const Limit = require('./limit'); const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; +const DOWNLOAD_LINK_SUBJECT = 'download_link'; // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. const SALT_ROUNDS = 10; // Create a redis client to use for authentication. -const Limit = require('./limit'); const loginRateLimiter = new Limit( 'loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW ); -// UsersService is the interface for the application to interact with the -// UserModel through. -class UsersService { +// downloadLinkLimiter can be used to limit downloads for the user's data to +// once every 24 hours. +const downloadLinkLimiter = new Limit('downloadLinkLimiter', 1, '1d'); + +// Users is the interface for the application to interact with the +// User through. +class Users { /** * Returns a user (if found) for the given email address. */ static findLocalUser(email) { - return UserModel.findOne({ + return User.findOne({ profiles: { $elemMatch: { id: email.toLowerCase(), @@ -68,7 +73,7 @@ class UsersService { } static async setSuspensionStatus(id, until, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id }, { $set: { @@ -89,7 +94,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw errors.ErrNotFound; } @@ -112,7 +117,7 @@ class UsersService { // Check to see if the user was suspended now and is currently suspended. if (user.suspended && message && message.length > 0) { - await UsersService.sendEmail(user, { + await Users.sendEmail(user, { template: 'plain', locals: { body: message, @@ -125,7 +130,7 @@ class UsersService { } static async setBanStatus(id, status, assignedBy = null, message) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.banned.status': { @@ -151,7 +156,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw errors.ErrNotFound; } @@ -165,7 +170,7 @@ class UsersService { // Check to see if the user was banned now and is currently banned. if (user.banned && status && message && message.length > 0) { - await UsersService.sendEmail(user, { + await Users.sendEmail(user, { template: 'plain', locals: { body: message, @@ -178,7 +183,7 @@ class UsersService { } static async setUsernameStatus(id, status, assignedBy = null) { - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( { id, 'status.username.status': { @@ -202,7 +207,7 @@ class UsersService { } ); if (user === null) { - user = await UserModel.findOne({ id }); + user = await User.findOne({ id }); if (user === null) { throw errors.ErrNotFound; } @@ -236,7 +241,7 @@ class UsersService { query.username = { $ne: username }; } - let user = await UserModel.findOneAndUpdate( + let user = await User.findOneAndUpdate( query, { $set: { @@ -257,7 +262,7 @@ class UsersService { } ); if (!user) { - user = await UsersService.findById(id); + user = await Users.findById(id); if (user === null) { throw errors.ErrNotFound; } @@ -284,24 +289,11 @@ class UsersService { } static async setUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'UNSET', - 'SET', - assignedBy, - true - ); + return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true); } static async changeUsername(id, username, assignedBy) { - return UsersService._setUsername( - id, - username, - 'REJECTED', - 'CHANGED', - assignedBy - ); + return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy); } /** @@ -325,7 +317,7 @@ class UsersService { * Sets or removes the recaptcha_required flag on a user's local profile. */ static flagForRecaptchaRequirement(email, required) { - return UserModel.update( + return User.update( { profiles: { $elemMatch: { @@ -357,11 +349,11 @@ class UsersService { const GROUP_ATTEMPTS = 50; // Cast the original username. - const castedName = UsersService.castUsername(username); + const castedName = Users.castUsername(username); const lowercaseUsername = castedName.toLowerCase(); // Try to see if our first guess has been taken. - const existingUserWithName = await UserModel.findOne({ + const existingUserWithName = await User.findOne({ lowercaseUsername, }); if (!existingUserWithName) { @@ -381,7 +373,7 @@ class UsersService { ); // See if any of these users aren't taken already. - const existingUsernames = (await UserModel.find( + const existingUsernames = (await User.find( { lowercaseUsername: { $in: lowercaseUsernameGuesses }, }, @@ -417,7 +409,7 @@ class UsersService { * @param {Function} done [description] */ static async findOrCreateExternalUser(ctx, id, provider, displayName) { - let user = await UserModel.findOne({ + let user = await User.findOne({ profiles: { $elemMatch: { id, @@ -432,10 +424,10 @@ class UsersService { // User does not exist and need to be created. // Create an initial username for the user. - let username = await UsersService.getInitialUsername(displayName); + let username = await Users.getInitialUsername(displayName); // The user was not found, lets create them! - user = new UserModel({ + user = new User({ username, lowercaseUsername: username.toLowerCase(), profiles: [{ id, provider }], @@ -465,11 +457,7 @@ class UsersService { * @param {String} email the email for the user to send the email to */ static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) { - let token = await UsersService.createEmailConfirmToken( - user, - email, - redirectURI - ); + let token = await Users.createEmailConfirmToken(user, email, redirectURI); return mailer.send({ template: 'email-confirm', @@ -483,6 +471,43 @@ class UsersService { }); } + static async sendDownloadLink(user) { + // Check that the user has not already requested a download within the last + // 24 hours. + const attempts = await downloadLinkLimiter.get(user.id); + if (attempts && attempts >= 1) { + 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 JWT_SECRET.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'), + }); + } + + static async verifyDownloadToken(token) { + const jwt = await Users.verifyToken(token, { + subject: DOWNLOAD_LINK_SUBJECT, + }); + + return jwt; + } + static async sendEmail(user, options) { return mailer.send( merge({}, options, { @@ -494,7 +519,7 @@ class UsersService { static async changePassword(id, password) { const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - return UserModel.update( + return User.update( { id }, { $inc: { __v: 1 }, @@ -565,13 +590,13 @@ class UsersService { username = username.trim(); await Promise.all([ - UsersService.isValidUsername(username), - UsersService.isValidPassword(password), + Users.isValidUsername(username), + Users.isValidPassword(password), ]); const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - let user = new UserModel({ + let user = new User({ username, lowercaseUsername: username.toLowerCase(), password: hashedPassword, @@ -615,11 +640,7 @@ class UsersService { * @param {String} role role to add */ static setRole(id, role) { - return UserModel.update( - { id }, - { $set: { role } }, - { runValidators: true } - ); + return User.update({ id }, { $set: { role } }, { runValidators: true }); } /** @@ -627,7 +648,7 @@ class UsersService { * @param {String} id user id (uuid) */ static findById(id) { - return UserModel.findOne({ id }); + return User.findOne({ id }); } /** @@ -637,7 +658,7 @@ class UsersService { */ static async findOrCreateByIDToken(id, token) { // Try to get the user. - let user = await UserModel.findOne({ id }); + let user = await User.findOne({ id }); // If the user was not found, try to look it up. if (user === null) { @@ -654,7 +675,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findByIdArray(ids) { - return UserModel.find({ + return User.find({ id: { $in: ids }, }); } @@ -664,7 +685,7 @@ class UsersService { * @param {Array} ids array of user identifiers (uuid) */ static findPublicByIdArray(ids) { - return UserModel.find( + return User.find( { id: { $in: ids }, }, @@ -686,7 +707,7 @@ class UsersService { email = email.toLowerCase(); const [user, domainValidated] = await Promise.all([ - UserModel.findOne({ profiles: { $elemMatch: { id: email } } }), + User.findOne({ profiles: { $elemMatch: { id: email } } }), DomainList.urlCheck(loc), ]); if (!user) { @@ -744,11 +765,11 @@ class UsersService { throw new Error('cannot verify an empty token'); } - const { userId, loc, version } = await UsersService.verifyToken(token, { + const { userId, loc, version } = await Users.verifyToken(token, { subject: PASSWORD_RESET_JWT_SUBJECT, }); - const user = await UsersService.findById(userId); + const user = await Users.findById(userId); if (version !== user.__v) { throw new Error('password reset token has expired'); @@ -762,7 +783,7 @@ class UsersService { * @return {Promise} */ static count(query = {}) { - return UserModel.count(query); + return User.count(query); } /** @@ -770,7 +791,7 @@ class UsersService { * @return {Promise} */ static all() { - return UserModel.find(); + return User.find(); } /** @@ -778,7 +799,7 @@ class UsersService { * @return {Promise} */ static updateSettings(id, settings) { - return UserModel.update( + return User.update( { id, }, @@ -798,7 +819,7 @@ class UsersService { * @return {Promise} */ static addAction(item_id, user_id, action_type, metadata) { - return ActionsService.create({ + return Actions.create({ item_id, item_type: 'users', user_id, @@ -861,11 +882,11 @@ class UsersService { throw new Error('cannot verify an empty token'); } - const decoded = await UsersService.verifyToken(token, { + const decoded = await Users.verifyToken(token, { subject: EMAIL_CONFIRM_JWT_SUBJECT, }); - const user = await UserModel.findOne({ + const user = await User.findOne({ id: decoded.userID, profiles: { $elemMatch: { @@ -898,13 +919,11 @@ class UsersService { * @return {Promise} */ static async verifyEmailConfirmation(token) { - let { - userID, - email, - referer, - } = await UsersService.verifyEmailConfirmationToken(token); + let { userID, email, referer } = await Users.verifyEmailConfirmationToken( + token + ); - await UsersService.confirmEmail(userID, email); + await Users.confirmEmail(userID, email); return { userID, email, referer }; } @@ -913,7 +932,7 @@ class UsersService { * Marks the email on the user as confirmed. */ static confirmEmail(id, email) { - return UserModel.update( + return User.update( { id, profiles: { @@ -941,12 +960,12 @@ class UsersService { throw new Error('Users cannot ignore themselves'); } - const users = await UsersService.findByIdArray(usersToIgnore); + const users = await Users.findByIdArray(usersToIgnore); if (some(users, user => user.isStaff())) { throw errors.ErrCannotIgnoreStaff; } - return UserModel.update( + return User.update( { id }, { $addToSet: { @@ -964,7 +983,7 @@ class UsersService { * @param {Array} usersToStopIgnoring Array of user IDs to stop ignoring */ static async stopIgnoringUsers(id, usersToStopIgnoring) { - await UserModel.update( + await User.update( { id }, { $pullAll: { @@ -975,7 +994,7 @@ class UsersService { } } -module.exports = UsersService; +module.exports = Users; // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. diff --git a/views/account/download.ejs b/views/account/download.ejs new file mode 100644 index 000000000..d8bdc6e27 --- /dev/null +++ b/views/account/download.ejs @@ -0,0 +1,47 @@ + + + + + <%= t('download_landing.download_your_account') %> + + + <%- include ../partials/head %> + + +
+
+
+ <%= t('download_landing.click_to_download') %> + +
+
+ + + + 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 12bb886032b91d5efba2b38982275f5d485822d2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 13 Apr 2018 18:24:46 -0600 Subject: [PATCH 10/15] itterated on copy --- locales/en.yml | 4 +- .../server/emails/download.html.ejs | 2 +- .../server/emails/download.txt.ejs | 2 +- .../talk-plugin-profile-data/server/errors.js | 18 ++++++ .../server/mutators.js | 11 +++- .../talk-plugin-profile-data/server/router.js | 6 +- .../server/views/download.ejs | 31 +++++---- .../talk-plugin-profile-data/translations.yml | 20 ++++-- public/css/admin.css | 63 +++++++++++++++---- routes/api/v1/account.js | 2 +- views/account/email/confirm.ejs | 24 +++---- views/account/password/reset.ejs | 45 +++++++------ views/embed/stream.ejs | 2 - views/partials/head.ejs | 6 ++ 14 files changed, 158 insertions(+), 78 deletions(-) create mode 100644 plugins/talk-plugin-profile-data/server/errors.js diff --git a/locales/en.yml b/locales/en.yml index 6b2330f27..50845b96f 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -20,11 +20,13 @@ en: bio_offensive: "This bio is offensive" cancel: "Cancel" confirm_email: - click_to_confirm: "Click below to confirm your email address" + email_confirmation: "Email Confirmation" + click_to_confirm: "Click below to confirm your email address." confirm: "Confirm" password_reset: mail_sent: 'If you have a registered account, a password reset link was sent to that email' set_new_password: "Change Your Password" + change_password_help: "Please enter a new password to use to login. Make it secure!" new_password: "New Password" new_password_help: "Password must be at least 8 characters" confirm_new_password: "Confirm New Password" diff --git a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs index c6aeb8e1e..00b6ad372 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs @@ -1 +1 @@ -

<%= t('email.download.download_link_ready') %> <%= t('email.download.download_archive') %>

+

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

diff --git a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs index 4f719bc06..138ea1088 100644 --- a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs +++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs @@ -1,3 +1,3 @@ -<%= t('email.download.download_link_ready') %> +<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= BASE_URL %>account/download#<%= token %> diff --git a/plugins/talk-plugin-profile-data/server/errors.js b/plugins/talk-plugin-profile-data/server/errors.js new file mode 100644 index 000000000..6261ebe89 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/errors.js @@ -0,0 +1,18 @@ +const { TalkError } = require('errors'); + +// ErrDownloadToken is returned in the event that the download is requested +// without a valid token. +class ErrDownloadToken extends TalkError { + constructor(err) { + super( + 'Token is invalid', + { + translation_key: 'DOWNLOAD_TOKEN_INVALID', + status: 400, + }, + { err } + ); + } +} + +module.exports = { ErrDownloadToken }; diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js index e5d717978..5a897a2e1 100644 --- a/plugins/talk-plugin-profile-data/server/mutators.js +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -4,6 +4,7 @@ const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); async function sendDownloadLink({ user, + loaders: { Settings }, connectors: { errors, secrets, @@ -43,19 +44,25 @@ async function sendDownloadLink({ { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT } ); + const now = new Date(); + + const { organizationName } = await Settings.load('organizationName'); + // Send the download link via the user's attached email account. await Users.sendEmail(user, { template: 'download', locals: { token, + organizationName, + now, }, - subject: I18n.t('email.download.subject'), + subject: I18n.t('email.download.subject', organizationName), }); // Amend the lastAccountDownload on the user. await User.update( { id: user.id }, - { $set: { 'metadata.lastAccountDownload': new Date() } } + { $set: { 'metadata.lastAccountDownload': now } } ); } diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js index 5ef3b0cfc..00061c173 100644 --- a/plugins/talk-plugin-profile-data/server/router.js +++ b/plugins/talk-plugin-profile-data/server/router.js @@ -5,6 +5,7 @@ const { get, pick, kebabCase } = require('lodash'); const moment = require('moment'); const archiver = require('archiver'); const stringify = require('csv-stringify'); +const { ErrDownloadToken } = require('./errors'); async function verifyDownloadToken( { connectors: { services: { Users } } }, @@ -109,10 +110,7 @@ module.exports = router => { // 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')); + return next(new ErrDownloadToken(err)); } res.status(204).end(); diff --git a/plugins/talk-plugin-profile-data/server/views/download.ejs b/plugins/talk-plugin-profile-data/server/views/download.ejs index 53ee8a4e9..bc8e0aa54 100644 --- a/plugins/talk-plugin-profile-data/server/views/download.ejs +++ b/plugins/talk-plugin-profile-data/server/views/download.ejs @@ -1,19 +1,26 @@ - <%= t('download_landing.download_your_account') %> - - - <%- include(root + '/partials/head') %> + <%- include(root + '/partials/account') %> - +
-
-
- <%= t('download_landing.click_to_download') %> - -
+
+

<%= t('download_landing.download_your_account') %>

+

<%= t('download_landing.download_details') %>

+

<%= t('download_landing.all_information_included') %>

+
    +
  • <%= t('download_landing.information_included.date') %>
  • +
  • <%= t('download_landing.information_included.url') %>
  • +
  • <%= t('download_landing.information_included.body') %>
  • +
  • <%= t('download_landing.information_included.asset_url') %>
  • +
+
+
+ +
+