Ghost/ghost/members-api/lib/members-api.js
Sag 7f963e9c2a
🎨 Added 'Changed email address' event to Member Activity (#20493)
fixes https://linear.app/tryghost/issue/ENG-1256

- when a member changes their email address, surface it in Member
Activity
2024-07-01 15:33:33 +00:00

397 lines
12 KiB
JavaScript

const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const PaymentsService = require('@tryghost/members-payments');
const TokenService = require('./services/TokenService');
const GeolocationService = require('./services/GeolocationService');
const MemberBREADService = require('./services/MemberBREADService');
const MemberRepository = require('./repositories/MemberRepository');
const EventRepository = require('./repositories/EventRepository');
const ProductRepository = require('./repositories/ProductRepository');
const RouterController = require('./controllers/RouterController');
const MemberController = require('./controllers/MemberController');
const WellKnownController = require('./controllers/WellKnownController');
const {EmailSuppressedEvent} = require('@tryghost/email-suppression-list');
const DomainEvents = require('@tryghost/domain-events');
module.exports = function MembersAPI({
tokenConfig: {
issuer,
privateKey,
publicKey
},
auth: {
allowSelfSignup = () => true,
getSigninURL,
tokenProvider
},
mail: {
transporter,
getText,
getHTML,
getSubject
},
models: {
DonationPaymentEvent,
EmailRecipient,
StripeCustomer,
StripeCustomerSubscription,
Member,
MemberNewsletter,
MemberCancelEvent,
MemberSubscribeEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberProductEvent,
MemberEmailChangeEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
EmailSpamComplaintEvent,
Offer,
OfferRedemption,
StripeProduct,
StripePrice,
Product,
Settings,
Comment,
MemberFeedback
},
tiersService,
stripeAPIService,
offersAPI,
labsService,
newslettersService,
memberAttributionService,
emailSuppressionList,
settingsCache
}) {
const tokenService = new TokenService({
privateKey,
publicKey,
issuer
});
const productRepository = new ProductRepository({
Product,
Settings,
StripeProduct,
StripePrice,
stripeAPIService
});
const memberRepository = new MemberRepository({
stripeAPIService,
tokenService,
newslettersService,
labsService,
productRepository,
Member,
MemberNewsletter,
MemberCancelEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberEmailChangeEvent,
MemberStatusEvent,
MemberProductEvent,
OfferRedemption,
StripeCustomer,
StripeCustomerSubscription,
offerRepository: offersAPI.repository
});
const eventRepository = new EventRepository({
DonationPaymentEvent,
EmailRecipient,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
MemberFeedback,
EmailSpamComplaintEvent,
Comment,
labsService,
memberAttributionService,
MemberEmailChangeEvent
});
const memberBREADService = new MemberBREADService({
offersAPI,
memberRepository,
emailService: {
async sendEmailWithMagicLink({email, requestedType}) {
return sendEmailWithMagicLink({
email,
requestedType,
options: {
forceEmailType: true
}
});
}
},
labsService,
stripeService: stripeAPIService,
memberAttributionService,
emailSuppressionList
});
const geolocationService = new GeolocationService();
const magicLinkService = new MagicLink({
transporter,
tokenProvider,
getSigninURL,
getText,
getHTML,
getSubject
});
const paymentsService = new PaymentsService({
StripeProduct,
StripePrice,
StripeCustomer,
Offer,
offersAPI,
stripeAPIService,
settingsCache
});
const memberController = new MemberController({
memberRepository,
productRepository,
paymentsService,
tiersService,
StripePrice,
tokenService,
sendEmailWithMagicLink
});
const routerController = new RouterController({
offersAPI,
paymentsService,
tiersService,
memberRepository,
StripePrice,
allowSelfSignup,
magicLinkService,
stripeAPIService,
tokenService,
sendEmailWithMagicLink,
memberAttributionService,
labsService,
newslettersService
});
const wellKnownController = new WellKnownController({
tokenService
});
const users = memberRepository;
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
let type = requestedType;
if (!options.forceEmailType) {
const member = await users.get({email});
if (member) {
type = 'signin';
} else if (type !== 'subscribe') {
type = 'signup';
}
}
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
}
/**
*
* @param {string} email
* @param {'signin'|'signup'} type When you specify 'signin' this will prevent the creation of a new member if no member is found with the provided email
* @param {*} [tokenData] Optional token data to add to the token
* @returns
*/
function getMagicLink(email, type, tokenData = {}) {
return magicLinkService.getMagicLink({
tokenData: {email, ...tokenData},
type
});
}
async function getTokenDataFromMagicLinkToken(token) {
return await magicLinkService.getDataFromToken(token);
}
async function getMemberDataFromMagicLinkToken(token) {
const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token);
if (!email) {
return null;
}
const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
if (member) {
await MemberLoginEvent.add({member_id: member.id});
if (oldEmail && (!type || type === 'updateEmail')) {
// user exists but wants to change their email address
await users.update({email}, {id: member.id});
return getMemberIdentityData(email);
}
return member;
}
// Note: old tokens can still have a missing type (we can remove this after a couple of weeks)
if (type && !['signup', 'subscribe'].includes(type)) {
// Don't allow sign up
// Note that we use the type from inside the magic token so this behaviour can't be changed
return null;
}
let geolocation;
if (reqIp) {
try {
geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(reqIp));
} catch (err) {
logging.warn(err);
// no-op, we don't want to stop anything working due to
// geolocation lookup failing
}
}
const newMember = await users.create({name, email, labels, newsletters, attribution, geolocation});
await MemberLoginEvent.add({member_id: newMember.id});
return getMemberIdentityData(email);
}
async function getMemberIdentityData(email) {
return memberBREADService.read({email});
}
async function getMemberIdentityDataFromTransientId(transientId) {
return memberBREADService.read({transient_id: transientId});
}
async function cycleTransientId(memberId) {
await users.cycleTransientId({id: memberId});
}
async function getMemberIdentityToken(transientId) {
const member = await getMemberIdentityDataFromTransientId(transientId);
if (!member) {
return null;
}
return tokenService.encodeIdentityToken({sub: member.email});
}
async function setMemberGeolocationFromIp(email, ip) {
if (!email || !ip) {
throw new errors.IncorrectUsageError({
message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
});
}
// toJSON() is needed here otherwise users.update() will pick methods off
// the model object rather than data and fail to edit correctly
const member = (await users.get({email})).toJSON();
if (!member) {
throw new errors.NotFoundError({
message: `Member with email address ${email} does not exist`
});
}
// max request time is 500ms so shouldn't slow requests down too much
let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
if (geolocation) {
await users.update({geolocation}, {id: member.id});
}
return getMemberIdentityData(email);
}
const forwardError = fn => async function forwardErrorMw(req, res, next) {
try {
await fn(req, res, next);
} catch (err) {
next(err);
}
};
const middleware = {
sendMagicLink: Router().use(
body.json(),
forwardError((req, res) => routerController.sendMagicLink(req, res))
),
createCheckoutSession: Router().use(
body.json(),
forwardError((req, res) => routerController.createCheckoutSession(req, res))
),
createCheckoutSetupSession: Router().use(
body.json(),
forwardError((req, res) => routerController.createCheckoutSetupSession(req, res))
),
updateEmailAddress: Router().use(
body.json(),
forwardError((req, res) => memberController.updateEmailAddress(req, res))
),
updateSubscription: Router({mergeParams: true}).use(
body.json(),
forwardError((req, res) => memberController.updateSubscription(req, res))
),
wellKnown: Router()
.get('/jwks.json',
(req, res) => wellKnownController.getPublicKeys(req, res)
)
};
const getPublicConfig = function () {
return Promise.resolve({
publicKey,
issuer
});
};
const bus = new (require('events').EventEmitter)();
bus.emit('ready');
DomainEvents.subscribe(EmailSuppressedEvent, async function (event) {
const member = await memberRepository.get({email: event.data.emailAddress});
if (!member) {
return;
}
await memberRepository.update({email_disabled: true}, {id: member.id});
});
return {
middleware,
getMemberDataFromMagicLinkToken,
getMemberIdentityToken,
getMemberIdentityDataFromTransientId,
getMemberIdentityData,
cycleTransientId,
setMemberGeolocationFromIp,
getPublicConfig,
bus,
sendEmailWithMagicLink,
getMagicLink,
members: users,
memberBREADService,
events: eventRepository,
productRepository,
// Test helpers
getTokenDataFromMagicLinkToken,
paymentsService
};
};