Merge pull request #9466 from overleaf/jk-add-labs-program-for-galileo
[web] Add a new "Overleaf Labs" program, for Galileo GitOrigin-RevId: 8f6c79c37c1719a59bd8405998cc3de2fd29960d
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const crypto = require('crypto')
|
||||
const Mailchimp = require('mailchimp-api-v3')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const { callbackify } = require('util')
|
||||
|
||||
function mailchimpIsConfigured() {
|
||||
return Settings.mailchimp != null && Settings.mailchimp.api_key != null
|
||||
}
|
||||
|
||||
function make(listName, listId) {
|
||||
let provider
|
||||
if (!mailchimpIsConfigured() || !listId) {
|
||||
logger.debug({ listName }, 'Using newsletter provider: none')
|
||||
provider = makeNullProvider(listName)
|
||||
} else {
|
||||
logger.debug({ listName }, 'Using newsletter provider: mailchimp')
|
||||
provider = makeMailchimpProvider(listName, listId)
|
||||
}
|
||||
return {
|
||||
subscribed: callbackify(provider.subscribed),
|
||||
subscribe: callbackify(provider.subscribe),
|
||||
unsubscribe: callbackify(provider.unsubscribe),
|
||||
changeEmail: callbackify(provider.changeEmail),
|
||||
promises: provider,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
make,
|
||||
}
|
||||
|
||||
class NonFatalEmailUpdateError extends OError {
|
||||
constructor(message, oldEmail, newEmail) {
|
||||
super(message, { oldEmail, newEmail })
|
||||
}
|
||||
}
|
||||
|
||||
function makeMailchimpProvider(listName, listId) {
|
||||
const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
|
||||
const MAILCHIMP_LIST_ID = listId
|
||||
|
||||
return {
|
||||
subscribed,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
changeEmail,
|
||||
}
|
||||
|
||||
async function subscribed(user) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
const result = await mailchimp.get(path)
|
||||
return result?.status === 'subscribed'
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
return false
|
||||
}
|
||||
throw OError.tag(err, 'error getting newsletter subscriptions status', {
|
||||
userId: user._id,
|
||||
listName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(user) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
await mailchimp.put(path, {
|
||||
email_address: user.email,
|
||||
status: 'subscribed',
|
||||
status_if_new: 'subscribed',
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
logger.debug(
|
||||
{ user, listName },
|
||||
'finished subscribing user to newsletter'
|
||||
)
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'error subscribing user to newsletter', {
|
||||
userId: user._id,
|
||||
listName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(user, options = {}) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
if (options.delete) {
|
||||
await mailchimp.delete(path)
|
||||
} else {
|
||||
await mailchimp.patch(path, {
|
||||
status: 'unsubscribed',
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
}
|
||||
logger.debug(
|
||||
{ user, options, listName },
|
||||
'finished unsubscribing user from newsletter'
|
||||
)
|
||||
} catch (err) {
|
||||
if (err.status === 404 || err.status === 405) {
|
||||
// silently ignore users who were never subscribed (404) or previously deleted (405)
|
||||
return
|
||||
}
|
||||
|
||||
if (err.message.includes('looks fake or invalid')) {
|
||||
logger.debug(
|
||||
{ err, user, options, listName },
|
||||
'Mailchimp declined to unsubscribe user because it finds the email looks fake'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
throw OError.tag(err, 'error unsubscribing user from newsletter', {
|
||||
userId: user._id,
|
||||
listName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEmail(user, newEmail) {
|
||||
const oldEmail = user.email
|
||||
|
||||
try {
|
||||
await updateEmailInMailchimp(user, newEmail)
|
||||
} catch (updateError) {
|
||||
// if we failed to update the user, delete their old email address so that
|
||||
// we don't leave it stuck in mailchimp
|
||||
logger.debug(
|
||||
{ oldEmail, newEmail, updateError, listName },
|
||||
'unable to change email in newsletter, removing old mail'
|
||||
)
|
||||
|
||||
try {
|
||||
await unsubscribe(user, { delete: true })
|
||||
} catch (unsubscribeError) {
|
||||
// something went wrong removing the user's address
|
||||
throw OError.tag(
|
||||
unsubscribeError,
|
||||
'error unsubscribing old email in response to email change failure',
|
||||
{ oldEmail, newEmail, updateError, listName }
|
||||
)
|
||||
}
|
||||
|
||||
if (!(updateError instanceof NonFatalEmailUpdateError)) {
|
||||
throw updateError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmailInMailchimp(user, newEmail) {
|
||||
const oldEmail = user.email
|
||||
|
||||
// mailchimp doesn't give us error codes, so we have to parse the message :'(
|
||||
const errors = {
|
||||
'merge fields were invalid': 'user has never subscribed',
|
||||
'could not be validated':
|
||||
'user has previously unsubscribed or new email already exist on list',
|
||||
'is already a list member': 'new email is already on mailing list',
|
||||
'looks fake or invalid': 'mail looks fake to mailchimp',
|
||||
}
|
||||
|
||||
try {
|
||||
const path = getSubscriberPath(oldEmail)
|
||||
await mailchimp.patch(path, {
|
||||
email_address: newEmail,
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
logger.debug(
|
||||
{ newEmail, listName },
|
||||
'finished changing email in the newsletter'
|
||||
)
|
||||
} catch (err) {
|
||||
// silently ignore users who were never subscribed
|
||||
if (err.status === 404) {
|
||||
return
|
||||
}
|
||||
|
||||
// look through expected mailchimp errors and log if we find one
|
||||
Object.keys(errors).forEach(key => {
|
||||
if (err.message.includes(key)) {
|
||||
const message = `unable to change email in newsletter, ${errors[key]}`
|
||||
|
||||
logger.debug({ oldEmail, newEmail, listName }, message)
|
||||
|
||||
throw new NonFatalEmailUpdateError(
|
||||
message,
|
||||
oldEmail,
|
||||
newEmail
|
||||
).withCause(err)
|
||||
}
|
||||
})
|
||||
|
||||
// if we didn't find an expected error, generate something to throw
|
||||
throw OError.tag(err, 'error changing email in newsletter', {
|
||||
oldEmail,
|
||||
newEmail,
|
||||
listName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriberPath(email) {
|
||||
const emailHash = hashEmail(email)
|
||||
return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}`
|
||||
}
|
||||
|
||||
function hashEmail(email) {
|
||||
return crypto.createHash('md5').update(email.toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
function getMergeFields(user) {
|
||||
return {
|
||||
FNAME: user.first_name,
|
||||
LNAME: user.last_name,
|
||||
MONGO_ID: user._id.toString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeNullProvider(listName) {
|
||||
return {
|
||||
subscribed,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
changeEmail,
|
||||
}
|
||||
|
||||
async function subscribed(user) {
|
||||
logger.debug(
|
||||
{ user, listName },
|
||||
'Not checking user because no newsletter provider is configured'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
async function subscribe(user) {
|
||||
logger.debug(
|
||||
{ user, listName },
|
||||
'Not subscribing user to newsletter because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
|
||||
async function unsubscribe(user) {
|
||||
logger.debug(
|
||||
{ user, listName },
|
||||
'Not unsubscribing user from newsletter because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
|
||||
async function changeEmail(user, newEmail) {
|
||||
logger.debug(
|
||||
{ userId: user._id, newEmail, listName },
|
||||
'Not changing email in newsletter for user because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,248 +1,9 @@
|
||||
const { callbackify } = require('util')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const crypto = require('crypto')
|
||||
const Mailchimp = require('mailchimp-api-v3')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const MailchimpProvider = require('./MailChimpProvider')
|
||||
|
||||
const provider = getProvider()
|
||||
const provider = MailchimpProvider.make(
|
||||
'newsletter',
|
||||
Settings.mailchimp ? Settings.mailchimp.list_id : null
|
||||
)
|
||||
|
||||
module.exports = {
|
||||
subscribed: callbackify(provider.subscribed),
|
||||
subscribe: callbackify(provider.subscribe),
|
||||
unsubscribe: callbackify(provider.unsubscribe),
|
||||
changeEmail: callbackify(provider.changeEmail),
|
||||
promises: provider,
|
||||
}
|
||||
|
||||
class NonFatalEmailUpdateError extends OError {
|
||||
constructor(message, oldEmail, newEmail) {
|
||||
super(message, { oldEmail, newEmail })
|
||||
}
|
||||
}
|
||||
|
||||
function getProvider() {
|
||||
if (mailchimpIsConfigured()) {
|
||||
logger.debug('Using newsletter provider: mailchimp')
|
||||
return makeMailchimpProvider()
|
||||
} else {
|
||||
logger.debug('Using newsletter provider: none')
|
||||
return makeNullProvider()
|
||||
}
|
||||
}
|
||||
|
||||
function mailchimpIsConfigured() {
|
||||
return Settings.mailchimp != null && Settings.mailchimp.api_key != null
|
||||
}
|
||||
|
||||
function makeMailchimpProvider() {
|
||||
const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
|
||||
const MAILCHIMP_LIST_ID = Settings.mailchimp.list_id
|
||||
|
||||
return {
|
||||
subscribed,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
changeEmail,
|
||||
}
|
||||
|
||||
async function subscribed(user) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
const result = await mailchimp.get(path)
|
||||
return result?.status === 'subscribed'
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
return false
|
||||
}
|
||||
throw OError.tag(err, 'error getting newsletter subscriptions status', {
|
||||
userId: user._id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(user) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
await mailchimp.put(path, {
|
||||
email_address: user.email,
|
||||
status: 'subscribed',
|
||||
status_if_new: 'subscribed',
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
logger.debug({ user }, 'finished subscribing user to newsletter')
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'error subscribing user to newsletter', {
|
||||
userId: user._id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(user, options = {}) {
|
||||
try {
|
||||
const path = getSubscriberPath(user.email)
|
||||
if (options.delete) {
|
||||
await mailchimp.delete(path)
|
||||
} else {
|
||||
await mailchimp.patch(path, {
|
||||
status: 'unsubscribed',
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
}
|
||||
logger.debug(
|
||||
{ user, options },
|
||||
'finished unsubscribing user from newsletter'
|
||||
)
|
||||
} catch (err) {
|
||||
if (err.status === 404 || err.status === 405) {
|
||||
// silently ignore users who were never subscribed (404) or previously deleted (405)
|
||||
return
|
||||
}
|
||||
|
||||
if (err.message.includes('looks fake or invalid')) {
|
||||
logger.debug(
|
||||
{ err, user, options },
|
||||
'Mailchimp declined to unsubscribe user because it finds the email looks fake'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
throw OError.tag(err, 'error unsubscribing user from newsletter', {
|
||||
userId: user._id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEmail(user, newEmail) {
|
||||
const oldEmail = user.email
|
||||
|
||||
try {
|
||||
await updateEmailInMailchimp(user, newEmail)
|
||||
} catch (updateError) {
|
||||
// if we failed to update the user, delete their old email address so that
|
||||
// we don't leave it stuck in mailchimp
|
||||
logger.debug(
|
||||
{ oldEmail, newEmail, updateError },
|
||||
'unable to change email in newsletter, removing old mail'
|
||||
)
|
||||
|
||||
try {
|
||||
await unsubscribe(user, { delete: true })
|
||||
} catch (unsubscribeError) {
|
||||
// something went wrong removing the user's address
|
||||
throw OError.tag(
|
||||
unsubscribeError,
|
||||
'error unsubscribing old email in response to email change failure',
|
||||
{ oldEmail, newEmail, updateError }
|
||||
)
|
||||
}
|
||||
|
||||
if (!(updateError instanceof NonFatalEmailUpdateError)) {
|
||||
throw updateError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmailInMailchimp(user, newEmail) {
|
||||
const oldEmail = user.email
|
||||
|
||||
// mailchimp doesn't give us error codes, so we have to parse the message :'(
|
||||
const errors = {
|
||||
'merge fields were invalid': 'user has never subscribed',
|
||||
'could not be validated':
|
||||
'user has previously unsubscribed or new email already exist on list',
|
||||
'is already a list member': 'new email is already on mailing list',
|
||||
'looks fake or invalid': 'mail looks fake to mailchimp',
|
||||
}
|
||||
|
||||
try {
|
||||
const path = getSubscriberPath(oldEmail)
|
||||
await mailchimp.patch(path, {
|
||||
email_address: newEmail,
|
||||
merge_fields: getMergeFields(user),
|
||||
})
|
||||
logger.debug('finished changing email in the newsletter')
|
||||
} catch (err) {
|
||||
// silently ignore users who were never subscribed
|
||||
if (err.status === 404) {
|
||||
return
|
||||
}
|
||||
|
||||
// look through expected mailchimp errors and log if we find one
|
||||
Object.keys(errors).forEach(key => {
|
||||
if (err.message.includes(key)) {
|
||||
const message = `unable to change email in newsletter, ${errors[key]}`
|
||||
|
||||
logger.debug({ oldEmail, newEmail }, message)
|
||||
|
||||
throw new NonFatalEmailUpdateError(
|
||||
message,
|
||||
oldEmail,
|
||||
newEmail
|
||||
).withCause(err)
|
||||
}
|
||||
})
|
||||
|
||||
// if we didn't find an expected error, generate something to throw
|
||||
throw OError.tag(err, 'error changing email in newsletter', {
|
||||
oldEmail,
|
||||
newEmail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriberPath(email) {
|
||||
const emailHash = hashEmail(email)
|
||||
return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}`
|
||||
}
|
||||
|
||||
function hashEmail(email) {
|
||||
return crypto.createHash('md5').update(email.toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
function getMergeFields(user) {
|
||||
return {
|
||||
FNAME: user.first_name,
|
||||
LNAME: user.last_name,
|
||||
MONGO_ID: user._id.toString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeNullProvider() {
|
||||
return {
|
||||
subscribed,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
changeEmail,
|
||||
}
|
||||
|
||||
async function subscribed(user) {
|
||||
logger.debug(
|
||||
{ user },
|
||||
'Not checking user because no newsletter provider is configured'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
async function subscribe(user) {
|
||||
logger.debug(
|
||||
{ user },
|
||||
'Not subscribing user to newsletter because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
|
||||
async function unsubscribe(user) {
|
||||
logger.debug(
|
||||
{ user },
|
||||
'Not unsubscribing user from newsletter because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
|
||||
async function changeEmail(oldEmail, newEmail) {
|
||||
logger.debug(
|
||||
{ oldEmail, newEmail },
|
||||
'Not changing email in newsletter for user because no newsletter provider is configured'
|
||||
)
|
||||
}
|
||||
}
|
||||
module.exports = provider
|
||||
|
||||
@@ -765,7 +765,7 @@ const ProjectController = {
|
||||
)
|
||||
User.findById(
|
||||
userId,
|
||||
'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace',
|
||||
'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsProgramGalileo',
|
||||
(err, user) => {
|
||||
// Handle case of deleted user
|
||||
if (user == null) {
|
||||
@@ -1197,6 +1197,8 @@ const ProjectController = {
|
||||
refProviders: _.mapValues(user.refProviders, Boolean),
|
||||
alphaProgram: user.alphaProgram,
|
||||
betaProgram: user.betaProgram,
|
||||
labsProgram: user.labsProgram,
|
||||
labsProgramGalileo: user.labsProgramGalileo,
|
||||
isAdmin: hasAdminAccess(user),
|
||||
},
|
||||
userSettings: {
|
||||
|
||||
@@ -290,8 +290,16 @@ const UserController = {
|
||||
OError.tag(err, 'error unsubscribing to newsletter')
|
||||
return next(err)
|
||||
}
|
||||
return res.json({
|
||||
message: req.i18n.translate('thanks_settings_updated'),
|
||||
// TODO: figure out why things go wrong if we import at the top
|
||||
const Modules = require('../../infrastructure/Modules')
|
||||
Modules.hooks.fire('newsletterUnsubscribed', user, err => {
|
||||
if (err) {
|
||||
OError.tag(err, 'error firing "newsletterUnsubscribed" hook')
|
||||
return next(err)
|
||||
}
|
||||
return res.json({
|
||||
message: req.i18n.translate('thanks_settings_updated'),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ async function settingsPage(req, res) {
|
||||
last_name: user.last_name,
|
||||
alphaProgram: user.alphaProgram,
|
||||
betaProgram: user.betaProgram,
|
||||
labsProgram: user.labsProgram,
|
||||
features: {
|
||||
dropbox: user.features.dropbox,
|
||||
github: user.features.github,
|
||||
|
||||
@@ -217,6 +217,16 @@ async function setDefaultEmailAddress(
|
||||
'Failed to change email in newsletter subscription'
|
||||
)
|
||||
}
|
||||
try {
|
||||
// TODO: figure out why things go wrong if we import at the top
|
||||
const Modules = require('../../infrastructure/Modules')
|
||||
await Modules.promises.hooks.fire('userEmailChanged', user, email)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, oldEmail, newEmail: email },
|
||||
'Failed to fire "userEmailChanged" hook'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email)
|
||||
|
||||
@@ -411,6 +411,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
Settings.analytics.ga.tokenV4,
|
||||
cookieDomain: Settings.cookieDomain,
|
||||
templateLinks: Settings.templateLinks,
|
||||
labsEnabled: Settings.labs && Settings.labs.enable,
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ function loadModules() {
|
||||
require(settingsCheckModule)
|
||||
}
|
||||
|
||||
for (const moduleName of Settings.moduleImportSequence) {
|
||||
for (const moduleName of Settings.moduleImportSequence || []) {
|
||||
const loadedModule = require(Path.join(
|
||||
MODULE_BASE_PATH,
|
||||
moduleName,
|
||||
|
||||
@@ -163,6 +163,8 @@ const UserSchema = new Schema({
|
||||
},
|
||||
alphaProgram: { type: Boolean, default: false }, // experimental features
|
||||
betaProgram: { type: Boolean, default: false },
|
||||
labsProgram: { type: Boolean, default: false },
|
||||
labsProgramGalileo: { type: Boolean, default: false },
|
||||
overleaf: {
|
||||
id: { type: Number },
|
||||
accessToken: { type: String },
|
||||
|
||||
Reference in New Issue
Block a user