ghost 6.0.4 → 6.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/components/tryghost-i18n-6.0.6.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
  3. package/core/built/admin/assets/admin-x-activitypub/{index-DzkhcDJy.mjs → index-BRzGrD-C.mjs} +10569 -10480
  4. package/core/built/admin/assets/admin-x-activitypub/{index-BGpQ-bIj.mjs → index-Co80faUx.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-CFP3ZKm2.mjs → CodeEditorView-B4W7CQcA.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{index-qJnVka7B.mjs → index-CuwMM9FM.mjs} +6603 -6565
  8. package/core/built/admin/assets/admin-x-settings/{index-CNPz6XrY.mjs → index-jv9DN3ZO.mjs} +2 -2
  9. package/core/built/admin/assets/admin-x-settings/{modals-CZ910p4i.mjs → modals-CUGEPPYA.mjs} +6 -6
  10. package/core/built/admin/assets/{chunk.397.f965bec6bb556d2750de.js → chunk.397.1904882a4a78e2922f07.js} +2 -2
  11. package/core/built/admin/assets/{chunk.524.d7ccca2fe46a0364b56a.js → chunk.524.56bb70d3e8660d34aef1.js} +7 -7
  12. package/core/built/admin/assets/{chunk.582.6bb47c177a8f2bf9503f.js → chunk.582.ae0341229e71a85d0b2d.js} +10 -10
  13. package/core/built/admin/assets/{ghost-754c74a251b36c8775d3cb9ca6a1a85d.js → ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js} +38 -35
  14. package/core/built/admin/assets/{ghost-a0676bad4687584c2c1b3b6b8bed5b31.css → ghost-2c537ee89c36199137eafc1768fd7de8.css} +1 -1
  15. package/core/built/admin/assets/{ghost-dark-65295e8112b12fc9b301111e1c446c51.css → ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css} +1 -1
  16. package/core/built/admin/assets/posts/posts.js +25911 -25852
  17. package/core/built/admin/assets/stats/stats.js +26175 -26121
  18. package/core/built/admin/index.html +5 -5
  19. package/core/frontend/helpers/facebook_url.js +3 -0
  20. package/core/frontend/helpers/ghost_head.js +2 -1
  21. package/core/frontend/helpers/twitter_url.js +3 -0
  22. package/core/frontend/public/ghost-stats.min.js +1 -1
  23. package/core/frontend/public/member-attribution.min.js +1 -1
  24. package/core/frontend/src/ghost-stats/ghost-stats.js +1 -8
  25. package/core/frontend/src/member-attribution/member-attribution.js +4 -2
  26. package/core/server/data/tinybird/README.md +38 -10
  27. package/core/server/data/tinybird/datasources/analytics_events.datasource +3 -2
  28. package/core/server/data/tinybird/datasources/analytics_events_test.datasource +3 -2
  29. package/core/server/data/tinybird/scripts/configure-ghost.sh +4 -2
  30. package/core/server/services/koenig/node-renderers/header-v2-renderer.js +74 -4
  31. package/core/server/services/lib/magic-link/MagicLink.js +88 -8
  32. package/core/server/services/members/SingleUseTokenProvider.js +61 -1
  33. package/core/server/services/members/api.js +18 -8
  34. package/core/server/services/members/emails/signin.js +38 -4
  35. package/core/server/services/members/members-api/controllers/RouterController.js +14 -3
  36. package/core/server/services/members/members-api/members-api.js +4 -3
  37. package/core/server/services/stats/PostsStatsService.js +26 -9
  38. package/core/server/services/stats/StatsService.js +1 -1
  39. package/core/shared/config/defaults.json +2 -2
  40. package/core/shared/labs.js +2 -1
  41. package/ghost.js +0 -1
  42. package/package.json +11 -11
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/yarn.lock +253 -307
  45. package/components/tryghost-i18n-6.0.4.tgz +0 -0
  46. /package/core/built/admin/assets/{chunk.397.f965bec6bb556d2750de.js.LICENSE.txt → chunk.397.1904882a4a78e2922f07.js.LICENSE.txt} +0 -0
@@ -68,6 +68,73 @@ function cardTemplate(nodeData, options = {}) {
68
68
  `;
69
69
  }
70
70
 
71
+ function generateMSOSplitHeaderImage(nodeData) {
72
+ const {backgroundSize, backgroundImageSrc, backgroundColor} = nodeData;
73
+
74
+ if (backgroundSize === 'contain') {
75
+ return `
76
+ <!--[if mso]>
77
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" stroke="false" style="width:600px;height:320px;">
78
+ <v:fill type="frame" aspect="atmost" size="225pt,120pt" src="${backgroundImageSrc}" color="${backgroundColor}" />
79
+ <v:textbox inset="0,0,0,0">
80
+ </v:textbox>
81
+ </v:rect>
82
+ <![endif]-->
83
+ `;
84
+ } else {
85
+ return `
86
+ <!--[if mso]>
87
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:600px;height:320px;">
88
+ <v:fill type="frame" aspect="atleast" src="${backgroundImageSrc}" color="${backgroundColor}" />
89
+ <v:textbox inset="0,0,0,0">
90
+ </v:textbox>
91
+ </v:rect>
92
+ <![endif]-->
93
+ `;
94
+ }
95
+ }
96
+
97
+ function generateMSOContentWrapper(nodeData) {
98
+ const {backgroundImageSrc, backgroundColor} = nodeData;
99
+ const hasContainAndSplit = nodeData.backgroundSize === 'contain' && nodeData.layout === 'split';
100
+ const hasImageWithoutSplit = nodeData.backgroundImageSrc && nodeData.layout !== 'split';
101
+
102
+ // Outlook clients will return the first td, all other clients will return the second td
103
+ const msoOpenTag = `
104
+ <!--[if mso]>
105
+ <td class="kg-header-card-content" style="${hasImageWithoutSplit ? 'padding: 0;' : 'padding: 40px;'}${hasContainAndSplit ? 'padding-top: 0;' : ''}">
106
+ <![endif]-->
107
+ <!--[if !mso]><!-->
108
+ <td class="kg-header-card-content" style="${hasContainAndSplit ? 'padding-top: 0;' : ''}">
109
+ <!--<![endif]-->
110
+ `;
111
+
112
+ const msoImageVML = hasImageWithoutSplit ? `
113
+ <!--[if mso]>
114
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:600px;">
115
+ <v:fill src="${backgroundImageSrc}" color="${backgroundColor}" type="frame" aspect="atleast" focusposition="0.5,0.5" />
116
+ <v:textbox inset="30pt,30pt,30pt,30pt" style="mso-fit-shape-to-text:true;">
117
+ <![endif]-->
118
+ ` : '';
119
+
120
+ return msoOpenTag + msoImageVML;
121
+ }
122
+
123
+ function generateMSOContentClosing(nodeData) {
124
+ const hasImageWithoutSplit = nodeData.backgroundImageSrc && nodeData.layout !== 'split';
125
+
126
+ if (!hasImageWithoutSplit) {
127
+ return '';
128
+ }
129
+
130
+ return `
131
+ <!--[if mso]>
132
+ </v:textbox>
133
+ </v:rect>
134
+ <![endif]-->
135
+ `;
136
+ }
137
+
71
138
  function emailTemplate(nodeData, options) {
72
139
  const backgroundAccent = nodeData.backgroundColor === 'accent' ? `background-color: ${nodeData.accentColor};` : '';
73
140
  const alignment = nodeData.alignment === 'center' ? 'text-align: center;' : '';
@@ -88,17 +155,19 @@ function emailTemplate(nodeData, options) {
88
155
 
89
156
  return (
90
157
  `
91
- <div class="kg-header-card kg-v2${hasDarkBg ? ' kg-header-card-dark-bg' : 'kg-header-card-light-bg'}" style="color:${nodeData.textColor}; ${alignment} ${backgroundImageStyle} ${backgroundAccent}">
158
+ <div class="kg-header-card kg-v2 ${hasDarkBg ? 'kg-header-card-dark-bg' : 'kg-header-card-light-bg'}" style="color:${nodeData.textColor}; ${alignment} ${backgroundImageStyle} ${backgroundAccent}">
92
159
  ${nodeData.layout === 'split' && nodeData.backgroundImageSrc ? `
93
160
  <table border="0" cellpadding="0" cellspacing="0" width="100%">
94
161
  <tr>
95
- <td background="${nodeData.backgroundImageSrc}" style="${splitImageStyle}" class="kg-header-card-image"></td>
162
+ <td background="${nodeData.backgroundImageSrc}" style="${splitImageStyle}" class="kg-header-card-image" bgcolor="${nodeData.backgroundColor}" align="center">
163
+ ${generateMSOSplitHeaderImage(nodeData) /* mso-only img, no shared markup */}
164
+ </td>
96
165
  </tr>
97
166
  </table>
98
167
  ` : ''}
99
168
  <table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:${nodeData.textColor}; ${alignment} ${backgroundImageStyle} ${backgroundAccent}">
100
169
  <tr>
101
- <td class="kg-header-card-content" style="${nodeData.layout === 'split' && nodeData.backgroundSize === 'contain' ? 'padding-top: 0;' : ''}">
170
+ ${generateMSOContentWrapper(nodeData) /* creates correct opening td tag for any platform */}
102
171
  <table border="0" cellpadding="0" cellspacing="0" width="100%">
103
172
  <tr>
104
173
  <td align="${nodeData.alignment}">
@@ -118,6 +187,7 @@ function emailTemplate(nodeData, options) {
118
187
  ` : ''}
119
188
  </tr>
120
189
  </table>
190
+ ${generateMSOContentClosing(nodeData) /* mso-only closing tags, no shared markup */}
121
191
  </td>
122
192
  </tr>
123
193
  </table>
@@ -209,4 +279,4 @@ function getCardClasses(nodeData) {
209
279
  return cardClasses;
210
280
  }
211
281
 
212
- module.exports = renderHeaderNodeV2;
282
+ module.exports = renderHeaderNodeV2;
@@ -1,6 +1,7 @@
1
1
  const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors');
2
2
  const {isEmail} = require('@tryghost/validator');
3
3
  const tpl = require('@tryghost/tpl');
4
+
4
5
  const messages = {
5
6
  invalidEmail: 'Email is not valid'
6
7
  };
@@ -17,6 +18,8 @@ const messages = {
17
18
  * @typedef {Object} TokenProvider<T, D>
18
19
  * @prop {(data: D) => Promise<T>} create
19
20
  * @prop {(token: T) => Promise<D>} validate
21
+ * @prop {(token: T) => Promise<string | null>} [getIdByToken]
22
+ * @prop {(tokenId: string, tokenValue: T) => string} [deriveOTC]
20
23
  */
21
24
 
22
25
  /**
@@ -34,6 +37,7 @@ class MagicLink {
34
37
  * @param {typeof defaultGetHTML} [options.getHTML]
35
38
  * @param {typeof defaultGetSubject} [options.getSubject]
36
39
  * @param {object} [options.sentry]
40
+ * @param {{isSet(name: string): boolean}} [options.labsService]
37
41
  */
38
42
  constructor(options) {
39
43
  if (!options || !options.transporter || !options.tokenProvider || !options.getSigninURL) {
@@ -46,6 +50,7 @@ class MagicLink {
46
50
  this.getHTML = options.getHTML || defaultGetHTML;
47
51
  this.getSubject = options.getSubject || defaultGetSubject;
48
52
  this.sentry = options.sentry || undefined;
53
+ this.labsService = options.labsService || undefined;
49
54
  }
50
55
 
51
56
  /**
@@ -56,7 +61,8 @@ class MagicLink {
56
61
  * @param {TokenData} options.tokenData - The data for token
57
62
  * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
58
63
  * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
59
- * @returns {Promise<{token: Token, info: SentMessageInfo}>}
64
+ * @param {boolean} [options.includeOTC=false] - Whether to send a one-time-code in the email.
65
+ * @returns {Promise<{token: Token, tokenId: string | null, info: SentMessageInfo}>}
60
66
  */
61
67
  async sendMagicLink(options) {
62
68
  this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});
@@ -73,14 +79,38 @@ class MagicLink {
73
79
 
74
80
  const url = this.getSigninURL(token, type, options.referrer);
75
81
 
82
+ let otc = null;
83
+ if (this.labsService?.isSet('membersSigninOTC') && options.includeOTC) {
84
+ try {
85
+ otc = await this.getOTCFromToken(token);
86
+ } catch (err) {
87
+ this.sentry?.captureException?.(err);
88
+ otc = null;
89
+ }
90
+ }
91
+
76
92
  const info = await this.transporter.sendMail({
77
93
  to: options.email,
78
- subject: this.getSubject(type),
79
- text: this.getText(url, type, options.email),
80
- html: this.getHTML(url, type, options.email)
94
+ subject: this.getSubject(type, otc),
95
+ text: this.getText(url, type, options.email, otc),
96
+ html: this.getHTML(url, type, options.email, otc)
81
97
  });
82
98
 
83
- return {token, info};
99
+ // return tokenId so we can pass it as a reference to the client so it
100
+ // can pass it back as a reference when verifying the OTC. We only do
101
+ // this if we've successfully generated an OTC to avoid clients showing
102
+ // a token input field when the email doesn't contain an OTC
103
+ let tokenId = null;
104
+ if (this.labsService?.isSet('membersSigninOTC') && otc) {
105
+ try {
106
+ tokenId = await this.getIdFromToken(token);
107
+ } catch (err) {
108
+ this.sentry?.captureException?.(err);
109
+ tokenId = null;
110
+ }
111
+ }
112
+
113
+ return {token, tokenId, info};
84
114
  }
85
115
 
86
116
  /**
@@ -99,6 +129,38 @@ class MagicLink {
99
129
  return this.getSigninURL(token, type, options.referrer);
100
130
  }
101
131
 
132
+ /**
133
+ * getIdFromToken
134
+ *
135
+ * @param {Token} token - The token to get the id from
136
+ * @returns {Promise<string|null>} id - The id of the token
137
+ */
138
+ async getIdFromToken(token) {
139
+ if (typeof this.tokenProvider.getIdByToken !== 'function') {
140
+ return null;
141
+ }
142
+
143
+ const id = await this.tokenProvider.getIdByToken(token);
144
+ return id;
145
+ }
146
+
147
+ /**
148
+ * getOTCFromToken
149
+ *
150
+ * @param {Token} token - The token to get the otc from
151
+ * @returns {Promise<string|null>} otc - The otc of the token
152
+ */
153
+ async getOTCFromToken(token) {
154
+ const tokenId = await this.getIdFromToken(token);
155
+
156
+ if (!tokenId || typeof this.tokenProvider.deriveOTC !== 'function') {
157
+ return null;
158
+ }
159
+
160
+ const otc = await this.tokenProvider.deriveOTC(tokenId, token);
161
+ return otc;
162
+ }
163
+
102
164
  /**
103
165
  * getDataFromToken
104
166
  *
@@ -117,13 +179,19 @@ class MagicLink {
117
179
  * @param {URL} url - The url which will trigger sign in flow
118
180
  * @param {string} type - The type of email to send e.g. signin, signup
119
181
  * @param {string} email - The recipient of the email to send
182
+ * @param {string} otc - Optional one-time-code
120
183
  * @returns {string} text - The text content of an email to send
121
184
  */
122
- function defaultGetText(url, type, email) {
185
+ function defaultGetText(url, type, email, otc) {
123
186
  let msg = 'sign in';
124
187
  if (type === 'signup') {
125
188
  msg = 'confirm your email address';
126
189
  }
190
+
191
+ if (otc) {
192
+ return `Enter the code ${otc} or click here to ${msg} ${url}. This msg was sent to ${email}`;
193
+ }
194
+
127
195
  return `Click here to ${msg} ${url}. This msg was sent to ${email}`;
128
196
  }
129
197
 
@@ -133,13 +201,19 @@ function defaultGetText(url, type, email) {
133
201
  * @param {URL} url - The url which will trigger sign in flow
134
202
  * @param {string} type - The type of email to send e.g. signin, signup
135
203
  * @param {string} email - The recipient of the email to send
204
+ * @param {string} otc - Optional one-time-code
136
205
  * @returns {string} HTML - The HTML content of an email to send
137
206
  */
138
- function defaultGetHTML(url, type, email) {
207
+ function defaultGetHTML(url, type, email, otc) {
139
208
  let msg = 'sign in';
140
209
  if (type === 'signup') {
141
210
  msg = 'confirm your email address';
142
211
  }
212
+
213
+ if (otc) {
214
+ return `Enter the code ${otc} or <a href="${url}">click here to ${msg}</a> This msg was sent to ${email}`;
215
+ }
216
+
143
217
  return `<a href="${url}">Click here to ${msg}</a> This msg was sent to ${email}`;
144
218
  }
145
219
 
@@ -147,12 +221,18 @@ function defaultGetHTML(url, type, email) {
147
221
  * defaultGetSubject
148
222
  *
149
223
  * @param {string} type - The type of email to send e.g. signin, signup
224
+ * @param {string} otc - Optional one-time-code
150
225
  * @returns {string} subject - The subject of an email to send
151
226
  */
152
- function defaultGetSubject(type) {
227
+ function defaultGetSubject(type, otc) {
153
228
  if (type === 'signup') {
154
229
  return `Signup!`;
155
230
  }
231
+
232
+ if (otc) {
233
+ return `Your signin verification code is ${otc}`;
234
+ }
235
+
156
236
  return `Signin!`;
157
237
  }
158
238
 
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
  const {ValidationError} = require('@tryghost/errors');
3
+ const crypto = require('node:crypto');
4
+ const {hotp} = require('otplib');
3
5
 
4
6
  class SingleUseTokenProvider {
5
7
  /**
@@ -8,12 +10,14 @@ class SingleUseTokenProvider {
8
10
  * @param {number} dependencies.validityPeriod - How long a token is valid for from it's creation in milliseconds.
9
11
  * @param {number} dependencies.validityPeriodAfterUsage - How long a token is valid after first usage, in milliseconds.
10
12
  * @param {number} dependencies.maxUsageCount - How many times a token can be used.
13
+ * @param {string} [dependencies.secret] - Secret for generating and verifying OTP codes.
11
14
  */
12
- constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount}) {
15
+ constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount, secret}) {
13
16
  this.model = SingleUseTokenModel;
14
17
  this.validityPeriod = validityPeriod;
15
18
  this.validityPeriodAfterUsage = validityPeriodAfterUsage;
16
19
  this.maxUsageCount = maxUsageCount;
20
+ this.secret = secret;
17
21
  }
18
22
 
19
23
  /**
@@ -106,6 +110,62 @@ class SingleUseTokenProvider {
106
110
  return {};
107
111
  }
108
112
  }
113
+
114
+ /**
115
+ * @private
116
+ * @method deriveCounter
117
+ * Derives a counter from a token ID and value
118
+ *
119
+ * @param {string} tokenId
120
+ * @param {string} tokenValue
121
+ * @returns {number}
122
+ */
123
+ deriveCounter(tokenId, tokenValue) {
124
+ const msg = `${tokenId}|${tokenValue}`;
125
+ const digest = crypto.createHash('sha256').update(msg).digest();
126
+ return digest.readUInt32BE(0);
127
+ }
128
+
129
+ /**
130
+ * @method deriveOTC
131
+ * Derives an OTC (one-time code) from a token ID and value
132
+ *
133
+ * @param {string} tokenId - Token ID
134
+ * @param {string} tokenValue - Token value
135
+ * @returns {string} The generated one-time code
136
+ */
137
+ deriveOTC(tokenId, tokenValue) {
138
+ if (!this.secret) {
139
+ throw new ValidationError({
140
+ message: 'Cannot derive OTC: secret not configured'
141
+ });
142
+ }
143
+
144
+ if (!tokenId || !tokenValue) {
145
+ throw new ValidationError({
146
+ message: 'Cannot derive OTC: tokenId and tokenValue are required'
147
+ });
148
+ }
149
+
150
+ const counter = this.deriveCounter(tokenId, tokenValue);
151
+ return hotp.generate(this.secret, counter);
152
+ }
153
+
154
+ /**
155
+ * @method getIdByToken
156
+ * Retrieves the ID associated with a given token.
157
+ *
158
+ * @param {string} token - The token to look up.
159
+ * @returns {Promise<string|null>} The ID if found, or null if not found or on error.
160
+ */
161
+ async getIdByToken(token) {
162
+ try {
163
+ const model = await this.model.findOne({token});
164
+ return model ? model.get('id') : null;
165
+ } catch (err) {
166
+ return null;
167
+ }
168
+ }
109
169
  }
110
170
 
111
171
  module.exports = SingleUseTokenProvider;
@@ -57,7 +57,8 @@ function createApiInstance(config) {
57
57
  SingleUseTokenModel: models.SingleUseToken,
58
58
  validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
59
59
  validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
60
- maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT
60
+ maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT,
61
+ secret: settingsCache.get('members_email_auth_secret')
61
62
  })
62
63
  },
63
64
  mail: {
@@ -75,7 +76,7 @@ function createApiInstance(config) {
75
76
  return ghostMailer.send(msg);
76
77
  }
77
78
  },
78
- getSubject(type) {
79
+ getSubject(type, otc) {
79
80
  const siteTitle = settingsCache.get('title');
80
81
  switch (type) {
81
82
  case 'subscribe':
@@ -88,10 +89,14 @@ function createApiInstance(config) {
88
89
  return `📫 ${t(`Confirm your email update for {siteTitle}!`, {siteTitle, interpolation: {escapeValue: false}})}`;
89
90
  case 'signin':
90
91
  default:
91
- return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
92
+ if (otc) {
93
+ return `🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}`;
94
+ } else {
95
+ return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
96
+ }
92
97
  }
93
98
  },
94
- getText(url, type, email) {
99
+ getText(url, type, email, otc) {
95
100
  const siteTitle = settingsCache.get('title');
96
101
  switch (type) {
97
102
  case 'subscribe':
@@ -162,10 +167,14 @@ function createApiInstance(config) {
162
167
  `;
163
168
  case 'signin':
164
169
  default:
170
+ /* eslint-disable indent */
165
171
  return trimLeadingWhitespace`
166
172
  ${t(`Hey there,`)}
167
173
 
168
- ${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}
174
+ ${otc
175
+ ? `${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}: ${otc}\n\n${t('Or use this link to securely sign in', {interpolation: {escapeValue: false}})}:`
176
+ : `${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}`
177
+ }
169
178
 
170
179
  ${url}
171
180
 
@@ -177,10 +186,11 @@ function createApiInstance(config) {
177
186
 
178
187
  ${t('Sent to {email}', {email})}
179
188
  ${t('If you did not make this request, you can safely ignore this email.')}
180
- `;
189
+ `;
190
+ /* eslint-enable indent */
181
191
  }
182
192
  },
183
- getHTML(url, type, email) {
193
+ getHTML(url, type, email, otc) {
184
194
  const siteTitle = settingsCache.get('title');
185
195
  const siteUrl = urlUtils.urlFor('home', true);
186
196
  const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
@@ -197,7 +207,7 @@ function createApiInstance(config) {
197
207
  return updateEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
198
208
  case 'signin':
199
209
  default:
200
- return signinEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
210
+ return signinEmail({t, url, otc, email, siteTitle, accentColor, siteDomain, siteUrl});
201
211
  }
202
212
  }
203
213
  },
@@ -1,10 +1,14 @@
1
- module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain, siteUrl}) => `
1
+ /* eslint-disable indent */
2
+ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteDomain, siteUrl}) => `
2
3
  <!doctype html>
3
4
  <html>
4
5
  <head>
5
6
  <meta name="viewport" content="width=device-width">
6
7
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
- <title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>
8
+ ${otc
9
+ ? `<title>🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
10
+ : `<title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
11
+ }
8
12
  <style>
9
13
  /* -------------------------------------
10
14
  RESPONSIVE AND MOBILE FRIENDLY STYLES
@@ -117,7 +121,36 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
117
121
  <tr>
118
122
  <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;">
119
123
  <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;">${t('Hey there,')}</p>
120
- <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; margin: 0; line-height: 24px; margin-bottom: 32px;">${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}</p>
124
+ ${otc ?
125
+ `<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; margin: 0; line-height: 24px; margin-bottom: 24px;">${t(`Welcome back! Here's your code to sign in to {siteTitle}. For your security, it's valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
126
+ <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; margin-bottom: 32px;">
127
+ <tbody>
128
+ <tr>
129
+ <td style="padding: 16px; background-color: #F4F5F6; border-radius: 8px; text-align: center; vertical-align: middle;" valign="middle">
130
+ <h2 style="text-align: center; vertical-align: center; letter-spacing: 5px; font-size: 24px; color: #15212A; font-weight: 600; line-height: 24px; margin: 0;">${otc}</h2>
131
+ </td>
132
+ </tr>
133
+ </tbody>
134
+ </table>
135
+ <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; margin: 0; line-height: 24px; margin-bottom: 24px;">${t('Or, skip the code and log in directly')}:</p>
136
+ <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;">
137
+ <tbody>
138
+ <tr>
139
+ <td align="center" 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;">
140
+ <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
141
+ <tbody>
142
+ <tr>
143
+ <td align="center" 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: ${accentColor}; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: ${accentColor}; border: solid 1px ${accentColor}; 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: ${accentColor};">${t('Sign in now')}</a> </td>
144
+ </tr>
145
+ </tbody>
146
+ </table>
147
+ </td>
148
+ </tr>
149
+ </tbody>
150
+ </table>
151
+ <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: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('Alternatively you can copy and paste this URL to your browser')}:</p>`
152
+ :
153
+ `<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; margin: 0; line-height: 24px; margin-bottom: 32px;">${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}</p>
121
154
  <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;">
122
155
  <tbody>
123
156
  <tr>
@@ -136,7 +169,8 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
136
169
  <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;">${t('For your security, the link will expire in 24 hours time.')}</p>
137
170
  <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;">${t('See you soon!')}</p>
138
171
  <hr/>
139
- <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: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p>
172
+ <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: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p>`
173
+ }
140
174
  <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;">${url}</p>
141
175
  </td>
142
176
  </tr>
@@ -626,7 +626,12 @@ module.exports = class RouterController {
626
626
  if (emailType === 'signup' || emailType === 'subscribe') {
627
627
  await this._handleSignup(req, normalizedEmail, referrer);
628
628
  } else {
629
- await this._handleSignin(req, normalizedEmail, referrer);
629
+ const signIn = await this._handleSignin(req, normalizedEmail, referrer);
630
+
631
+ if (this.labsService.isSet('membersSigninOTC') && signIn.tokenId) {
632
+ res.writeHead(201, {'Content-Type': 'application/json'});
633
+ return res.end(JSON.stringify({otc_ref: signIn.tokenId}));
634
+ }
630
635
  }
631
636
 
632
637
  res.writeHead(201);
@@ -679,7 +684,13 @@ module.exports = class RouterController {
679
684
  }
680
685
 
681
686
  async _handleSignin(req, normalizedEmail, referrer = null) {
682
- const {emailType} = req.body;
687
+ const {emailType, otc} = req.body;
688
+
689
+ let includeOTC = false;
690
+
691
+ if (this.labsService.isSet('membersSigninOTC') && (otc === true || otc === 'true')) {
692
+ includeOTC = true;
693
+ }
683
694
 
684
695
  const member = await this._memberRepository.get({email: normalizedEmail});
685
696
 
@@ -690,7 +701,7 @@ module.exports = class RouterController {
690
701
  }
691
702
 
692
703
  const tokenData = {};
693
- return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
704
+ return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer, includeOTC});
694
705
  }
695
706
 
696
707
  /**
@@ -158,7 +158,8 @@ module.exports = function MembersAPI({
158
158
  getText,
159
159
  getHTML,
160
160
  getSubject,
161
- sentry
161
+ sentry,
162
+ labsService
162
163
  });
163
164
 
164
165
  const paymentsService = new PaymentsService({
@@ -207,7 +208,7 @@ module.exports = function MembersAPI({
207
208
 
208
209
  const users = memberRepository;
209
210
 
210
- async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
211
+ async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null, includeOTC = false}) {
211
212
  let type = requestedType;
212
213
  if (!options.forceEmailType) {
213
214
  const member = await users.get({email});
@@ -217,7 +218,7 @@ module.exports = function MembersAPI({
217
218
  type = 'signup';
218
219
  }
219
220
  }
220
- return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
221
+ return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer, includeOTC});
221
222
  }
222
223
 
223
224
  /**