Ghost/ghost/members-api/lib/MembersAPI.js
Rishabh Garg 1bf70bf3c6
Stored geolocation for member on creation (#15320)
refs https://github.com/TryGhost/Team/issues/1826

Geolocation was prev. loaded after member was created and updated on existing member. this was mostly due to historical context where we couldn't store data on magic link token.
Since email alerts go out at the time of member creation, this flow missed out on attaching member's location to email. 
This change -

- stores request ip when a member asks for magic link in the token
- loads request ip from token when member uses magic link, and for new members loads their geolocation and stores it with member creation
2022-08-26 00:45:34 +05:30

354 lines
10 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 MemberAnalyticsService = require('@tryghost/member-analytics-service');
const MembersAnalyticsIngress = require('@tryghost/members-analytics-ingress');
const PaymentsService = require('@tryghost/members-payments');
const TokenService = require('./services/token');
const GeolocationSerice = require('./services/geolocation');
const MemberBREADService = require('./services/member-bread');
const MemberRepository = require('./repositories/member');
const EventRepository = require('./repositories/event');
const ProductRepository = require('./repositories/product');
const RouterController = require('./controllers/router');
const MemberController = require('./controllers/member');
const WellKnownController = require('./controllers/well-known');
module.exports = function MembersAPI({
tokenConfig: {
issuer,
privateKey,
publicKey
},
auth: {
allowSelfSignup = () => true,
getSigninURL,
tokenProvider
},
mail: {
transporter,
getText,
getHTML,
getSubject
},
models: {
EmailRecipient,
StripeCustomer,
StripeCustomerSubscription,
Member,
MemberCancelEvent,
MemberSubscribeEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberProductEvent,
MemberEmailChangeEvent,
MemberAnalyticEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
Offer,
OfferRedemption,
StripeProduct,
StripePrice,
Product,
Settings,
Comment
},
stripeAPIService,
offersAPI,
staffService,
labsService,
newslettersService,
memberAttributionService
}) {
const tokenService = new TokenService({
privateKey,
publicKey,
issuer
});
const memberAnalyticsService = MemberAnalyticsService.create(MemberAnalyticEvent);
memberAnalyticsService.eventHandler.setupSubscribers();
const productRepository = new ProductRepository({
Product,
Settings,
StripeProduct,
StripePrice,
stripeAPIService
});
const memberRepository = new MemberRepository({
stripeAPIService,
tokenService,
newslettersService,
staffService,
labsService,
productRepository,
Member,
MemberCancelEvent,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberEmailChangeEvent,
MemberStatusEvent,
MemberProductEvent,
OfferRedemption,
StripeCustomer,
StripeCustomerSubscription,
offerRepository: offersAPI.repository
});
const eventRepository = new EventRepository({
EmailRecipient,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
Comment,
labsService,
memberAttributionService
});
const memberBREADService = new MemberBREADService({
offersAPI,
memberRepository,
emailService: {
async sendEmailWithMagicLink({email, requestedType}) {
return sendEmailWithMagicLink({
email,
requestedType,
options: {
forceEmailType: true
}
});
}
},
labsService,
stripeService: stripeAPIService,
memberAttributionService
});
const geolocationService = new GeolocationSerice();
const magicLinkService = new MagicLink({
transporter,
tokenProvider,
getSigninURL,
getText,
getHTML,
getSubject
});
const memberController = new MemberController({
memberRepository,
productRepository,
StripePrice,
tokenService,
staffService,
sendEmailWithMagicLink
});
const paymentsService = new PaymentsService({
Offer,
offersAPI,
stripeAPIService
});
const routerController = new RouterController({
offersAPI,
paymentsService,
productRepository,
memberRepository,
StripePrice,
allowSelfSignup,
magicLinkService,
stripeAPIService,
tokenService,
sendEmailWithMagicLink,
memberAttributionService,
labsService
});
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}, tokenData), referrer});
}
function getMagicLink(email, tokenData = {}) {
return magicLinkService.getMagicLink({tokenData: {email, ...tokenData}, type: 'signin'});
}
async function getTokenDataFromMagicLinkToken(token) {
return await magicLinkService.getDataFromToken(token);
}
async function getMemberDataFromMagicLinkToken(token) {
const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp} = 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) {
// user exists but wants to change their email address
await users.update({email}, {id: member.id});
return getMemberIdentityData(email);
}
return member;
}
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});
// Notify staff users of new free member signup
if (labsService.isSet('emailAlerts')) {
await staffService.notifyFreeMemberSignup(newMember.toJSON());
}
await MemberLoginEvent.add({member_id: newMember.id});
return getMemberIdentityData(email);
}
async function getMemberIdentityData(email) {
return memberBREADService.read({email});
}
async function getMemberIdentityToken(email) {
const member = await getMemberIdentityData(email);
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 (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))
),
createEvents: Router().use(
body.json(),
forwardError((req, res) => MembersAnalyticsIngress.createEvents(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');
return {
middleware,
getMemberDataFromMagicLinkToken,
getMemberIdentityToken,
getMemberIdentityData,
setMemberGeolocationFromIp,
getPublicConfig,
bus,
sendEmailWithMagicLink,
getMagicLink,
members: users,
memberBREADService,
events: eventRepository,
productRepository,
// Test helpers
getTokenDataFromMagicLinkToken
};
};