Added new member signup flow behind labs flag (#19986)
ref https://linear.app/tryghost/issue/KTLO-1/members-spam-signups - Some customers are seeing many spammy signups ("hundreds a day") — our hypothesis is that bots and/or email link checkers are able to signup by simply following the link in the email without even loading the page in a browser. - Currently new members signup by clicking a magic link in an email, which is a simple GET request. When the user (or a bot) clicks that link, Ghost creates the member and signs them in for the first time. - This change, behind an alpha flag, requires a new member to click the link in the email, which takes them to a new frontend route `/confirm_signup/`, then submit a form on the page which sends a POST request to the server. If JavaScript is enabled, the form will be submitted automatically so the only change to the user is an extra flash/redirect before being signed in and redirected to the homepage. - This change is behind the alpha flag `membersSpamPrevention` so we can test it out on a few customer's sites and see if it helps reduce the spam signups. With the flag off, the signup flow remains the same as before.
This commit is contained in:
parent
a262a64eea
commit
01d0b2b304
@ -59,6 +59,10 @@ const features = [{
|
||||
title: 'NestJS Playground',
|
||||
description: 'Wires up the Ghost NestJS App to the Admin API',
|
||||
flag: 'NestPlayground'
|
||||
},{
|
||||
title: 'Prevent Member Spam Signups',
|
||||
description: 'Enables features to help combat spam member signups',
|
||||
flag: 'membersSpamPrevention'
|
||||
}];
|
||||
|
||||
const AlphaFeatures: React.FC = () => {
|
||||
|
@ -81,6 +81,7 @@ export default class FeatureService extends Service {
|
||||
@feature('adminXDemo') adminXDemo;
|
||||
@feature('portalImprovements') portalImprovements;
|
||||
@feature('onboardingChecklist') onboardingChecklist;
|
||||
@feature('membersSpamPrevention') membersSpamPrevention;
|
||||
|
||||
_user = null;
|
||||
|
||||
|
@ -8,6 +8,7 @@ const PreviewRouter = require('./PreviewRouter');
|
||||
const ParentRouter = require('./ParentRouter');
|
||||
const EmailRouter = require('./EmailRouter');
|
||||
const UnsubscribeRouter = require('./UnsubscribeRouter');
|
||||
const SubscribeRouter = require('./SubscribeRouter');
|
||||
|
||||
// This emits its own routing events
|
||||
const events = require('../../../server/lib/common/events');
|
||||
@ -109,6 +110,10 @@ class RouterManager {
|
||||
this.siteRouter.mountRouter(unsubscribeRouter.router());
|
||||
this.registry.setRouter('unsubscribeRouter', unsubscribeRouter);
|
||||
|
||||
const subscribeRouter = new SubscribeRouter();
|
||||
this.siteRouter.mountRouter(subscribeRouter.router());
|
||||
this.registry.setRouter('subscribeRouter', subscribeRouter);
|
||||
|
||||
if (RESOURCE_CONFIG.QUERY.email) {
|
||||
const emailRouter = new EmailRouter(RESOURCE_CONFIG);
|
||||
this.siteRouter.mountRouter(emailRouter.router());
|
||||
|
27
ghost/core/core/frontend/services/routing/SubscribeRouter.js
Normal file
27
ghost/core/core/frontend/services/routing/SubscribeRouter.js
Normal file
@ -0,0 +1,27 @@
|
||||
const ParentRouter = require('./ParentRouter');
|
||||
const controllers = require('./controllers');
|
||||
|
||||
/**
|
||||
* @description Subscribe Router.
|
||||
*
|
||||
* "/subscribe/" -> Subscribe Router
|
||||
*/
|
||||
class SubscribeRouter extends ParentRouter {
|
||||
constructor() {
|
||||
super('SubscribeRouter');
|
||||
|
||||
// @NOTE: hardcoded, not configurable
|
||||
this.route = {value: '/confirm_signup/'};
|
||||
this._registerRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Register all routes of this router.
|
||||
* @private
|
||||
*/
|
||||
_registerRoutes() {
|
||||
this.mountRoute(this.route.value, controllers.subscribe);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SubscribeRouter;
|
@ -29,5 +29,9 @@ module.exports = {
|
||||
|
||||
get unsubscribe() {
|
||||
return require('./unsubscribe');
|
||||
},
|
||||
|
||||
get subscribe() {
|
||||
return require('./subscribe');
|
||||
}
|
||||
};
|
||||
|
@ -0,0 +1,35 @@
|
||||
const debug = require('@tryghost/debug')('services:routing:controllers:subscribe');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const handlebars = require('handlebars');
|
||||
const assetHelper = require('../../../helpers/asset');
|
||||
const {settingsCache} = require('../../../services/proxy');
|
||||
|
||||
handlebars.registerHelper('asset', assetHelper);
|
||||
|
||||
module.exports = async function subscribeController(req, res) {
|
||||
debug('subscribeController');
|
||||
|
||||
// Get the query params
|
||||
const {query} = req;
|
||||
const token = query.token || null;
|
||||
const action = query.action || null;
|
||||
const ref = query.r || null;
|
||||
|
||||
if (!token || !action) {
|
||||
return res.send(404);
|
||||
}
|
||||
|
||||
// Prepare context for rendering template
|
||||
const context = {
|
||||
token,
|
||||
action,
|
||||
r: ref,
|
||||
meta_title: settingsCache.get('title'),
|
||||
accent_color: settingsCache.get('accent_color')
|
||||
};
|
||||
// Compile and render the template
|
||||
const rawTemplate = fs.readFileSync(path.resolve(path.join(__dirname, '../../../views/subscribe.hbs'))).toString();
|
||||
const template = handlebars.compile(rawTemplate);
|
||||
return res.send(template(context));
|
||||
};
|
36
ghost/core/core/frontend/views/subscribe.hbs
Normal file
36
ghost/core/core/frontend/views/subscribe.hbs
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>{{title}}</title>
|
||||
<link rel="stylesheet" href="{{asset "public/ghost.css" hasMinFile="true"}}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gh-app">
|
||||
<div class="gh-viewport">
|
||||
<main class="gh-main" role="main" id="main">
|
||||
<div class="gh-flow">
|
||||
<div class="gh-flow-content-wrap">
|
||||
<h1>Subscribing to {{meta_title}}</h1>
|
||||
<p>If you are not redirected automatically, please click the "Subscribe" button below.</p>
|
||||
<form id="gh-subscribe-form" action="/members/api/member" method="POST">
|
||||
<input type="hidden" name="token" value="{{token}}" />
|
||||
<input type="hidden" name="action" value="{{action}}" />
|
||||
<input type="hidden" name="r" value="{{r}}" />
|
||||
<button class="gh-btn" style="background-color: {{accent_color}}; color: #FFFFFF" type="submit"><span>Subscribe</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const main = document.getElementById('main');
|
||||
main.style.display = 'none';
|
||||
const form = document.getElementById('gh-subscribe-form');
|
||||
form.submit();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -2,6 +2,7 @@ const logging = require('@tryghost/logging');
|
||||
const {URL} = require('url');
|
||||
const crypto = require('crypto');
|
||||
const createKeypair = require('keypair');
|
||||
const labs = require('../../../shared/labs');
|
||||
|
||||
class MembersConfigProvider {
|
||||
/**
|
||||
@ -87,7 +88,8 @@ class MembersConfigProvider {
|
||||
}
|
||||
|
||||
getSigninURL(token, type, referrer) {
|
||||
const siteUrl = this._urlUtils.urlFor({relativeUrl: '/members/'}, true);
|
||||
const relativeUrl = ['signup', 'subscribe'].includes(type) && labs.isSet('membersSpamPrevention') ? '/confirm_signup/' : '/members/';
|
||||
const siteUrl = this._urlUtils.urlFor({relativeUrl}, true);
|
||||
const signinURL = new URL(siteUrl);
|
||||
signinURL.searchParams.set('token', token);
|
||||
signinURL.searchParams.set('action', type);
|
||||
|
@ -37,6 +37,9 @@ module.exports = function setupMembersApp() {
|
||||
// Initializes members specific routes as well as assigns members specific data to the req/res objects
|
||||
// We don't want to add global bodyParser middleware as that interferes with stripe webhook requests on - `/webhooks`.
|
||||
|
||||
// Double opt-in subscription handling
|
||||
membersApp.post('/api/member', membersService.api.middleware.createMemberFromToken);
|
||||
|
||||
// Manage newsletter subscription via unsubscribe link
|
||||
membersApp.get('/api/member/newsletters', middleware.getMemberNewsletters);
|
||||
membersApp.put('/api/member/newsletters', bodyParser.json({limit: '50mb'}), middleware.updateMemberNewsletters);
|
||||
|
@ -50,7 +50,8 @@ const ALPHA_FEATURES = [
|
||||
'lexicalIndicators',
|
||||
// 'adminXOffers',
|
||||
'adminXDemo',
|
||||
'onboardingChecklist'
|
||||
'onboardingChecklist',
|
||||
'membersSpamPrevention'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
@ -1829,6 +1829,291 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 1: [body] 1`] = `
|
||||
Object {
|
||||
"members": Array [
|
||||
Object {
|
||||
"attribution": Object {
|
||||
"id": null,
|
||||
"referrer_medium": "Ghost Admin",
|
||||
"referrer_source": "Created manually",
|
||||
"referrer_url": null,
|
||||
"title": null,
|
||||
"type": null,
|
||||
"url": null,
|
||||
},
|
||||
"avatar_image": null,
|
||||
"comped": false,
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"email": "member_getting_confirmation@test.com",
|
||||
"email_count": 0,
|
||||
"email_open_rate": null,
|
||||
"email_opened_count": 0,
|
||||
"email_suppression": Object {
|
||||
"info": null,
|
||||
"suppressed": false,
|
||||
},
|
||||
"geolocation": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"labels": Array [],
|
||||
"last_seen_at": null,
|
||||
"name": "Send Me Confirmation",
|
||||
"newsletters": Array [
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Default Newsletter",
|
||||
"status": "active",
|
||||
},
|
||||
Object {
|
||||
"description": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"name": "Daily newsletter",
|
||||
"status": "active",
|
||||
},
|
||||
],
|
||||
"note": null,
|
||||
"status": "free",
|
||||
"subscribed": true,
|
||||
"subscriptions": Array [],
|
||||
"tiers": Array [],
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "890",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"location": Any<String>,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 3: [html 1] 1`] = `
|
||||
"
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name=\\"viewport\\" content=\\"width=device-width\\">
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title>🙌 Complete your sign up to Ghost's Test Site!</title>
|
||||
<style>
|
||||
/* -------------------------------------
|
||||
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
||||
------------------------------------- */
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
table[class=body] p[class=small],
|
||||
table[class=body] a[class=small] {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.recipient-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
hr {
|
||||
border-width: 0;
|
||||
height: 0;
|
||||
margin-top: 34px;
|
||||
margin-bottom: 34px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #EEF5F8;
|
||||
}
|
||||
a {
|
||||
color: #3A464C;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style=\\"background-color: #FFFFFF; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;\\">
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"body\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #FFFFFF;\\">
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<span class=\\"preheader\\" style=\\"color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;\\">Complete signup for Ghost's Test Site!</span>
|
||||
<table class=\\"main\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;\\">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;\\">
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\">
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 24px; margin: 0; margin-bottom: 15px;\\">Hey there!</p>
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 32px;\\">Tap the link below to complete the signup process for Ghost's Test Site, and be automatically signed in:</p>
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"btn btn-primary\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align=\\"left\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 35px;\\">
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Confirm signup</a> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 11px;\\">For your security, the link will expire in 24 hours time.</p>
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 30px;\\">See you soon!</p>
|
||||
<hr/>
|
||||
<p style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0;\\">You can also copy & paste this URL into your browser:</p>
|
||||
<p style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 22px; margin-top: 0; color: #3A464C;\\">http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 80px;\\">
|
||||
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 16px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0;\\">If you did not make this request, you can simply delete this message. You will not be signed up, and no account will be created for you.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 2px;\\">
|
||||
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 16px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0;\\">This message was sent from <a class=\\"small\\" href=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:member_getting_confirmation@test.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">member_getting_confirmation@test.com</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 4: [text 1] 1`] = `
|
||||
"Hey there,
|
||||
|
||||
Tap the link below to complete the signup process for Ghost's Test Site, and be automatically signed in:
|
||||
|
||||
http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup
|
||||
|
||||
For your security, the link will expire in 24 hours time.
|
||||
|
||||
See you soon!
|
||||
|
||||
---
|
||||
|
||||
Sent to member_getting_confirmation@test.com
|
||||
If you did not make this request, you can simply delete this message. You will not be signed up, and no account will be created for you."
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 5: [metadata 1] 1`] = `
|
||||
Object {
|
||||
"encoding": "base64",
|
||||
"forceTextContent": true,
|
||||
"from": "\\"Ghost's Test Site\\" <noreply@127.0.0.1>",
|
||||
"generateTextFromHTML": false,
|
||||
"replyTo": null,
|
||||
"subject": "🙌 Complete your sign up to Ghost's Test Site!",
|
||||
"to": "member_getting_confirmation@test.com",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 6: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Origin",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Members API Can add complimentary subscription (out of date) 1: [body] 1`] = `
|
||||
Object {
|
||||
"members": Array [
|
||||
|
@ -907,6 +907,118 @@ describe('Members API', function () {
|
||||
});
|
||||
|
||||
it('Can add and send a signup confirmation email', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const member = {
|
||||
name: 'Send Me Confirmation',
|
||||
email: 'member_getting_confirmation@test.com',
|
||||
newsletters: [
|
||||
newsletters[0],
|
||||
newsletters[1]
|
||||
]
|
||||
};
|
||||
|
||||
// Set site title to something with a special character to ensure subject line doesn't get escaped
|
||||
// Refs https://github.com/TryGhost/Team/issues/2895
|
||||
await agent.put('/settings/')
|
||||
.body({
|
||||
settings: [
|
||||
{
|
||||
key: 'title',
|
||||
value: 'Ghost\'s Test Site'
|
||||
}
|
||||
]
|
||||
})
|
||||
.expectStatus(200);
|
||||
|
||||
const {body} = await agent
|
||||
.post('/members/?send_email=true&email_type=signup')
|
||||
.body({members: [member]})
|
||||
.expectStatus(201)
|
||||
.matchBodySnapshot({
|
||||
members: [
|
||||
buildMemberWithoutIncludesSnapshot({
|
||||
newsletters: 2
|
||||
})
|
||||
]
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag,
|
||||
location: anyString
|
||||
});
|
||||
|
||||
const newMember = body.members[0];
|
||||
|
||||
emailMockReceiver
|
||||
.assertSentEmailCount(1)
|
||||
.matchHTMLSnapshot([{
|
||||
pattern: queryStringToken('token'),
|
||||
replacement: 'token=REPLACED_TOKEN'
|
||||
}])
|
||||
.matchPlaintextSnapshot([{
|
||||
pattern: queryStringToken('token'),
|
||||
replacement: 'token=REPLACED_TOKEN'
|
||||
}])
|
||||
.matchMetadataSnapshot();
|
||||
|
||||
await assertMemberEvents({
|
||||
eventType: 'MemberStatusEvent',
|
||||
memberId: newMember.id,
|
||||
asserts: [
|
||||
{
|
||||
from_status: null,
|
||||
to_status: 'free'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await assertMemberEvents({
|
||||
eventType: 'MemberSubscribeEvent',
|
||||
memberId: newMember.id,
|
||||
asserts: [
|
||||
{
|
||||
subscribed: true,
|
||||
source: 'admin',
|
||||
newsletter_id: newsletters[0].id
|
||||
},
|
||||
{
|
||||
subscribed: true,
|
||||
source: 'admin',
|
||||
newsletter_id: newsletters[1].id
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// @TODO: do we really need to delete this member here?
|
||||
await agent
|
||||
.delete(`members/${body.members[0].id}/`)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
})
|
||||
.expectStatus(204);
|
||||
|
||||
// There should be no MemberSubscribeEvent remaining.
|
||||
await assertMemberEvents({
|
||||
eventType: 'MemberSubscribeEvent',
|
||||
memberId: newMember.id,
|
||||
asserts: []
|
||||
});
|
||||
|
||||
// Reset the site title to the default
|
||||
await agent.put('/settings/')
|
||||
.body({
|
||||
settings: [
|
||||
{
|
||||
key: 'title',
|
||||
value: 'Ghost'
|
||||
}
|
||||
]
|
||||
})
|
||||
.expectStatus(200);
|
||||
});
|
||||
|
||||
it('Can add and send a signup confirmation email with membersSpamPrevention enabled', async function () {
|
||||
const member = {
|
||||
name: 'Send Me Confirmation',
|
||||
email: 'member_getting_confirmation@test.com',
|
||||
|
@ -1,4 +1,5 @@
|
||||
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {agentProvider, mockManager, fixtureManager, matchers, resetRateLimits} = require('../../utils/e2e-framework');
|
||||
const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
|
||||
const should = require('should');
|
||||
const settingsCache = require('../../../core/shared/settings-cache');
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
@ -18,7 +19,7 @@ describe('sendMagicLink', function () {
|
||||
|
||||
beforeEach(function () {
|
||||
mockManager.mockMail();
|
||||
|
||||
resetRateLimits();
|
||||
// Reset settings
|
||||
settingsCache.set('members_signup_access', {value: 'all'});
|
||||
});
|
||||
@ -174,6 +175,7 @@ describe('sendMagicLink', function () {
|
||||
});
|
||||
|
||||
it('triggers email alert for free member signup', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const email = 'newly-created-user-magic-link-test@test.com';
|
||||
await membersAgent.post('/api/send-magic-link')
|
||||
.body({
|
||||
@ -211,8 +213,48 @@ describe('sendMagicLink', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers email alert for free member signup with membersSpamPrevention enabled', async function () {
|
||||
const email = 'newly-created-user-magic-link-test-spam@test.com';
|
||||
await membersAgent.post('/api/send-magic-link')
|
||||
.body({
|
||||
email,
|
||||
emailType: 'signup'
|
||||
})
|
||||
.expectEmptyBody()
|
||||
.expectStatus(201);
|
||||
|
||||
// Check email is sent
|
||||
const mail = mockManager.assert.sentEmail({
|
||||
to: email,
|
||||
subject: /Complete your sign up to Ghost!/
|
||||
});
|
||||
|
||||
// Get link from email
|
||||
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
|
||||
const parsed = new URL(url);
|
||||
const token = parsed.searchParams.get('token');
|
||||
|
||||
// Get member data from token
|
||||
const signinLink = await membersService.api.createMemberFromToken(token);
|
||||
|
||||
// Wait for the dispatched events (because this happens async)
|
||||
await DomainEvents.allSettled();
|
||||
// Check member alert is sent to site owners
|
||||
mockManager.assert.sentEmail({
|
||||
to: 'jbloggs@example.com',
|
||||
subject: /🥳 Free member signup: newly-created-user-magic-link-test-spam@test.com/
|
||||
});
|
||||
|
||||
// Check the signin link is returned correctly
|
||||
const parsedSigninLink = new URL(signinLink);
|
||||
const signinToken = parsedSigninLink.searchParams.get('token');
|
||||
const action = parsedSigninLink.searchParams.get('action');
|
||||
should(action).equal('signin');
|
||||
should(signinToken.length).equal(32);
|
||||
});
|
||||
|
||||
it('Converts the urlHistory to the attribution and stores it in the token', async function () {
|
||||
const email = 'newly-created-user-magic-link-test-2@test.com';
|
||||
const email = 'newly-created-user-magic-link-test-10@test.com';
|
||||
await membersAgent.post('/api/send-magic-link')
|
||||
.body({
|
||||
email,
|
||||
|
@ -1,4 +1,5 @@
|
||||
const {agentProvider, mockManager, fixtureManager, configUtils, resetRateLimits, dbUtils} = require('../../utils/e2e-framework');
|
||||
const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
|
||||
const models = require('../../../core/server/models');
|
||||
const assert = require('assert/strict');
|
||||
require('should');
|
||||
@ -118,6 +119,7 @@ describe('Members Signin', function () {
|
||||
});
|
||||
|
||||
it('Will create a new member on signup', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const email = 'not-existent-member@test.com';
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
@ -146,7 +148,23 @@ describe('Members Signin', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('Will not create a new member on signup if membersSpamPrevention is enabled', async function () {
|
||||
const email = 'not-existent-member-spam@test.com';
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
const token = magicLinkUrl.searchParams.get('token');
|
||||
|
||||
await membersAgent.get(`/?token=${token}&action=signup`)
|
||||
.expectStatus(302)
|
||||
.expectHeader('Location', /success=false/);
|
||||
|
||||
const member = await getMemberByEmail(email, false);
|
||||
|
||||
assert(!member, 'Member should not have been created');
|
||||
});
|
||||
|
||||
it('Allows a signin via a signup link', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
// This member should be created by the previous test
|
||||
const email = 'not-existent-member@test.com';
|
||||
|
||||
@ -160,6 +178,19 @@ describe('Members Signin', function () {
|
||||
.expectHeader('Set-Cookie', /members-ssr.*/);
|
||||
});
|
||||
|
||||
it('Allows a signin via a signup link with membersSpamPrevention enabled', async function () {
|
||||
// This member should be created by the previous test
|
||||
const email = 'not-existent-member@test.com';
|
||||
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
const token = magicLinkUrl.searchParams.get('token');
|
||||
|
||||
await membersAgent.get(`/?token=${token}&action=signup`)
|
||||
.expectStatus(302)
|
||||
.expectHeader('Location', /\/welcome-free\/\?success=true&action=signup$/);
|
||||
});
|
||||
|
||||
it('Will not create a new member on signin', async function () {
|
||||
const email = 'not-existent-member-2@test.com';
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signin');
|
||||
@ -193,6 +224,7 @@ describe('Members Signin', function () {
|
||||
});
|
||||
|
||||
it('Expires a token after 10 minutes of first usage', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
const token = magicLinkUrl.searchParams.get('token');
|
||||
@ -246,6 +278,7 @@ describe('Members Signin', function () {
|
||||
});
|
||||
|
||||
it('Expires a token after 3 uses', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
const token = magicLinkUrl.searchParams.get('token');
|
||||
@ -459,6 +492,7 @@ describe('Members Signin', function () {
|
||||
});
|
||||
|
||||
it('Will clear rate limits for members auth', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
// Temporary increase the member_login rate limits to a higher number
|
||||
// because other wise we would hit user enumeration rate limits (this won't get reset after a succeeded login)
|
||||
// We need to do this here otherwise the middlewares are not setup correctly
|
||||
@ -548,6 +582,7 @@ describe('Members Signin', function () {
|
||||
|
||||
describe('Member attribution', function () {
|
||||
it('Will create a member attribution if magic link contains an attribution source', async function () {
|
||||
mockLabsDisabled('membersSpamPrevention');
|
||||
const email = 'non-existent-member@test.com';
|
||||
const magicLink = await membersService.api.getMagicLink(email, 'signup', {
|
||||
attribution: {
|
||||
|
62
ghost/core/test/e2e-frontend/subscribe_routes.test.js
Normal file
62
ghost/core/test/e2e-frontend/subscribe_routes.test.js
Normal file
@ -0,0 +1,62 @@
|
||||
// # Frontend Route tests
|
||||
// As it stands, these tests depend on the database, and as such are integration tests.
|
||||
// Mocking out the models to not touch the DB would turn these into unit tests, and should probably be done in future,
|
||||
// But then again testing real code, rather than mock code, might be more useful...
|
||||
const should = require('should');
|
||||
|
||||
const sinon = require('sinon');
|
||||
const supertest = require('supertest');
|
||||
const cheerio = require('cheerio');
|
||||
const testUtils = require('../utils');
|
||||
const config = require('../../core/shared/config');
|
||||
let request;
|
||||
|
||||
function assertCorrectFrontendHeaders(res) {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
should.not.exist(res.headers['X-CSRF-Token']);
|
||||
should.not.exist(res.headers['set-cookie']);
|
||||
should.exist(res.headers.date);
|
||||
}
|
||||
|
||||
describe('Frontend Routing: Subscribe Routes', function () {
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
before(async function () {
|
||||
await testUtils.startGhost();
|
||||
request = supertest.agent(config.get('url'));
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await testUtils.stopGhost();
|
||||
});
|
||||
|
||||
it('should return 404 if no token or action parameter is provided', async function () {
|
||||
await request.get('/confirm_signup/?action=signup')
|
||||
.expect(404);
|
||||
|
||||
await request.get('/confirm_signup/?token=123')
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should render the subscribe template if a token and action parameter is provided', async function () {
|
||||
await request.get('/confirm_signup/?token=123&action=signup')
|
||||
.expect('Content-Type', /html/)
|
||||
.expect(200)
|
||||
.expect(assertCorrectFrontendHeaders)
|
||||
.expect((res) => {
|
||||
const $ = cheerio.load(res.text);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
should.not.exist(res.headers['X-CSRF-Token']);
|
||||
should.not.exist(res.headers['set-cookie']);
|
||||
should.exist(res.headers.date);
|
||||
|
||||
$('#gh-subscribe-form').should.exist;
|
||||
$('#gh-subscribe-form').attr('action').should.eql('/members/api/member');
|
||||
$('input[name="token"]').val().should.eql('123');
|
||||
$('input[name="action"]').val().should.eql('signup');
|
||||
});
|
||||
});
|
||||
});
|
@ -52,7 +52,8 @@ module.exports = class RouterController {
|
||||
memberAttributionService,
|
||||
sendEmailWithMagicLink,
|
||||
labsService,
|
||||
newslettersService
|
||||
newslettersService,
|
||||
createMemberFromToken
|
||||
}) {
|
||||
this._offersAPI = offersAPI;
|
||||
this._paymentsService = paymentsService;
|
||||
@ -67,6 +68,7 @@ module.exports = class RouterController {
|
||||
this._memberAttributionService = memberAttributionService;
|
||||
this.labsService = labsService;
|
||||
this._newslettersService = newslettersService;
|
||||
this._createMemberFromToken = createMemberFromToken;
|
||||
}
|
||||
|
||||
async ensureStripe(_req, res, next) {
|
||||
@ -555,4 +557,20 @@ module.exports = class RouterController {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async createMemberFromToken(req, res) {
|
||||
const {token} = req.body;
|
||||
|
||||
// If successful, creating the member will return a signin link we can redirect the member to
|
||||
// This will sign them in automatically
|
||||
const signinLink = await this._createMemberFromToken(token);
|
||||
|
||||
if (!signinLink) {
|
||||
res.writeHead(400);
|
||||
return res.end('Bad Request.');
|
||||
}
|
||||
|
||||
// If the member exists, redirect to the members page with the token in the URL to create a session
|
||||
res.redirect(signinLink);
|
||||
}
|
||||
};
|
||||
|
@ -187,6 +187,7 @@ module.exports = function MembersAPI({
|
||||
stripeAPIService,
|
||||
tokenService,
|
||||
sendEmailWithMagicLink,
|
||||
createMemberFromToken,
|
||||
memberAttributionService,
|
||||
labsService,
|
||||
newslettersService
|
||||
@ -247,6 +248,12 @@ module.exports = function MembersAPI({
|
||||
return member;
|
||||
}
|
||||
|
||||
// If spam prevention is enabled, we don't create the member via middleware upon clicking the magic link
|
||||
// The member can only be created by following the link to /subscribe and sending a POST request to explicitly confirm
|
||||
if (labsService.isSet('membersSpamPrevention')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -271,6 +278,45 @@ module.exports = function MembersAPI({
|
||||
return getMemberIdentityData(email);
|
||||
}
|
||||
|
||||
async function createMemberFromToken(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) {
|
||||
const magicLink = getMagicLink(email, 'signin');
|
||||
return magicLink;
|
||||
}
|
||||
|
||||
// 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});
|
||||
if (newMember) {
|
||||
const magicLink = getMagicLink(email, 'signin');
|
||||
return magicLink;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getMemberIdentityData(email) {
|
||||
return memberBREADService.read({email});
|
||||
}
|
||||
@ -330,6 +376,11 @@ module.exports = function MembersAPI({
|
||||
body.json(),
|
||||
forwardError((req, res) => routerController.sendMagicLink(req, res))
|
||||
),
|
||||
createMemberFromToken: Router().use(
|
||||
body.urlencoded({extended: true}),
|
||||
body.json(),
|
||||
forwardError((req, res) => routerController.createMemberFromToken(req, res))
|
||||
),
|
||||
createCheckoutSession: Router().use(
|
||||
body.json(),
|
||||
forwardError((req, res) => routerController.createCheckoutSession(req, res))
|
||||
@ -377,6 +428,7 @@ module.exports = function MembersAPI({
|
||||
getMemberIdentityToken,
|
||||
getMemberIdentityDataFromTransientId,
|
||||
getMemberIdentityData,
|
||||
createMemberFromToken,
|
||||
cycleTransientId,
|
||||
setMemberGeolocationFromIp,
|
||||
getPublicConfig,
|
||||
|
Loading…
Reference in New Issue
Block a user