mirror of
https://github.com/wassname/talk.git
synced 2026-07-27 11:28:12 +08:00
improved routing, cleaned mail
This commit is contained in:
@@ -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.
|
||||
|
||||
+19
-19
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+67
-39
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
+7
-14
@@ -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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<p><%= t('email.download.download_link_ready') %> <a href="<%= BASE_URL %>account/download#<%= token %>"><%= t('email.download.download_archive') %></a></p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<%= t('email.download.download_link_ready') %>
|
||||
|
||||
<%= BASE_URL %>account/download#<%= token %>
|
||||
@@ -1,3 +1,3 @@
|
||||
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
|
||||
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
|
||||
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>account/email/confirm#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
|
||||
<p><%= t('email.confirm.if_you_did_not') %></p>
|
||||
|
||||
@@ -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') %>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<p><%= t('email.password_reset.we_received_a_request') %><br />
|
||||
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>admin/password-reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
|
||||
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>account/password/reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
+96
-77
@@ -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<String>} 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.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title><%= t('download_landing.download_your_account') %></title>
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
|
||||
<%- include ../partials/head %>
|
||||
</head>
|
||||
<body class="confirm-email-page">
|
||||
<div id="root">
|
||||
<div class="error-console container"></div>
|
||||
<form id="download-form" class="container" method="post" action="<%= BASE_PATH %>api/v1/account/download">
|
||||
<legend class="legend"><%= t('download_landing.click_to_download') %></legend>
|
||||
<button type="submit"><%= t('download_landing.confirm') %></button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
function showError(error) {
|
||||
try {
|
||||
let err = JSON.parse(error);
|
||||
$('.error-console').text(err.message).addClass('active');
|
||||
} catch (err) {
|
||||
$('.error-console').text(error).addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
var token = location.hash.replace('#', '');
|
||||
|
||||
$.ajax({
|
||||
url: '<%= BASE_PATH %>api/v1/account/download',
|
||||
contentType: 'application/json',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({token: token, check: true})
|
||||
})
|
||||
.then(function () {
|
||||
$('#download-form').append('<input name="token" type="hidden" value="' + token + '"/>').fadeIn();
|
||||
})
|
||||
.catch(function (error) {
|
||||
showError(error.responseText);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>Email Verification</title>
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
|
||||
<%- include ../partials/head %>
|
||||
<%- include ../../partials/head %>
|
||||
</head>
|
||||
<body class="confirm-email-page">
|
||||
<div id="root">
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>Password Reset</title>
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
|
||||
<%- include ../partials/head %>
|
||||
<%- include ../../partials/head %>
|
||||
</head>
|
||||
<body class="password-reset-page">
|
||||
<div id="root">
|
||||
@@ -23,7 +23,7 @@
|
||||
<main>
|
||||
<h1><%= title %></h1>
|
||||
<p><%= body %></p>
|
||||
<p><a href="<%= BASE_PATH %>admin">Admin</a> - <a href="<%= BASE_PATH %>assets">All Assets</a></p>
|
||||
<p><a href="<%= BASE_PATH %>admin">Admin</a> - <a href="<%= BASE_PATH %>dev/assets">All Assets</a></p>
|
||||
<div id='coralStreamEmbed'></div>
|
||||
<script src="<%= resolve('embed.js') %>" async onload="
|
||||
window.TalkEmbed = Coral.Talk.render(document.getElementById('coralStreamEmbed'), {
|
||||
@@ -4,7 +4,7 @@
|
||||
Asset list
|
||||
</h1>
|
||||
<% assets.forEach(function (asset) { %>
|
||||
<a href="<%= BASE_PATH %>assets/id/<%= asset.id %>"><%= asset.url %></a><br />
|
||||
<a href="<%= BASE_PATH %>dev/assets/id/<%= asset.id %>"><%= asset.url %></a><br />
|
||||
<% }) %>
|
||||
<p>
|
||||
(For dev use only. FYI, you can: ?skip=100&limit=25)
|
||||
Reference in New Issue
Block a user