ghost 6.0.5 → 6.0.7

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 (48) hide show
  1. package/components/tryghost-i18n-6.0.7.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-DBxGycCG.mjs → index-B-ckGCDl.mjs} +20083 -16775
  4. package/core/built/admin/assets/admin-x-activitypub/{index-Db9aLAi4.mjs → index-C81KQoIh.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-B4W7CQcA.mjs → CodeEditorView-BfL5FINN.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-jv9DN3ZO.mjs → index-C_ZS-INP.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{index-CuwMM9FM.mjs → index-DiNZ3HQD.mjs} +12 -4
  9. package/core/built/admin/assets/admin-x-settings/{modals-CUGEPPYA.mjs → modals-BEiWgHsk.mjs} +8807 -8799
  10. package/core/built/admin/assets/{chunk.524.0e2bff9b664f925d7af7.js → chunk.524.9f989b3664418d6271a7.js} +6 -6
  11. package/core/built/admin/assets/{chunk.582.a8f6c436bbec6f9ba678.js → chunk.582.2421014e45b977b43b68.js} +8 -8
  12. package/core/built/admin/assets/{ghost-2b02de85a93ec9a5623180b373cece35.js → ghost-182ed60de3f37fff8a40cdd65d3bd2ef.js} +55 -50
  13. package/core/built/admin/assets/{ghost-2c537ee89c36199137eafc1768fd7de8.css → ghost-a7a53bf80dc45c37ae9c174a0d02a882.css} +1 -1
  14. package/core/built/admin/assets/{ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css → ghost-dark-6e0062029f988d8676e87f22d8e7f4a3.css} +1 -1
  15. package/core/built/admin/assets/posts/posts.js +78821 -77904
  16. package/core/built/admin/assets/stats/stats.js +21689 -21645
  17. package/core/built/admin/index.html +4 -4
  18. package/core/server/data/tinybird/datasources/analytics_events.datasource +3 -2
  19. package/core/server/data/tinybird/datasources/analytics_events_test.datasource +3 -2
  20. package/core/server/data/tinybird/endpoints/api_top_utm_campaigns.pipe +31 -0
  21. package/core/server/data/tinybird/endpoints/api_top_utm_contents.pipe +31 -0
  22. package/core/server/data/tinybird/endpoints/api_top_utm_mediums.pipe +31 -0
  23. package/core/server/data/tinybird/endpoints/api_top_utm_sources.pipe +31 -0
  24. package/core/server/data/tinybird/endpoints/api_top_utm_terms.pipe +31 -0
  25. package/core/server/data/tinybird/scripts/configure-ghost.sh +4 -2
  26. package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +108 -0
  27. package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +108 -0
  28. package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +108 -0
  29. package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +108 -0
  30. package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +108 -0
  31. package/core/server/services/lib/magic-link/MagicLink.js +98 -12
  32. package/core/server/services/members/MembersConfigProvider.js +11 -1
  33. package/core/server/services/members/SingleUseTokenProvider.js +218 -5
  34. package/core/server/services/members/api.js +18 -8
  35. package/core/server/services/members/emails/signin.js +42 -5
  36. package/core/server/services/members/members-api/controllers/RouterController.js +105 -18
  37. package/core/server/services/members/members-api/members-api.js +12 -7
  38. package/core/server/services/members/members-ssr.js +5 -3
  39. package/core/server/services/members/middleware.js +2 -2
  40. package/core/server/services/stats/PostsStatsService.js +26 -9
  41. package/core/server/services/stats/StatsService.js +1 -1
  42. package/core/server/web/members/app.js +12 -3
  43. package/core/shared/config/defaults.json +1 -1
  44. package/core/shared/labs.js +3 -1
  45. package/package.json +9 -9
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/yarn.lock +213 -323
  48. package/components/tryghost-i18n-6.0.5.tgz +0 -0
@@ -1,5 +1,16 @@
1
1
  // @ts-check
2
2
  const {ValidationError} = require('@tryghost/errors');
3
+ const tpl = require('@tryghost/tpl');
4
+ const crypto = require('node:crypto');
5
+ const {hotp} = require('otplib');
6
+
7
+ const messages = {
8
+ OTC_SECRET_NOT_CONFIGURED: 'OTC secret not configured',
9
+ INVALID_OTC_VERIFICATION_HASH: 'Invalid OTC verification hash',
10
+ INVALID_TOKEN: 'Invalid token provided',
11
+ TOKEN_EXPIRED: 'Token expired',
12
+ DERIVE_OTC_MISSING_INPUT: 'tokenId and tokenValue are required'
13
+ };
3
14
 
4
15
  class SingleUseTokenProvider {
5
16
  /**
@@ -8,12 +19,14 @@ class SingleUseTokenProvider {
8
19
  * @param {number} dependencies.validityPeriod - How long a token is valid for from it's creation in milliseconds.
9
20
  * @param {number} dependencies.validityPeriodAfterUsage - How long a token is valid after first usage, in milliseconds.
10
21
  * @param {number} dependencies.maxUsageCount - How many times a token can be used.
22
+ * @param {string} [dependencies.secret] - Secret for generating and verifying OTP codes.
11
23
  */
12
- constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount}) {
24
+ constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount, secret}) {
13
25
  this.model = SingleUseTokenModel;
14
26
  this.validityPeriod = validityPeriod;
15
27
  this.validityPeriodAfterUsage = validityPeriodAfterUsage;
16
28
  this.maxUsageCount = maxUsageCount;
29
+ this.secret = secret;
17
30
  }
18
31
 
19
32
  /**
@@ -39,6 +52,9 @@ class SingleUseTokenProvider {
39
52
  * If the token is invalid the returned Promise will reject.
40
53
  *
41
54
  * @param {string} token
55
+ * @param {Object} [options] - Optional configuration object
56
+ * @param {Object} [options.transacting] - Database transaction object
57
+ * @param {string} [options.otcVerification] - OTC verification hash for additional validation
42
58
  *
43
59
  * @returns {Promise<Object<string, any>>}
44
60
  */
@@ -52,17 +68,29 @@ class SingleUseTokenProvider {
52
68
  });
53
69
  }
54
70
 
71
+ if (options.otcVerification) {
72
+ const isValidOTCVerification = await this._validateOTCVerificationHash(options.otcVerification, token);
73
+ if (!isValidOTCVerification) {
74
+ throw new ValidationError({
75
+ message: tpl(messages.INVALID_OTC_VERIFICATION_HASH),
76
+ code: 'INVALID_OTC_VERIFICATION_HASH'
77
+ });
78
+ }
79
+ }
80
+
55
81
  const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
56
82
 
57
83
  if (!model) {
58
84
  throw new ValidationError({
59
- message: 'Invalid token provided'
85
+ message: tpl(messages.INVALID_TOKEN),
86
+ code: 'INVALID_TOKEN'
60
87
  });
61
88
  }
62
89
 
63
90
  if (model.get('used_count') >= this.maxUsageCount) {
64
91
  throw new ValidationError({
65
- message: 'Token expired'
92
+ message: tpl(messages.TOKEN_EXPIRED),
93
+ code: 'TOKEN_EXPIRED'
66
94
  });
67
95
  }
68
96
 
@@ -75,7 +103,8 @@ class SingleUseTokenProvider {
75
103
 
76
104
  if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
77
105
  throw new ValidationError({
78
- message: 'Token expired'
106
+ message: tpl(messages.TOKEN_EXPIRED),
107
+ code: 'TOKEN_EXPIRED'
79
108
  });
80
109
  }
81
110
  }
@@ -83,7 +112,8 @@ class SingleUseTokenProvider {
83
112
 
84
113
  if (tokenLifetimeMilliseconds > this.validityPeriod) {
85
114
  throw new ValidationError({
86
- message: 'Token expired'
115
+ message: tpl(messages.TOKEN_EXPIRED),
116
+ code: 'TOKEN_EXPIRED'
87
117
  });
88
118
  }
89
119
 
@@ -106,6 +136,189 @@ class SingleUseTokenProvider {
106
136
  return {};
107
137
  }
108
138
  }
139
+
140
+ /**
141
+ * @private
142
+ * @method deriveCounter
143
+ * Derives a counter from a token ID and value
144
+ *
145
+ * @param {string} tokenId
146
+ * @param {string} tokenValue
147
+ * @returns {number}
148
+ */
149
+ deriveCounter(tokenId, tokenValue) {
150
+ const msg = `${tokenId}|${tokenValue}`;
151
+ const digest = crypto.createHash('sha256').update(msg).digest();
152
+ return digest.readUInt32BE(0);
153
+ }
154
+
155
+ /**
156
+ * @method deriveOTC
157
+ * Derives an OTC (one-time code) from a token ID and value
158
+ *
159
+ * @param {string} tokenId - Token ID
160
+ * @param {string} tokenValue - Token value
161
+ * @returns {string} The generated one-time code
162
+ */
163
+ deriveOTC(tokenId, tokenValue) {
164
+ if (!this.secret) {
165
+ throw new ValidationError({
166
+ message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
167
+ code: 'OTC_SECRET_NOT_CONFIGURED'
168
+ });
169
+ }
170
+
171
+ if (!tokenId || !tokenValue) {
172
+ throw new ValidationError({
173
+ message: tpl(messages.DERIVE_OTC_MISSING_INPUT),
174
+ code: 'DERIVE_OTC_MISSING_INPUT'
175
+ });
176
+ }
177
+
178
+ const counter = this.deriveCounter(tokenId, tokenValue);
179
+ return hotp.generate(this.secret, counter);
180
+ }
181
+
182
+ /**
183
+ * @method verifyOTC
184
+ * Verifies an OTC (one-time code) by looking up the token and performing HOTP verification
185
+ *
186
+ * @param {string} otcRef - Reference for the one-time code
187
+ * @param {string} otc - The one-time code to verify
188
+ * @returns {Promise<boolean>} Returns true if the OTC is valid, false otherwise
189
+ */
190
+ async verifyOTC(otcRef, otc) {
191
+ if (!this.secret || !otcRef || !otc) {
192
+ return false;
193
+ }
194
+
195
+ try {
196
+ const model = await this.model.findOne({id: otcRef});
197
+
198
+ if (!model) {
199
+ return false;
200
+ }
201
+
202
+ const tokenValue = model.get('token');
203
+ const counter = this.deriveCounter(otcRef, tokenValue);
204
+ return hotp.verify({token: otc, secret: this.secret, counter});
205
+ } catch (err) {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * @method getIdByToken
212
+ * Retrieves the ID associated with a given token.
213
+ *
214
+ * @param {string} token - The token to look up.
215
+ * @returns {Promise<string|null>} The ID if found, or null if not found or on error.
216
+ */
217
+ async getIdByToken(token) {
218
+ try {
219
+ const model = await this.model.findOne({token});
220
+ return model ? model.get('id') : null;
221
+ } catch (err) {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * @method getTokenByRef
228
+ * Retrieves the token associated with a given reference.
229
+ *
230
+ * @param {string} ref - The reference to look up.
231
+ * @returns {Promise<string|null>} The token if found, or null if not found or on error.
232
+ */
233
+ async getTokenByRef(ref) {
234
+ try {
235
+ const model = await this.model.findOne({id: ref});
236
+ return model ? model.get('token') : null;
237
+ } catch (err) {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * @method createOTCVerificationHash
244
+ * Creates an OTC verification hash for a given token and one-time code.
245
+ *
246
+ * @param {string} otc - The one-time code
247
+ * @param {string} token - The token value
248
+ * @param {number} [timestamp] - Optional timestamp to use for the hash, defaults to current time
249
+ * @returns {string} The OTC verification hash
250
+ */
251
+ createOTCVerificationHash(otc, token, timestamp) {
252
+ if (!this.secret) {
253
+ throw new ValidationError({
254
+ message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
255
+ code: 'OTC_SECRET_NOT_CONFIGURED'
256
+ });
257
+ }
258
+
259
+ // timestamp allows us to restrict the hash's lifetime window
260
+ timestamp ??= Math.floor(Date.now() / 1000);
261
+
262
+ const dataToHash = `${otc}:${token}:${timestamp}`;
263
+
264
+ const secret = Buffer.from(this.secret, 'hex');
265
+ const hmac = crypto.createHmac('sha256', secret);
266
+ hmac.update(dataToHash);
267
+
268
+ return hmac.digest('hex');
269
+ }
270
+
271
+ /**
272
+ * @private
273
+ * @method _validateOTCVerificationHash
274
+ * Validates OTC verification hash by recreating and comparing the hash.
275
+ * Private because it's only used internally by the public validate method.
276
+ *
277
+ * @param {string} otcVerificationHash - The hash to validate (timestamp:hash format)
278
+ * @param {string} token - The token value
279
+ * @returns {Promise<boolean>} - True if hash is valid, false otherwise
280
+ */
281
+ async _validateOTCVerificationHash(otcVerificationHash, token) {
282
+ try {
283
+ if (!this.secret || !otcVerificationHash || !token) {
284
+ return false;
285
+ }
286
+
287
+ // Parse timestamp:hash format
288
+ const parts = otcVerificationHash.split(':');
289
+ if (parts.length !== 2) {
290
+ return false;
291
+ }
292
+
293
+ const timestamp = parseInt(parts[0]);
294
+ const providedHash = parts[1];
295
+
296
+ // Check if hash is expired (5 minute window)
297
+ const now = Math.floor(Date.now() / 1000);
298
+ const maxAge = 5 * 60; // 5 minutes in seconds
299
+ if (now - timestamp > maxAge) {
300
+ return false;
301
+ }
302
+
303
+ const tokenId = await this.getIdByToken(token);
304
+ if (!tokenId) {
305
+ return false;
306
+ }
307
+
308
+ // Derive the original OTC that was used to create this hash
309
+ const otc = this.deriveOTC(tokenId, token);
310
+
311
+ const expectedHash = this.createOTCVerificationHash(otc, token, timestamp);
312
+
313
+ // Compare the hashes using constant-time comparison to prevent timing attacks
314
+ return crypto.timingSafeEqual(
315
+ Buffer.from(providedHash, 'hex'),
316
+ Buffer.from(expectedHash, 'hex')
317
+ );
318
+ } catch (err) {
319
+ return false;
320
+ }
321
+ }
109
322
  }
110
323
 
111
324
  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('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, 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('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, 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
@@ -107,7 +111,10 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
107
111
  <div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
108
112
 
109
113
  <!-- START CENTERED CONTAINER -->
110
- <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;">${t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
114
+ <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;">${otc ? t('Welcome back to {siteTitle}! Your verification code is {otc}.', {siteTitle, otc, interpolation: {escapeValue: false}}) : t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
115
+ <div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
116
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
117
+ </div>
111
118
  <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
112
119
 
113
120
  <!-- START MAIN CONTENT AREA -->
@@ -117,7 +124,36 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
117
124
  <tr>
118
125
  <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
126
  <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>
127
+ ${otc ?
128
+ `<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 only valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
129
+ <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;">
130
+ <tbody>
131
+ <tr>
132
+ <td style="padding: 16px; background-color: #F4F5F6; border-radius: 8px; text-align: center; vertical-align: middle;" valign="middle">
133
+ <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>
134
+ </td>
135
+ </tr>
136
+ </tbody>
137
+ </table>
138
+ <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 sign in directly')}:</p>
139
+ <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;">
140
+ <tbody>
141
+ <tr>
142
+ <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;">
143
+ <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
144
+ <tbody>
145
+ <tr>
146
+ <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>
147
+ </tr>
148
+ </tbody>
149
+ </table>
150
+ </td>
151
+ </tr>
152
+ </tbody>
153
+ </table>
154
+ <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>`
155
+ :
156
+ `<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
157
  <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
158
  <tbody>
123
159
  <tr>
@@ -136,7 +172,8 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
136
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: 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
173
  <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
174
  <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>
175
+ <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>`
176
+ }
140
177
  <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
178
  </td>
142
179
  </tr>
@@ -25,9 +25,31 @@ const messages = {
25
25
  invalidType: 'Invalid checkout type.',
26
26
  notConfigured: 'This site is not accepting payments at the moment.',
27
27
  invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
28
- archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}'
28
+ archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}',
29
+ otcNotSupported: 'OTC verification not supported.',
30
+ invalidCode: 'Invalid verification code.',
31
+ failedToVerifyCode: 'Failed to verify code, please try again.'
29
32
  };
30
33
 
34
+ // helper utility for logic shared between sendMagicLink and verifyOTC
35
+ function extractRefererOrRedirect(req) {
36
+ const {autoRedirect, redirect} = req.body;
37
+
38
+ if (autoRedirect === false) {
39
+ return null;
40
+ }
41
+
42
+ if (redirect) {
43
+ try {
44
+ return new URL(redirect).href;
45
+ } catch (e) {
46
+ logging.warn(e);
47
+ }
48
+ }
49
+
50
+ return req.get('referer') || null;
51
+ }
52
+
31
53
  module.exports = class RouterController {
32
54
  /**
33
55
  * RouterController
@@ -558,21 +580,10 @@ module.exports = class RouterController {
558
580
  }
559
581
 
560
582
  async sendMagicLink(req, res) {
561
- const {email, honeypot, autoRedirect} = req.body;
562
- let {emailType, redirect} = req.body;
583
+ const {email, honeypot} = req.body;
584
+ let {emailType} = req.body;
563
585
 
564
- let referrer = req.get('referer');
565
- if (autoRedirect === false){
566
- referrer = null;
567
- }
568
- if (redirect) {
569
- try {
570
- // Validate URL
571
- referrer = new URL(redirect).href;
572
- } catch (e) {
573
- logging.warn(e);
574
- }
575
- }
586
+ const referrer = extractRefererOrRedirect(req);
576
587
 
577
588
  if (!email) {
578
589
  throw new errors.BadRequestError({
@@ -626,7 +637,12 @@ module.exports = class RouterController {
626
637
  if (emailType === 'signup' || emailType === 'subscribe') {
627
638
  await this._handleSignup(req, normalizedEmail, referrer);
628
639
  } else {
629
- await this._handleSignin(req, normalizedEmail, referrer);
640
+ const signIn = await this._handleSignin(req, normalizedEmail, referrer);
641
+
642
+ if (this.labsService.isSet('membersSigninOTC') && signIn.otcRef) {
643
+ res.writeHead(201, {'Content-Type': 'application/json'});
644
+ return res.end(JSON.stringify({otc_ref: signIn.otcRef}));
645
+ }
630
646
  }
631
647
 
632
648
  res.writeHead(201);
@@ -644,6 +660,71 @@ module.exports = class RouterController {
644
660
  }
645
661
  }
646
662
 
663
+ async verifyOTC(req, res) {
664
+ const {otc, otcRef} = req.body;
665
+
666
+ if (!otc || !otcRef) {
667
+ throw new errors.BadRequestError({
668
+ message: tpl(messages.badRequest),
669
+ context: 'otc and otcRef are required',
670
+ code: 'OTC_VERIFICATION_MISSING_PARAMS'
671
+ });
672
+ }
673
+
674
+ const tokenProvider = this._magicLinkService.tokenProvider;
675
+ if (!tokenProvider || typeof tokenProvider.verifyOTC !== 'function') {
676
+ throw new errors.BadRequestError({
677
+ message: tpl(messages.otcNotSupported),
678
+ code: 'OTC_NOT_SUPPORTED'
679
+ });
680
+ }
681
+
682
+ const isValidOTC = await tokenProvider.verifyOTC(otcRef, otc);
683
+ if (!isValidOTC) {
684
+ throw new errors.BadRequestError({
685
+ message: tpl(messages.invalidCode),
686
+ code: 'INVALID_OTC'
687
+ });
688
+ }
689
+
690
+ const tokenValue = await tokenProvider.getTokenByRef(otcRef);
691
+ if (!tokenValue) {
692
+ throw new errors.BadRequestError({
693
+ message: tpl(messages.invalidCode),
694
+ code: 'INVALID_OTC_REF'
695
+ });
696
+ }
697
+
698
+ const otcVerificationHash = await this._createHashFromOTCAndToken(otc, tokenValue);
699
+ if (!otcVerificationHash) {
700
+ throw new errors.BadRequestError({
701
+ message: tpl(messages.failedToVerifyCode),
702
+ code: 'OTC_VERIFICATION_FAILED'
703
+ });
704
+ }
705
+
706
+ const referrer = extractRefererOrRedirect(req);
707
+
708
+ const redirectUrl = this._magicLinkService.getSigninURL(tokenValue, 'signin', referrer, otcVerificationHash);
709
+ if (!redirectUrl) {
710
+ throw new errors.BadRequestError({
711
+ message: tpl(messages.failedToVerifyCode),
712
+ code: 'OTC_VERIFICATION_FAILED'
713
+ });
714
+ }
715
+
716
+ return res.json({redirectUrl});
717
+ }
718
+
719
+ async _createHashFromOTCAndToken(otc, token) {
720
+ // timestamp for anti-replay protection (5 minute window)
721
+ const timestamp = Math.floor(Date.now() / 1000);
722
+
723
+ const hash = this._magicLinkService.tokenProvider.createOTCVerificationHash(otc, token, timestamp);
724
+
725
+ return `${timestamp}:${hash}`;
726
+ }
727
+
647
728
  async _handleSignup(req, normalizedEmail, referrer = null) {
648
729
  if (!this._allowSelfSignup()) {
649
730
  if (this._settingsCache.get('members_signup_access') === 'paid') {
@@ -679,7 +760,13 @@ module.exports = class RouterController {
679
760
  }
680
761
 
681
762
  async _handleSignin(req, normalizedEmail, referrer = null) {
682
- const {emailType} = req.body;
763
+ const {emailType, includeOTC: reqIncludeOTC} = req.body;
764
+
765
+ let includeOTC = false;
766
+
767
+ if (this.labsService.isSet('membersSigninOTC') && (reqIncludeOTC === true || reqIncludeOTC === 'true')) {
768
+ includeOTC = true;
769
+ }
683
770
 
684
771
  const member = await this._memberRepository.get({email: normalizedEmail});
685
772
 
@@ -690,7 +777,7 @@ module.exports = class RouterController {
690
777
  }
691
778
 
692
779
  const tokenData = {};
693
- return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
780
+ return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer, includeOTC});
694
781
  }
695
782
 
696
783
  /**
@@ -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
  /**
@@ -234,12 +235,12 @@ module.exports = function MembersAPI({
234
235
  });
235
236
  }
236
237
 
237
- async function getTokenDataFromMagicLinkToken(token) {
238
- return await magicLinkService.getDataFromToken(token);
238
+ async function getTokenDataFromMagicLinkToken(token, otcVerification) {
239
+ return await magicLinkService.getDataFromToken(token, otcVerification);
239
240
  }
240
241
 
241
- async function getMemberDataFromMagicLinkToken(token) {
242
- const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token);
242
+ async function getMemberDataFromMagicLinkToken(token, otcVerification) {
243
+ const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token, otcVerification);
243
244
  if (!email) {
244
245
  return null;
245
246
  }
@@ -339,6 +340,10 @@ module.exports = function MembersAPI({
339
340
  body.json(),
340
341
  forwardError((req, res) => routerController.sendMagicLink(req, res))
341
342
  ),
343
+ verifyOTC: Router().use(
344
+ body.json(),
345
+ forwardError((req, res) => routerController.verifyOTC(req, res))
346
+ ),
342
347
  createCheckoutSession: Router().use(
343
348
  body.json(),
344
349
  forwardError((req, res) => routerController.createCheckoutSession(req, res))