ghost/core/core/server/services/members/middleware.js
const crypto = require('crypto');
const _ = require('lodash');
const logging = require('@tryghost/logging');
const membersService = require('./service');
const emailSuppressionList = require('../email-suppression-list');
const models = require('../../models');
const urlUtils = require('../../../shared/url-utils');
const spamPrevention = require('../../web/shared/middleware/api/spam-prevention');
const {
formattedMemberResponse,
formatNewsletterResponse
} = require('./utils');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const onHeaders = require('on-headers');
const tiersService = require('../tiers/service');
const config = require('../../../shared/config');
const messages = {
missingUuid: 'Missing uuid.',
invalidUuid: 'Invalid uuid.'
};
const getFreeTier = async function getFreeTier() {
const response = await tiersService.api.browse();
const freeTier = response.data.find(tier => tier.type === 'free');
return freeTier;
};
/**
* Sets the ghost-access and ghost-access-hmac cookies on the response object
* @param {Object} member - The member object
* @param {import('express').Request} req - The member object
* @param {import('express').Response} res - The express response object to set the cookies on
* @param {Object} freeTier - The free tier object
* @returns
*/
const setAccessCookies = function setAccessCookies(member, req, res, freeTier) {
if (!member) {
// If there is no cookie sent with the request, return early
if (!req.headers.cookie || !req.headers.cookie.includes('ghost-access')) {
return;
}
// If there are cookies sent with the request, set them to null and expire them immediately
const accessCookie = `ghost-access=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`;
const hmacCookie = `ghost-access-hmac=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`;
const existingCookies = res.getHeader('Set-Cookie') || [];
const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies);
res.setHeader('Set-Cookie', cookiesToSet);
return;
}
const hmacSecret = config.get('cacheMembersContent:hmacSecret');
if (!hmacSecret) {
return;
}
const hmacSecretBuffer = Buffer.from(hmacSecret, 'base64');
if (hmacSecretBuffer.length === 0) {
return;
}
const activeSubscription = member.subscriptions?.find(sub => sub.status === 'active');
const cookieTimestamp = Math.floor(Date.now() / 1000); // to mitigate a cookie replay attack
const memberTier = activeSubscription && activeSubscription.tier.id || freeTier.id;
const memberTierAndTimestamp = `${memberTier}:${cookieTimestamp}`;
const memberTierHmac = crypto.createHmac('sha256', hmacSecretBuffer).update(memberTierAndTimestamp).digest('hex');
const maxAge = 3600;
const accessCookie = `ghost-access=${memberTierAndTimestamp}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`;
const hmacCookie = `ghost-access-hmac=${memberTierHmac}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`;
const existingCookies = res.getHeader('Set-Cookie') || [];
const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies);
res.setHeader('Set-Cookie', cookiesToSet);
};
const accessInfoSession = async function accessInfoSession(req, res, next) {
const freeTier = await getFreeTier();
onHeaders(res, function () {
setAccessCookies(req.member, req, res, freeTier);
});
next();
};
// @TODO: This piece of middleware actually belongs to the frontend, not to the member app
// Need to figure a way to separate these things (e.g. frontend actually talks to members API)
const loadMemberSession = async function loadMemberSession(req, res, next) {
try {
const member = await membersService.ssr.getMemberDataFromSession(req, res);
Object.assign(req, {member});
res.locals.member = req.member;
next();
} catch (err) {
Object.assign(req, {member: null});
next();
}
};
/**
* Require member authentication, and make it possible to authenticate via uuid.
* You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session.
*/
const authMemberByUuid = async function authMemberByUuid(req, res, next) {
try {
const uuid = req.query.uuid;
if (!uuid) {
if (res.locals.member && req.member) {
// Already authenticated via session
return next();
}
throw new errors.UnauthorizedError({
messsage: tpl(messages.missingUuid)
});
}
const member = await membersService.api.memberBREADService.read({uuid});
if (!member) {
throw new errors.UnauthorizedError({
message: tpl(messages.invalidUuid)
});
}
Object.assign(req, {member});
res.locals.member = req.member;
next();
} catch (err) {
next(err);
}
};
const getIdentityToken = async function getIdentityToken(req, res) {
try {
const token = await membersService.ssr.getIdentityTokenForMemberFromSession(req, res);
res.writeHead(200);
res.end(token);
} catch (err) {
res.writeHead(204);
res.end();
}
};
const deleteSession = async function deleteSession(req, res) {
try {
await membersService.ssr.deleteSession(req, res);
res.writeHead(204);
res.end();
} catch (err) {
if (!err.statusCode) {
logging.error(err);
}
res.writeHead(err.statusCode ?? 500, {
'Content-Type': 'text/plain;charset=UTF-8'
});
res.end(err.message);
}
};
const getMemberData = async function getMemberData(req, res) {
try {
const member = await membersService.ssr.getMemberDataFromSession(req, res);
if (member) {
res.json(formattedMemberResponse(member));
} else {
res.json(null);
}
} catch (err) {
res.writeHead(204);
res.end();
}
};
const deleteSuppression = async function deleteSuppression(req, res) {
try {
const member = await membersService.ssr.getMemberDataFromSession(req, res);
const options = {
id: member.id
};
await emailSuppressionList.removeEmail(member.email);
await membersService.api.members.update({email_disabled: false}, options);
res.writeHead(204);
res.end();
} catch (err) {
if (!err.statusCode) {
logging.error(err);
}
res.writeHead(err.statusCode ?? 500, {
'Content-Type': 'text/plain;charset=UTF-8'
});
res.end(err.message);
}
};
const getMemberNewsletters = async function getMemberNewsletters(req, res) {
try {
const memberUuid = req.query.uuid;
if (!memberUuid) {
res.writeHead(400);
return res.end('Invalid member uuid');
}
const memberData = await membersService.api.members.get({
uuid: memberUuid
}, {
withRelated: ['newsletters']
});
if (!memberData) {
res.writeHead(404);
return res.end('Email address not found.');
}
const data = _.pick(memberData.toJSON(), 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status');
if (data.newsletters) {
data.newsletters = formatNewsletterResponse(data.newsletters);
}
return res.json(data);
} catch (err) {
res.writeHead(400);
res.end('Failed to unsubscribe this email address');
}
};
const updateMemberNewsletters = async function updateMemberNewsletters(req, res) {
try {
const memberUuid = req.query.uuid;
if (!memberUuid) {
res.writeHead(400);
return res.end('Invalid member uuid');
}
const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications');
const memberData = await membersService.api.members.get({
uuid: memberUuid
});
if (!memberData) {
res.writeHead(404);
return res.end('Email address not found.');
}
const options = {
id: memberData.get('id'),
withRelated: ['newsletters']
};
const updatedMember = await membersService.api.members.update(data, options);
const updatedMemberData = _.pick(updatedMember.toJSON(), ['uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status']);
if (updatedMemberData.newsletters) {
updatedMemberData.newsletters = formatNewsletterResponse(updatedMemberData.newsletters);
}
res.json(updatedMemberData);
} catch (err) {
res.writeHead(400);
res.end('Failed to update newsletters');
}
};
const updateMemberData = async function updateMemberData(req, res) {
try {
const data = _.pick(req.body, 'name', 'expertise', 'subscribed', 'newsletters', 'enable_comment_notifications');
const member = await membersService.ssr.getMemberDataFromSession(req, res);
if (member) {
const options = {
id: member.id,
withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'newsletters']
};
await membersService.api.members.update(data, options);
const updatedMember = await membersService.ssr.getMemberDataFromSession(req, res);
res.json(formattedMemberResponse(updatedMember));
} else {
res.json(null);
}
} catch (err) {
if (!err.statusCode) {
logging.error(err);
}
res.writeHead(err.statusCode ?? 500, {
'Content-Type': 'text/plain;charset=UTF-8'
});
res.end(err.message);
}
};
const createSessionFromMagicLink = async function createSessionFromMagicLink(req, res, next) {
if (!req.url.includes('token=')) {
return next();
}
// req.query is a plain object, copy it to a URLSearchParams object so we can call toString()
const searchParams = new URLSearchParams('');
Object.keys(req.query).forEach((param) => {
// don't copy the "token" or "r" params
if (param !== 'token' && param !== 'r') {
searchParams.set(param, req.query[param]);
}
});
try {
const member = await membersService.ssr.exchangeTokenForSession(req, res);
spamPrevention.membersAuth().reset(req.ip, `${member.email}login`);
// Note: don't reset 'member_login', or that would give an easy way around user enumeration by logging in to a manually created account
const subscriptions = member && member.subscriptions || [];
if (config.get('cacheMembersContent:enabled')) {
// Set the ghost-access cookies to enable tier-based caching
try {
const freeTier = await getFreeTier();
setAccessCookies(member, req, res, freeTier);
} catch {
// This is a non-critical operation, so we can safely ignore any errors
}
}
const action = req.query.action;
if (action === 'signup' || action === 'signup-paid' || action === 'subscribe') {
let customRedirect = '';
const mostRecentActiveSubscription = subscriptions
.sort((a, b) => {
const aStartDate = new Date(a.start_date);
const bStartDate = new Date(b.start_date);
return bStartDate.valueOf() - aStartDate.valueOf();
})
.find(sub => ['active', 'trialing'].includes(sub.status));
if (mostRecentActiveSubscription) {
customRedirect = mostRecentActiveSubscription.tier.welcome_page_url;
} else {
const freeTier = await models.Product.findOne({type: 'free'});
customRedirect = freeTier && freeTier.get('welcome_page_url') || '';
}
if (customRedirect && customRedirect !== '/') {
const baseUrl = urlUtils.getSiteUrl();
const ensureEndsWith = (string, endsWith) => (string.endsWith(endsWith) ? string : string + endsWith);
const removeLeadingSlash = string => string.replace(/^\//, '');
// Add query parameters so the frontend can detect that the signup went fine
const redirectUrl = new URL(removeLeadingSlash(ensureEndsWith(customRedirect, '/')), ensureEndsWith(baseUrl, '/'));
if (urlUtils.isSiteUrl(redirectUrl)) {
// Add only for non-external URLs
redirectUrl.searchParams.set('success', 'true');
redirectUrl.searchParams.set('action', 'signup');
}
return res.redirect(redirectUrl.href);
}
}
// If a custom referrer/redirect was passed, redirect the user to that URL
const referrer = req.query.r;
const siteUrl = urlUtils.getSiteUrl();
if (referrer && referrer.startsWith(siteUrl)) {
const redirectUrl = new URL(referrer);
// Copy search params
searchParams.forEach((value, key) => {
redirectUrl.searchParams.set(key, value);
});
redirectUrl.searchParams.set('success', 'true');
if (action === 'signin') {
// Not sure if we can delete this, this is a legacy param
redirectUrl.searchParams.set('action', 'signin');
}
return res.redirect(redirectUrl.href);
}
// Do a standard 302 redirect to the homepage, with success=true
searchParams.set('success', 'true');
res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`);
} catch (err) {
logging.warn(err.message);
// Do a standard 302 redirect to the homepage, with success=false
searchParams.set('success', false);
res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`);
}
};
// Set req.member & res.locals.member if a cookie is set
module.exports = {
loadMemberSession,
authMemberByUuid,
createSessionFromMagicLink,
getIdentityToken,
getMemberNewsletters,
getMemberData,
updateMemberData,
updateMemberNewsletters,
deleteSession,
accessInfoSession,
deleteSuppression
};