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