Ghost/ghost/members-api/index.js
Kevin Ansfield bf63ffe424 Moved members geolocation fetch/update into members-ssr (#151)
closes https://github.com/TryGhost/Members/issues/148

- geolocation was not being fetched/stored for paid member signup
  - magic link was being sent after Stripe webhook but we don't have an IP at that stage
  - it only worked when a magic link was requested by the browser
- moved the geolocation fetch/update to `members-ssr`
  - kept the ip geolookup and storage inside `members-api` but exposed it as a method so consumers are able to choose when it's performed
  - used the new api method in `members-ssr` when exchanging a token from the session as that is always driven by browser requests so we know we have an IP and it's likely the correct one (reliant on consumers having "trust proxy" config correct)
  - stopped storing IP addresses in the token payload (keeps links shorter)
2020-06-04 13:20:19 +01:00

450 lines
15 KiB
JavaScript

const _ = require('lodash');
const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const StripePaymentProcessor = require('./lib/stripe');
const Tokens = require('./lib/tokens');
const Users = require('./lib/users');
const Metadata = require('./lib/metadata');
const common = require('./lib/common');
const {getGeolocationFromIP} = require('./lib/geolocation');
module.exports = function MembersApi({
tokenConfig: {
issuer,
privateKey,
publicKey
},
auth: {
allowSelfSignup = true,
getSigninURL,
secret
},
paymentConfig,
mail: {
transporter,
getText,
getHTML,
getSubject
},
memberStripeCustomerModel,
stripeCustomerSubscriptionModel,
memberModel,
logger
}) {
if (logger) {
common.logging.setLogger(logger);
}
const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer});
const metadata = Metadata({memberStripeCustomerModel, stripeCustomerSubscriptionModel});
const stripeStorage = {
async get(member) {
return metadata.getMetadata('stripe', member);
},
async set(data) {
return metadata.setMetadata('stripe', data);
}
};
const stripe = paymentConfig.stripe ? new StripePaymentProcessor(paymentConfig.stripe, stripeStorage, common.logging) : null;
async function ensureStripe(_req, res, next) {
if (!stripe) {
res.writeHead(400);
return res.end('Stripe not configured');
}
try {
await stripe.ready();
next();
} catch (err) {
res.writeHead(500);
return res.end('There was an error configuring stripe');
}
}
const magicLinkService = new MagicLink({
transporter,
secret,
getSigninURL,
getText,
getHTML,
getSubject
});
async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}){
if (options.forceEmailType) {
return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType});
}
const member = await users.get({email});
if (member) {
return magicLinkService.sendMagicLink({email, payload, subject: email, type: 'signin'});
} else {
const type = requestedType === 'subscribe' ? 'subscribe' : 'signup';
return magicLinkService.sendMagicLink({email, payload, subject: email, type});
}
}
function getMagicLink(email) {
return magicLinkService.getMagicLink({email, subject: email, type: 'signin'});
}
const users = Users({
stripe,
memberModel
});
async function getMemberDataFromMagicLinkToken(token) {
const email = await magicLinkService.getUserFromToken(token);
const {labels = [], name = '', oldEmail} = await magicLinkService.getPayloadFromToken(token);
if (!email) {
return null;
}
const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
if (member) {
if (oldEmail) {
// user exists but wants to change their email address
if (oldEmail) {
member.email = email;
}
await users.update(member, {id: member.id});
return getMemberIdentityData(email);
}
return member;
}
await users.create({name, email, labels});
return getMemberIdentityData(email);
}
async function getMemberIdentityData(email){
return users.get({email});
}
async function getMemberIdentityToken(email){
const member = await getMemberIdentityData(email);
if (!member) {
return null;
}
return encodeIdentityToken({sub: member.email});
}
async function setMemberGeolocationFromIp(email, ip) {
if (!email || !ip) {
return Promise.reject(new common.errors.IncorrectUsageError({
message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
}));
}
const member = await getMemberIdentityData(email);
if (!member) {
return Promise.reject(new common.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 getGeolocationFromIP(ip));
if (geolocation) {
member.geolocation = geolocation;
await users.update(member, {id: member.id});
}
return getMemberIdentityData(email);
}
const middleware = {
sendMagicLink: Router(),
createCheckoutSession: Router(),
createCheckoutSetupSession: Router(),
handleStripeWebhook: Router(),
updateSubscription: Router({mergeParams: true})
};
middleware.sendMagicLink.use(body.json(), async function (req, res) {
const {email, emailType, oldEmail} = req.body;
const payload = {};
if (!email) {
res.writeHead(400);
return res.end('Bad Request.');
}
try {
if (oldEmail) {
const existingMember = await users.get({email});
if (existingMember) {
throw new common.errors.BadRequestError({
message: 'This email is already associated with a member'
});
}
}
if (!allowSelfSignup) {
const member = await users.get({email});
if (member) {
Object.assign(payload, _.pick(body, ['oldEmail']));
await sendEmailWithMagicLink({email, requestedType: emailType, payload});
}
} else {
Object.assign(payload, _.pick(body, ['labels', 'name', 'oldEmail']));
await sendEmailWithMagicLink({email, requestedType: emailType, payload});
}
res.writeHead(201);
return res.end('Created.');
} catch (err) {
common.logging.error(err);
res.writeHead(500);
return res.end('Internal Server Error.');
}
});
middleware.createCheckoutSession.use(ensureStripe, body.json(), async function (req, res) {
const plan = req.body.plan;
const identity = req.body.identity;
if (!plan) {
res.writeHead(400);
return res.end('Bad Request.');
}
// NOTE: never allow "Complimenatry" plan to be subscribed to from the client
if (plan.toLowerCase() === 'complimentary') {
res.writeHead(400);
return res.end('Bad Request.');
}
let email;
try {
if (!identity) {
email = null;
} else {
const claims = await decodeToken(identity);
email = claims.sub;
}
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}
const member = email ? await users.get({email}) : null;
// Do not allow members already with a subscription to initiate a new checkout session
if (member && member.stripe.subscriptions.length > 0) {
res.writeHead(403);
return res.end('No permission');
}
const sessionInfo = await stripe.createCheckoutSession(member, plan, {
successUrl: req.body.successUrl,
cancelUrl: req.body.cancelUrl,
customerEmail: req.body.customerEmail,
metadata: req.body.metadata
});
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify(sessionInfo));
});
middleware.createCheckoutSetupSession.use(ensureStripe, body.json(), async function (req, res) {
const identity = req.body.identity;
let email;
try {
if (!identity) {
email = null;
} else {
const claims = await decodeToken(identity);
email = claims.sub;
}
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}
const member = email ? await users.get({email}) : null;
if (!member) {
res.writeHead(403);
return res.end('Bad Request.');
}
const sessionInfo = await stripe.createCheckoutSetupSession(member, {
successUrl: req.body.successUrl,
cancelUrl: req.body.cancelUrl
});
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify(sessionInfo));
});
middleware.handleStripeWebhook.use(ensureStripe, body.raw({type: 'application/json'}), async function (req, res) {
let event;
try {
event = await stripe.parseWebhook(req.body, req.headers['stripe-signature']);
} catch (err) {
common.logging.error(err);
res.writeHead(401);
return res.end();
}
try {
if (event.type === 'customer.subscription.deleted') {
await stripe.handleCustomerSubscriptionDeletedWebhook(event.data.object);
}
if (event.type === 'customer.subscription.updated') {
await stripe.handleCustomerSubscriptionUpdatedWebhook(event.data.object);
}
if (event.type === 'invoice.payment_succeeded') {
await stripe.handleInvoicePaymentSucceededWebhook(event.data.object);
}
if (event.type === 'invoice.payment_failed') {
await stripe.handleInvoicePaymentFailedWebhook(event.data.object);
}
if (event.type === 'checkout.session.completed') {
if (event.data.object.setup_intent) {
const setupIntent = await stripe.getSetupIntent(event.data.object.setup_intent);
const customer = await stripe.getCustomer(setupIntent.metadata.customer_id);
const member = await users.get({email: customer.email});
await stripe.handleCheckoutSetupSessionCompletedWebhook(setupIntent, member);
} else {
const customer = await stripe.getCustomer(event.data.object.customer, {
expand: ['subscriptions.data.default_payment_method']
});
let member = await users.get({email: customer.email});
if (!member) {
const metadata = event.data.object.metadata;
const name = (metadata && metadata.name) || '';
member = await users.create({email: customer.email, name});
}
await stripe.handleCheckoutSessionCompletedWebhook(member, customer);
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
if (payerName && !member.name) {
await users.update({name: payerName}, {id: member.id});
}
const emailType = 'signup';
await sendEmailWithMagicLink({email: customer.email, requestedType: emailType, options: {forceEmailType: true}});
}
}
res.writeHead(200);
res.end();
} catch (err) {
common.logging.error(`Error handling webhook ${event.type}`, err);
res.writeHead(400);
res.end();
}
});
middleware.updateSubscription.use(ensureStripe, body.json(), async function (req, res) {
const identity = req.body.identity;
const cancelAtPeriodEnd = req.body.cancel_at_period_end;
const planName = req.body.planName;
const subscriptionId = req.params.id;
let member;
try {
if (!identity) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find member'
});
}
const claims = await decodeToken(identity);
const email = claims.sub;
member = email ? await users.get({email}) : null;
if (!member) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find member'
});
}
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}
// Don't allow removing subscriptions that don't belong to the member
const plan = planName && stripe.findPlanByNickname(planName);
if (planName && !plan) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find plan'
});
}
const subscription = member.stripe.subscriptions.find(sub => sub.id === subscriptionId);
if (!subscription) {
res.writeHead(403);
return res.end('No permission');
}
if (cancelAtPeriodEnd === undefined && planName === undefined) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed!',
help: 'Request should contain "cancel" or "plan" field.'
});
}
const subscriptionUpdate = {
id: subscription.id
};
if (cancelAtPeriodEnd !== undefined) {
subscriptionUpdate.cancel_at_period_end = !!(cancelAtPeriodEnd);
}
if (plan) {
subscriptionUpdate.plan = plan.id;
}
await stripe.updateSubscriptionFromClient(subscriptionUpdate);
res.writeHead(204);
res.end();
});
const getPublicConfig = function () {
return Promise.resolve({
publicKey,
issuer
});
};
const bus = new (require('events').EventEmitter)();
if (stripe) {
stripe.ready().then(() => {
bus.emit('ready');
}).catch((err) => {
bus.emit('error', err);
});
} else {
process.nextTick(() => bus.emit('ready'));
}
return {
middleware,
getMemberDataFromMagicLinkToken,
getMemberIdentityToken,
getMemberIdentityData,
setMemberGeolocationFromIp,
getPublicConfig,
bus,
sendEmailWithMagicLink,
getMagicLink,
members: users
};
};