Merge pull request #7752 from overleaf/em-promisify-user-updater

Finish promisification of UserUpdater

GitOrigin-RevId: 8f32b2248cfd0db4232bd808f337c17bd7f7dbf4
This commit is contained in:
Eric Mc Sween 2022-04-27 08:02:40 -04:00 committed by Copybot
parent cb69f49d04
commit a1ff7d8274
3 changed files with 723 additions and 908 deletions

View file

@ -267,9 +267,13 @@ const AuthenticationController = {
() => {} () => {}
) )
} }
return UserUpdater.updateUser(user._id.toString(), { return UserUpdater.updateUser(
$set: { lastLoginIp: req.ip }, user._id.toString(),
}) {
$set: { lastLoginIp: req.ip },
},
() => {}
)
}, },
requireLogin() { requireLogin() {

View file

@ -2,14 +2,9 @@ const logger = require('@overleaf/logger')
const OError = require('@overleaf/o-error') const OError = require('@overleaf/o-error')
const { db } = require('../../infrastructure/mongodb') const { db } = require('../../infrastructure/mongodb')
const { normalizeQuery } = require('../Helpers/Mongo') const { normalizeQuery } = require('../Helpers/Mongo')
const metrics = require('@overleaf/metrics') const { callbackify } = require('util')
const async = require('async')
const { callbackify, promisify } = require('util')
const UserGetter = require('./UserGetter') const UserGetter = require('./UserGetter')
const { const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
addAffiliation,
promises: InstitutionsAPIPromises,
} = require('../Institutions/InstitutionsAPI')
const Features = require('../../infrastructure/Features') const Features = require('../../infrastructure/Features')
const FeaturesUpdater = require('../Subscription/FeaturesUpdater') const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
const EmailHandler = require('../Email/EmailHandler') const EmailHandler = require('../Email/EmailHandler')
@ -56,16 +51,19 @@ async function _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) {
// Next, get extra recipients with affiliation data // Next, get extra recipients with affiliation data
const emailsData = await UserGetter.promises.getUserFullEmails(userId) const emailsData = await UserGetter.promises.getUserFullEmails(userId)
const extraRecipients = const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
UserUpdater.securityAlertPrimaryEmailChangedExtraRecipients( emailsData,
emailsData, oldEmail,
oldEmail, email
email )
)
await sendToRecipients(extraRecipients) await sendToRecipients(extraRecipients)
} }
/**
* Add a new email address for the user. Email cannot be already used by this
* or any other user
*/
async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) { async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
newEmail = EmailHelper.parseEmail(newEmail) newEmail = EmailHelper.parseEmail(newEmail)
if (!newEmail) { if (!newEmail) {
@ -87,7 +85,7 @@ async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
) )
try { try {
await InstitutionsAPIPromises.addAffiliation( await InstitutionsAPI.promises.addAffiliation(
userId, userId,
newEmail, newEmail,
affiliationOptions affiliationOptions
@ -103,7 +101,7 @@ async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
emails: { email: newEmail, createdAt: new Date(), reversedHostname }, emails: { email: newEmail, createdAt: new Date(), reversedHostname },
}, },
} }
await UserUpdater.promises.updateUser(userId, update) await updateUser(userId, update)
} catch (error) { } catch (error) {
throw OError.tag(error, 'problem updating users emails') throw OError.tag(error, 'problem updating users emails')
} }
@ -129,10 +127,10 @@ async function clearSAMLData(userId, auditLog, sendEmail) {
'emails.$[].samlProviderId': 1, 'emails.$[].samlProviderId': 1,
}, },
} }
await UserUpdater.promises.updateUser(userId, update) await updateUser(userId, update)
for (const emailData of user.emails) { for (const emailData of user.emails) {
await InstitutionsAPIPromises.removeEntitlement(userId, emailData.email) await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
} }
await FeaturesUpdater.promises.refreshFeatures( await FeaturesUpdater.promises.refreshFeatures(
@ -145,6 +143,10 @@ async function clearSAMLData(userId, auditLog, sendEmail) {
} }
} }
/**
* set the default email address by setting the `email` attribute. The email
* must be one of the user's multiple emails (`emails` attribute)
*/
async function setDefaultEmailAddress( async function setDefaultEmailAddress(
userId, userId,
email, email,
@ -187,7 +189,7 @@ async function setDefaultEmailAddress(
const query = { _id: userId, 'emails.email': email } const query = { _id: userId, 'emails.email': email }
const update = { $set: { email, lastPrimaryEmailCheck: new Date() } } const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
const res = await UserUpdater.promises.updateUser(query, update) const res = await updateUser(query, update)
// this should not happen // this should not happen
if (res.matchedCount !== 1) { if (res.matchedCount !== 1) {
@ -198,7 +200,11 @@ async function setDefaultEmailAddress(
if (sendSecurityAlert) { if (sendSecurityAlert) {
// no need to wait, errors are logged and not passed back // no need to wait, errors are logged and not passed back
_sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email) _sendSecurityAlertPrimaryEmailChanged(userId, oldEmail, email).catch(
err => {
logger.error({ err }, 'failed to send security alert email')
}
)
} }
try { try {
@ -228,7 +234,9 @@ async function confirmEmail(userId, email) {
logger.log({ userId, email }, 'confirming user email') logger.log({ userId, email }, 'confirming user email')
try { try {
await InstitutionsAPIPromises.addAffiliation(userId, email, { confirmedAt }) await InstitutionsAPI.promises.addAffiliation(userId, email, {
confirmedAt,
})
} catch (error) { } catch (error) {
throw OError.tag(error, 'problem adding affiliation while confirming email') throw OError.tag(error, 'problem adding affiliation while confirming email')
} }
@ -254,7 +262,7 @@ async function confirmEmail(userId, email) {
} }
} }
const res = await UserUpdater.promises.updateUser(query, update) const res = await updateUser(query, update)
if (res.matchedCount !== 1) { if (res.matchedCount !== 1) {
throw new Errors.NotFoundError('user id and email do no match') throw new Errors.NotFoundError('user id and email do no match')
@ -283,7 +291,7 @@ async function removeEmailAddress(userId, email, skipParseEmail = false) {
} }
try { try {
await InstitutionsAPIPromises.removeAffiliation(userId, email) await InstitutionsAPI.promises.removeAffiliation(userId, email)
} catch (error) { } catch (error) {
OError.tag(error, 'problem removing affiliation') OError.tag(error, 'problem removing affiliation')
throw error throw error
@ -294,7 +302,7 @@ async function removeEmailAddress(userId, email, skipParseEmail = false) {
let res let res
try { try {
res = await UserUpdater.promises.updateUser(query, update) res = await updateUser(query, update)
} catch (error) { } catch (error) {
OError.tag(error, 'problem removing users email') OError.tag(error, 'problem removing users email')
throw error throw error
@ -307,177 +315,125 @@ async function removeEmailAddress(userId, email, skipParseEmail = false) {
await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email') await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
} }
const UserUpdater = { async function addAffiliationForNewUser(
addAffiliationForNewUser(userId, email, affiliationOptions, callback) { userId,
if (callback == null) { email,
// affiliationOptions is optional affiliationOptions = {}
callback = affiliationOptions ) {
affiliationOptions = {} await InstitutionsAPI.promises.addAffiliation(
} userId,
addAffiliation(userId, email, affiliationOptions, error => { email,
if (error) { affiliationOptions
return callback(error) )
} try {
UserUpdater.updateUser( await updateUser(
{ _id: userId, 'emails.email': email }, { _id: userId, 'emails.email': email },
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }, { $unset: { 'emails.$.affiliationUnchecked': 1 } }
error => {
if (error) {
callback(
OError.tag(
error,
'could not remove affiliationUnchecked flag for user on create',
{
userId,
email,
}
)
)
} else {
callback()
}
}
)
})
},
updateUser(query, update, callback) {
if (callback == null) {
callback = () => {}
}
try {
query = normalizeQuery(query)
} catch (err) {
return callback(err)
}
db.users.updateOne(query, update, callback)
},
//
// DEPRECATED
//
// Change the user's main email address by adding a new email, switching the
// default email and removing the old email. Prefer manipulating multiple
// emails and the default rather than calling this method directly
//
changeEmailAddress(userId, newEmail, auditLog, callback) {
newEmail = EmailHelper.parseEmail(newEmail)
if (newEmail == null) {
return callback(new Error('invalid email'))
}
let oldEmail = null
async.series(
[
cb =>
UserGetter.getUserEmail(userId, (error, email) => {
oldEmail = email
cb(error)
}),
cb => UserUpdater.addEmailAddress(userId, newEmail, {}, auditLog, cb),
cb =>
UserUpdater.setDefaultEmailAddress(
userId,
newEmail,
true,
auditLog,
true,
cb
),
cb => UserUpdater.removeEmailAddress(userId, oldEmail, cb),
],
callback
) )
}, } catch (error) {
throw OError.tag(
// Add a new email address for the user. Email cannot be already used by this error,
// or any other user 'could not remove affiliationUnchecked flag for user on create',
addEmailAddress: callbackify(addEmailAddress),
removeEmailAddress: callbackify(removeEmailAddress),
clearSAMLData: callbackify(clearSAMLData),
// set the default email address by setting the `email` attribute. The email
// must be one of the user's multiple emails (`emails` attribute)
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
confirmEmail: callbackify(confirmEmail),
removeReconfirmFlag(userId, callback) {
UserUpdater.updateUser(
userId.toString(),
{ {
$set: { must_reconfirm: false }, userId,
}, email,
error => callback(error) }
) )
}, }
securityAlertPrimaryEmailChangedExtraRecipients(emailsData, oldEmail, email) {
// Group by institution if we have it, or domain if we dont, and for each group send to the most recently
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
// primary email addresses because they are emailed separately
// See #6101.
function sortEmailsByConfirmation(emails) {
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
}
const recipients = new Set()
const emailsToIgnore = new Set([oldEmail, email])
// Remove non-confirmed emails
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
// to avoid any potential clash
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
if (!emailData.affiliation || !emailData.affiliation.institution) {
return `domain:${EmailHelper.getDomain(emailData.email)}`
}
return `institution_id:${emailData.affiliation.institution.id}`
})
// For each group of emails, order the emails by (re-)confirmation date and pick the first
for (const emails of Object.values(groupedEmails)) {
// Sort by confirmation and pick the first
sortEmailsByConfirmation(emails)
// Ignore original and new primary email addresses
const recipient = emails[0].email
if (!emailsToIgnore.has(recipient)) {
recipients.add(emails[0].email)
}
}
return Array.from(recipients)
},
}
;[
'updateUser',
'changeEmailAddress',
'setDefaultEmailAddress',
'addEmailAddress',
'removeEmailAddress',
'removeReconfirmFlag',
].map(method =>
metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)
)
const promises = {
addAffiliationForNewUser: promisify(UserUpdater.addAffiliationForNewUser),
addEmailAddress,
clearSAMLData,
confirmEmail,
setDefaultEmailAddress,
updateUser: promisify(UserUpdater.updateUser),
removeEmailAddress,
removeReconfirmFlag: promisify(UserUpdater.removeReconfirmFlag),
} }
UserUpdater.promises = promises async function updateUser(query, update) {
query = normalizeQuery(query)
const result = await db.users.updateOne(query, update)
return result
}
module.exports = UserUpdater /**
* DEPRECATED
*
* Change the user's main email address by adding a new email, switching the
* default email and removing the old email. Prefer manipulating multiple
* emails and the default rather than calling this method directly
*/
async function changeEmailAddress(userId, newEmail, auditLog) {
newEmail = EmailHelper.parseEmail(newEmail)
if (newEmail == null) {
throw new Error('invalid email')
}
const oldEmail = await UserGetter.promises.getUserEmail(userId)
await addEmailAddress(userId, newEmail, {}, auditLog)
await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
await removeEmailAddress(userId, oldEmail)
}
async function removeReconfirmFlag(userId) {
await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
}
function _securityAlertPrimaryEmailChangedExtraRecipients(
emailsData,
oldEmail,
email
) {
// Group by institution if we have it, or domain if we dont, and for each group send to the most recently
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
// primary email addresses because they are emailed separately
// See #6101.
function sortEmailsByConfirmation(emails) {
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
}
const recipients = new Set()
const emailsToIgnore = new Set([oldEmail, email])
// Remove non-confirmed emails
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
// to avoid any potential clash
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
if (!emailData.affiliation || !emailData.affiliation.institution) {
return `domain:${EmailHelper.getDomain(emailData.email)}`
}
return `institution_id:${emailData.affiliation.institution.id}`
})
// For each group of emails, order the emails by (re-)confirmation date and pick the first
for (const emails of Object.values(groupedEmails)) {
// Sort by confirmation and pick the first
sortEmailsByConfirmation(emails)
// Ignore original and new primary email addresses
const recipient = emails[0].email
if (!emailsToIgnore.has(recipient)) {
recipients.add(emails[0].email)
}
}
return Array.from(recipients)
}
module.exports = {
addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
addEmailAddress: callbackify(addEmailAddress),
changeEmailAddress: callbackify(changeEmailAddress),
clearSAMLData: callbackify(clearSAMLData),
confirmEmail: callbackify(confirmEmail),
removeEmailAddress: callbackify(removeEmailAddress),
removeReconfirmFlag: callbackify(removeReconfirmFlag),
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
updateUser: callbackify(updateUser),
promises: {
addAffiliationForNewUser,
addEmailAddress,
changeEmailAddress,
clearSAMLData,
confirmEmail,
removeEmailAddress,
removeReconfirmFlag,
setDefaultEmailAddress,
updateUser,
},
}

File diff suppressed because it is too large Load diff