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