ghost 6.2.0 → 6.3.0

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 (57) hide show
  1. package/components/tryghost-i18n-6.3.0.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
  3. package/core/built/admin/assets/admin-x-activitypub/{index-DmCoswaX.mjs → index-C8tyOPu-.mjs} +2 -2
  4. package/core/built/admin/assets/admin-x-activitypub/{index-lT95Q15h.mjs → index-QqbAPyqT.mjs} +77 -76
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-UxqLGRTu.mjs → CodeEditorView-CHa5Y-LX.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  7. package/core/built/admin/assets/admin-x-settings/{index-B5r0jdJS.mjs → index-CGFCkAXn.mjs} +9 -5
  8. package/core/built/admin/assets/admin-x-settings/{index-Co907MFn.mjs → index-Cg4zMcj4.mjs} +2 -2
  9. package/core/built/admin/assets/admin-x-settings/{modals-B7j9sxR4.mjs → modals-DH5H9Tgk.mjs} +1036 -1034
  10. package/core/built/admin/assets/{chunk.397.d5e25bb9baf088f52499.js → chunk.397.a720333cfffc99c47e71.js} +5 -4
  11. package/core/built/admin/assets/{chunk.524.70595796c7b8c6003a2d.js → chunk.524.aac61953956de04feb53.js} +6 -6
  12. package/core/built/admin/assets/{chunk.582.d9b970b71da671ac1b7b.js → chunk.582.0a1461429ddbaef85ea9.js} +8 -8
  13. package/core/built/admin/assets/{ghost-2066304fd0b166e1c16d397dd73ef7b2.js → ghost-1bfab97cb7f550726e894fae6650a808.js} +23 -21
  14. package/core/built/admin/assets/ghost-8ade80412a20088a4f0a9a1159f0bdba.css +1 -0
  15. package/core/built/admin/assets/ghost-dark-b128f29fc44b34b6cfb0fc8492266c2a.css +1 -0
  16. package/core/built/admin/assets/posts/posts.js +30561 -30283
  17. package/core/built/admin/assets/stats/stats.js +21340 -21270
  18. package/core/built/admin/index.html +5 -5
  19. package/core/frontend/helpers/ghost_head.js +2 -1
  20. package/core/server/api/endpoints/stats.js +37 -1
  21. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +1 -0
  22. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +1 -0
  23. package/core/server/data/migrations/versions/6.3/2025-10-02-15-13-31-add-members-otc-secret-setting.js +9 -0
  24. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  25. package/core/server/models/settings.js +1 -0
  26. package/core/server/services/donations/DonationBookshelfRepository.js +6 -1
  27. package/core/server/services/donations/DonationBookshelfRepository.ts +11 -1
  28. package/core/server/services/donations/DonationPaymentEvent.js +10 -0
  29. package/core/server/services/donations/DonationPaymentEvent.ts +10 -0
  30. package/core/server/services/member-attribution/AttributionBuilder.js +55 -10
  31. package/core/server/services/member-attribution/README.md +101 -0
  32. package/core/server/services/member-attribution/ReferrerTranslator.js +40 -3
  33. package/core/server/services/member-attribution/UrlHistory.js +5 -0
  34. package/core/server/services/members/api.js +1 -1
  35. package/core/server/services/members/members-api/controllers/RouterController.js +26 -0
  36. package/core/server/services/members/members-api/repositories/MemberRepository.js +6 -1
  37. package/core/server/services/members-events/EventStorage.js +10 -0
  38. package/core/server/services/stats/ReferrersStatsService.js +143 -0
  39. package/core/server/services/stats/StatsService.js +17 -0
  40. package/core/server/services/stripe/StripeAPI.js +7 -2
  41. package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +6 -1
  42. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  43. package/core/server/web/members/app.js +2 -0
  44. package/core/server/web/shared/middleware/api/spam-prevention.js +76 -0
  45. package/core/server/web/shared/middleware/brute.js +23 -0
  46. package/core/shared/config/defaults.json +13 -1
  47. package/core/shared/config/env/config.testing-browser.json +12 -0
  48. package/core/shared/config/env/config.testing-mysql.json +12 -0
  49. package/core/shared/config/env/config.testing.json +12 -0
  50. package/core/shared/labs.js +1 -0
  51. package/package.json +5 -5
  52. package/tsconfig.tsbuildinfo +1 -1
  53. package/yarn.lock +178 -180
  54. package/components/tryghost-i18n-6.2.0.tgz +0 -0
  55. package/core/built/admin/assets/ghost-49475952d56ffe89bd47ab9d9c64ada8.css +0 -1
  56. package/core/built/admin/assets/ghost-dark-27877727751b91f03261d449d74e33b9.css +0 -1
  57. /package/core/built/admin/assets/{chunk.397.d5e25bb9baf088f52499.js.LICENSE.txt → chunk.397.a720333cfffc99c47e71.js.LICENSE.txt} +0 -0
@@ -1,4 +1,5 @@
1
1
  const moment = require('moment');
2
+ const errors = require('@tryghost/errors');
2
3
 
3
4
  // Import centralized date utilities
4
5
  const {getDateBoundaries, applyDateFilter} = require('./utils/date-utils');
@@ -455,6 +456,138 @@ class ReferrersStatsService {
455
456
  meta: {}
456
457
  };
457
458
  }
459
+
460
+ /**
461
+ * Get UTM growth stats broken down by UTM parameter (fixture data for now)
462
+ * @param {Object} options
463
+ * @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
464
+ * @param {string} [options.order='free_members desc'] - Sort order
465
+ * @param {number} [options.limit=50] - Maximum number of results
466
+ * @param {string} [options.post_id] - Optional filter by post ID
467
+ * @returns {Promise<{data: UtmGrowthStat[], meta: {}}>}
468
+ */
469
+ async getUtmGrowthStats(options = {}) {
470
+ const utmField = options.utm_type || 'utm_source';
471
+ const limit = options.limit || 50;
472
+ const postId = options.post_id;
473
+
474
+ // Validate utm_type is a valid UTM field
475
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
476
+ if (!validUtmFields.includes(utmField)) {
477
+ throw new errors.BadRequestError({
478
+ message: `Invalid utm_type: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
479
+ });
480
+ }
481
+
482
+ // Fixture data; will replace with real data once members service is wired up fully
483
+ let fixtureData = this._getUtmFixtureData(utmField);
484
+
485
+ // If filtering by post, scale down the data
486
+ if (postId) {
487
+ fixtureData = fixtureData.map(item => ({
488
+ ...item,
489
+ free_members: Math.floor(item.free_members * 0.3), // 30% of global
490
+ paid_members: Math.floor(item.paid_members * 0.25), // 25% of global
491
+ mrr: Math.floor(item.mrr * 0.25) // 25% of global
492
+ })).filter(item => item.free_members > 0 || item.paid_members > 0); // Only include items with data
493
+ }
494
+
495
+ const sortedData = this._sortUtmData(fixtureData, options.order);
496
+ const limitedData = postId ? sortedData : (limit > 0 ? sortedData.slice(0, limit) : sortedData);
497
+
498
+ return {
499
+ data: limitedData,
500
+ meta: {}
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Generate fixture data for UTM parameters
506
+ * @private
507
+ * @param {string} utmField - The UTM field ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
508
+ * @returns {UtmGrowthStat[]}
509
+ */
510
+ _getUtmFixtureData(utmField) {
511
+ const fixtures = {
512
+ utm_source: [
513
+ {utm_value: 'google', utm_type: 'utm_source', free_members: 100, paid_members: 20, mrr: 10000},
514
+ {utm_value: 'facebook', utm_type: 'utm_source', free_members: 80, paid_members: 15, mrr: 7500},
515
+ {utm_value: 'twitter', utm_type: 'utm_source', free_members: 60, paid_members: 10, mrr: 5000},
516
+ {utm_value: 'newsletter', utm_type: 'utm_source', free_members: 40, paid_members: 25, mrr: 12500},
517
+ {utm_value: 'linkedin', utm_type: 'utm_source', free_members: 35, paid_members: 12, mrr: 6000},
518
+ {utm_value: 'reddit', utm_type: 'utm_source', free_members: 25, paid_members: 5, mrr: 2500},
519
+ {utm_value: 'youtube', utm_type: 'utm_source', free_members: 20, paid_members: 8, mrr: 4000},
520
+ {utm_value: 'instagram', utm_type: 'utm_source', free_members: 18, paid_members: 6, mrr: 3000}
521
+ ],
522
+ utm_medium: [
523
+ {utm_value: 'organic', utm_type: 'utm_medium', free_members: 150, paid_members: 30, mrr: 15000},
524
+ {utm_value: 'cpc', utm_type: 'utm_medium', free_members: 90, paid_members: 20, mrr: 10000},
525
+ {utm_value: 'email', utm_type: 'utm_medium', free_members: 70, paid_members: 20, mrr: 10000},
526
+ {utm_value: 'social', utm_type: 'utm_medium', free_members: 30, paid_members: 10, mrr: 5000},
527
+ {utm_value: 'referral', utm_type: 'utm_medium', free_members: 25, paid_members: 8, mrr: 4000},
528
+ {utm_value: 'display', utm_type: 'utm_medium', free_members: 15, paid_members: 3, mrr: 1500}
529
+ ],
530
+ utm_campaign: [
531
+ {utm_value: 'spring-sale', utm_type: 'utm_campaign', free_members: 120, paid_members: 35, mrr: 17500},
532
+ {utm_value: 'product-launch', utm_type: 'utm_campaign', free_members: 80, paid_members: 20, mrr: 10000},
533
+ {utm_value: 'webinar-series', utm_type: 'utm_campaign', free_members: 60, paid_members: 15, mrr: 7500},
534
+ {utm_value: 'holiday-promo', utm_type: 'utm_campaign', free_members: 45, paid_members: 18, mrr: 9000},
535
+ {utm_value: 'content-upgrade', utm_type: 'utm_campaign', free_members: 30, paid_members: 8, mrr: 4000},
536
+ {utm_value: 'partner-collab', utm_type: 'utm_campaign', free_members: 25, paid_members: 12, mrr: 6000}
537
+ ],
538
+ utm_term: [
539
+ {utm_value: 'best-email-marketing', utm_type: 'utm_term', free_members: 85, paid_members: 22, mrr: 11000},
540
+ {utm_value: 'ghost-cms', utm_type: 'utm_term', free_members: 70, paid_members: 18, mrr: 9000},
541
+ {utm_value: 'newsletter-platform', utm_type: 'utm_term', free_members: 55, paid_members: 15, mrr: 7500},
542
+ {utm_value: 'content-management', utm_type: 'utm_term', free_members: 40, paid_members: 10, mrr: 5000},
543
+ {utm_value: 'publishing-platform', utm_type: 'utm_term', free_members: 30, paid_members: 8, mrr: 4000},
544
+ {utm_value: 'membership-software', utm_type: 'utm_term', free_members: 20, paid_members: 5, mrr: 2500}
545
+ ],
546
+ utm_content: [
547
+ {utm_value: 'hero-cta', utm_type: 'utm_content', free_members: 95, paid_members: 25, mrr: 12500},
548
+ {utm_value: 'sidebar-banner', utm_type: 'utm_content', free_members: 75, paid_members: 18, mrr: 9000},
549
+ {utm_value: 'footer-link', utm_type: 'utm_content', free_members: 50, paid_members: 12, mrr: 6000},
550
+ {utm_value: 'email-button', utm_type: 'utm_content', free_members: 45, paid_members: 15, mrr: 7500},
551
+ {utm_value: 'popup-form', utm_type: 'utm_content', free_members: 35, paid_members: 8, mrr: 4000},
552
+ {utm_value: 'text-link', utm_type: 'utm_content', free_members: 25, paid_members: 6, mrr: 3000}
553
+ ]
554
+ };
555
+
556
+ return fixtures[utmField] || fixtures.utm_source;
557
+ }
558
+
559
+ /**
560
+ * Sort UTM data by the specified order
561
+ * @private
562
+ * @param {UtmGrowthStat[]} data
563
+ * @param {string} [order='free_members desc']
564
+ * @returns {UtmGrowthStat[]}
565
+ */
566
+ _sortUtmData(data, order = 'free_members desc') {
567
+ const [field, direction] = order.split(' ');
568
+ const validFields = ['free_members', 'paid_members', 'mrr', 'utm_value'];
569
+
570
+ if (!validFields.includes(field)) {
571
+ return data;
572
+ }
573
+
574
+ return [...data].sort((a, b) => {
575
+ let valueA = a[field];
576
+ let valueB = b[field];
577
+
578
+ // Handle string sorting for utm_value
579
+ if (field === 'utm_value') {
580
+ valueA = String(valueA).toLowerCase();
581
+ valueB = String(valueB).toLowerCase();
582
+ }
583
+
584
+ if (direction === 'asc') {
585
+ return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
586
+ }
587
+ // Default to desc
588
+ return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
589
+ });
590
+ }
458
591
  }
459
592
 
460
593
  module.exports = ReferrersStatsService;
@@ -507,3 +640,13 @@ module.exports.normalizeSource = normalizeSource;
507
640
  * @property {number} mrr Total MRR from this source (in cents)
508
641
  * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
509
642
  **/
643
+
644
+ /**
645
+ * @typedef {object} UtmGrowthStat
646
+ * @type {Object}
647
+ * @property {string} utm_value - The UTM parameter value (e.g., 'google', 'facebook')
648
+ * @property {string} utm_type - The UTM parameter type ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
649
+ * @property {number} free_members - Count of free member signups
650
+ * @property {number} paid_members - Count of paid member conversions
651
+ * @property {number} mrr - Total MRR from this UTM parameter (in cents)
652
+ **/
@@ -244,6 +244,23 @@ class StatsService {
244
244
  return this.referrers.getTopSourcesWithRange(options);
245
245
  }
246
246
 
247
+ /**
248
+ * Get UTM growth stats broken down by UTM field (fixture data)
249
+ * Can be filtered by post using post_id parameter
250
+ * @param {Object} options
251
+ * @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
252
+ * @param {string} [options.order='free_members desc'] - Sort order
253
+ * @param {number} [options.limit=50] - Maximum number of results (ignored when filtering by post)
254
+ * @param {string} [options.date_from] - Start date in YYYY-MM-DD format (not yet implemented for fixtures)
255
+ * @param {string} [options.date_to] - End date in YYYY-MM-DD format (not yet implemented for fixtures)
256
+ * @param {string} [options.timezone] - Timezone to use for date interpretation (not yet implemented for fixtures)
257
+ * @param {string} [options.post_id] - Optional filter by post ID
258
+ * @returns {Promise<{data: import('./ReferrersStatsService').UtmGrowthStat[], meta: {}}>}
259
+ */
260
+ async getUtmGrowthStats(options = {}) {
261
+ return this.referrers.getUtmGrowthStats(options);
262
+ }
263
+
247
264
  /**
248
265
  * @param {object} deps
249
266
  *
@@ -526,13 +526,18 @@ module.exports = class StripeAPI {
526
526
  items: [{
527
527
  plan: priceId
528
528
  }],
529
- metadata: {
529
+ metadata: {
530
530
  attribution_id: metadata?.attribution_id,
531
531
  attribution_url: metadata?.attribution_url,
532
532
  attribution_type: metadata?.attribution_type,
533
533
  referrer_source: metadata?.referrer_source,
534
534
  referrer_medium: metadata?.referrer_medium,
535
- referrer_url: metadata?.referrer_url
535
+ referrer_url: metadata?.referrer_url,
536
+ utm_source: metadata?.utm_source,
537
+ utm_medium: metadata?.utm_medium,
538
+ utm_campaign: metadata?.utm_campaign,
539
+ utm_term: metadata?.utm_term,
540
+ utm_content: metadata?.utm_content
536
541
  }
537
542
  };
538
543
 
@@ -75,7 +75,12 @@ module.exports = class CheckoutSessionEventService {
75
75
  attributionType: session.metadata?.attribution_type ?? null,
76
76
  referrerSource: session.metadata?.referrer_source ?? null,
77
77
  referrerMedium: session.metadata?.referrer_medium ?? null,
78
- referrerUrl: session.metadata?.referrer_url ?? null
78
+ referrerUrl: session.metadata?.referrer_url ?? null,
79
+ utmSource: session.metadata?.utm_source ?? null,
80
+ utmMedium: session.metadata?.utm_medium ?? null,
81
+ utmCampaign: session.metadata?.utm_campaign ?? null,
82
+ utmTerm: session.metadata?.utm_term ?? null,
83
+ utmContent: session.metadata?.utm_content ?? null
79
84
  });
80
85
 
81
86
  const donationRepository = this.deps.donationRepository;
@@ -165,6 +165,7 @@ module.exports = function apiRoutes() {
165
165
  router.get('/stats/posts/:id/top-referrers', mw.authAdminApi, http(api.stats.postReferrersAlpha));
166
166
  router.get('/stats/posts/:id/growth', mw.authAdminApi, http(api.stats.postGrowthStats));
167
167
  router.get('/stats/top-sources-growth', mw.authAdminApi, http(api.stats.topSourcesGrowth));
168
+ router.get('/stats/utm-growth', mw.authAdminApi, http(api.stats.utmGrowth));
168
169
  router.post('/stats/posts-visitor-counts', mw.authAdminApi, http(api.stats.postsVisitorCounts));
169
170
  router.post('/stats/posts-member-counts', mw.authAdminApi, http(api.stats.postsMemberCounts));
170
171
 
@@ -89,6 +89,8 @@ module.exports = function setupMembersApp() {
89
89
  '/api/verify-otc',
90
90
  bodyParser.json(),
91
91
  middleware.verifyIntegrityToken,
92
+ shared.middleware.brute.otcVerificationEnumeration,
93
+ shared.middleware.brute.otcVerification,
92
94
  // NOTE: this is wrapped in a function to ensure we always go via the getter
93
95
  function lazyVerifyOTCMw(req, res, next) {
94
96
  return membersService.api.middleware.verifyOTC(req, res, next);
@@ -21,6 +21,10 @@ const messages = {
21
21
  context: 'Too many login attempts.'
22
22
  },
23
23
  tooManyAttempts: 'Too many attempts.',
24
+ tooManyOTCVerificationAttempts: {
25
+ error: 'Too many attempts for this verification code.',
26
+ context: 'Too many verification code attempts.'
27
+ },
24
28
  webmentionsBlock: 'Too many mention attempts',
25
29
  emailPreviewBlock: 'Only 10 test emails can be sent per hour'
26
30
  };
@@ -35,6 +39,8 @@ let spamMemberLogin = spam.member_login || {};
35
39
  let spamContentApiKey = spam.content_api_key || {};
36
40
  let spamWebmentionsBlock = spam.webmentions_block || {};
37
41
  let spamEmailPreviewBlock = spam.email_preview_block || {};
42
+ let spamOtcVerificationEnumeration = spam.otc_verification_enumeration || {};
43
+ let spamOtcVerification = spam.otc_verification || {};
38
44
 
39
45
  let store;
40
46
  let memoryStore;
@@ -50,6 +56,8 @@ let sendVerificationCodeInstance;
50
56
  let userVerificationInstance;
51
57
  let contentApiKeyInstance;
52
58
  let emailPreviewBlockInstance;
59
+ let otcVerificationEnumerationInstance;
60
+ let otcVerificationInstance;
53
61
 
54
62
  const spamConfigKeys = ['freeRetries', 'minWait', 'maxWait', 'lifetime'];
55
63
 
@@ -248,6 +256,68 @@ const membersAuthEnumeration = () => {
248
256
  return membersAuthEnumerationInstance;
249
257
  };
250
258
 
259
+ const otcVerificationEnumeration = () => {
260
+ const ExpressBrute = require('express-brute');
261
+ const BruteKnex = require('brute-knex');
262
+ const db = require('../../../../data/db');
263
+
264
+ store = store || new BruteKnex({
265
+ tablename: 'brute',
266
+ createTable: false,
267
+ knex: db.knex
268
+ });
269
+
270
+ if (!otcVerificationEnumerationInstance) {
271
+ otcVerificationEnumerationInstance = new ExpressBrute(store,
272
+ extend({
273
+ attachResetToRequest: false,
274
+ failCallback(req, res, next, nextValidRequestDate) {
275
+ return next(new errors.TooManyRequestsError({
276
+ message: `Too many verification attempts across multiple codes, try again in ${moment(nextValidRequestDate).fromNow(true)}`,
277
+ context: tpl(messages.tooManyOTCVerificationAttempts.context),
278
+ help: tpl(messages.tooManyOTCVerificationAttempts.context),
279
+ code: 'OTC_TOTAL_ATTEMPTS_RATE_LIMITED'
280
+ }));
281
+ },
282
+ handleStoreError: handleStoreError
283
+ }, pick(spamOtcVerificationEnumeration, spamConfigKeys))
284
+ );
285
+ }
286
+
287
+ return otcVerificationEnumerationInstance;
288
+ };
289
+
290
+ const otcVerification = () => {
291
+ const ExpressBrute = require('express-brute');
292
+ const BruteKnex = require('brute-knex');
293
+ const db = require('../../../../data/db');
294
+
295
+ store = store || new BruteKnex({
296
+ tablename: 'brute',
297
+ createTable: false,
298
+ knex: db.knex
299
+ });
300
+
301
+ if (!otcVerificationInstance) {
302
+ otcVerificationInstance = new ExpressBrute(store,
303
+ extend({
304
+ attachResetToRequest: false,
305
+ failCallback(req, res, next, nextValidRequestDate) {
306
+ return next(new errors.TooManyRequestsError({
307
+ message: `Too many attempts for this verification code, try again in ${moment(nextValidRequestDate).fromNow(true)}`,
308
+ context: tpl(messages.tooManyOTCVerificationAttempts.context),
309
+ help: tpl(messages.tooManyOTCVerificationAttempts.context),
310
+ code: 'OTC_CODE_ATTEMPTS_RATE_LIMITED'
311
+ }));
312
+ },
313
+ handleStoreError: handleStoreError
314
+ }, pick(spamOtcVerification, spamConfigKeys))
315
+ );
316
+ }
317
+
318
+ return otcVerificationInstance;
319
+ };
320
+
251
321
  // Stops login attempts for a user+IP pair with an increasing time period starting from 10 minutes
252
322
  // and rising to a week in a fibonnaci sequence
253
323
  // The user+IP count is reset when on successful login
@@ -432,6 +502,8 @@ module.exports = {
432
502
  userVerification: userVerification,
433
503
  membersAuth: membersAuth,
434
504
  membersAuthEnumeration: membersAuthEnumeration,
505
+ otcVerification: otcVerification,
506
+ otcVerificationEnumeration: otcVerificationEnumeration,
435
507
  userReset: userReset,
436
508
  privateBlog: privateBlog,
437
509
  contentApiKey: contentApiKey,
@@ -450,6 +522,8 @@ module.exports = {
450
522
  sendVerificationCodeInstance = undefined;
451
523
  userVerificationInstance = undefined;
452
524
  contentApiKeyInstance = undefined;
525
+ otcVerificationEnumerationInstance = undefined;
526
+ otcVerificationInstance = undefined;
453
527
 
454
528
  spam = config.get('spam') || {};
455
529
  spamPrivateBlock = spam.private_block || {};
@@ -461,5 +535,7 @@ module.exports = {
461
535
  spamUserVerification = spam.user_verification || {};
462
536
  spamMemberLogin = spam.member_login || {};
463
537
  spamContentApiKey = spam.content_api_key || {};
538
+ spamOtcVerificationEnumeration = spam.otc_verification_enumeration || {};
539
+ spamOtcVerification = spam.otc_verification || {};
464
540
  }
465
541
  };
@@ -128,6 +128,29 @@ module.exports = {
128
128
  return spamPrevention.membersAuthEnumeration().prevent(req, res, next);
129
129
  },
130
130
 
131
+ /**
132
+ * Block too many OTC verification attempts from same IP (blocks user enumeration)
133
+ */
134
+ otcVerificationEnumeration(req, res, next) {
135
+ return spamPrevention.otcVerificationEnumeration().prevent(req, res, next);
136
+ },
137
+
138
+ /**
139
+ * Block too many attempts for the same otcRef
140
+ */
141
+ otcVerification(req, res, next) {
142
+ return spamPrevention.otcVerification().getMiddleware({
143
+ // ignoring IP here blocks rotating ip attacks, only one IP should receive an otcRef so it shouldn't cause false positives
144
+ ignoreIP: true,
145
+ key(_req, _res, _next) {
146
+ if (_req.body.otcRef) {
147
+ return _next(`${_req.body.otcRef}otc_verification`);
148
+ }
149
+ return _next();
150
+ }
151
+ })(req, res, next);
152
+ },
153
+
131
154
  /**
132
155
  * Blocks webmention spam
133
156
  */
@@ -130,6 +130,18 @@
130
130
  "lifetime": 3600,
131
131
  "freeRetries": 10
132
132
  },
133
+ "otc_verification_enumeration": {
134
+ "minWait": 600000,
135
+ "maxWait": 43200000,
136
+ "lifetime": 43200,
137
+ "freeRetries": 8
138
+ },
139
+ "otc_verification": {
140
+ "minWait": 3600000,
141
+ "maxWait": 3600000,
142
+ "lifetime": 3600,
143
+ "freeRetries": 4
144
+ },
133
145
  "blocked_email_domains": []
134
146
  },
135
147
  "caching": {
@@ -212,7 +224,7 @@
212
224
  },
213
225
  "portal": {
214
226
  "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
215
- "version": "2.54"
227
+ "version": "2.55"
216
228
  },
217
229
  "sodoSearch": {
218
230
  "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
@@ -70,6 +70,18 @@
70
70
  "maxWait": 360000,
71
71
  "lifetime": 3600,
72
72
  "freeRetries": 10
73
+ },
74
+ "otc_verification_enumeration": {
75
+ "minWait": 600000,
76
+ "maxWait": 43200000,
77
+ "lifetime": 43200,
78
+ "freeRetries": 8
79
+ },
80
+ "otc_verification": {
81
+ "minWait": 300000,
82
+ "maxWait": 3600000,
83
+ "lifetime": 3600,
84
+ "freeRetries": 4
73
85
  }
74
86
  },
75
87
  "privacy": {
@@ -74,6 +74,18 @@
74
74
  "maxWait": 360000,
75
75
  "lifetime": 3600,
76
76
  "freeRetries": 10
77
+ },
78
+ "otc_verification_enumeration": {
79
+ "minWait": 600000,
80
+ "maxWait": 43200000,
81
+ "lifetime": 43200,
82
+ "freeRetries": 8
83
+ },
84
+ "otc_verification": {
85
+ "minWait": 300000,
86
+ "maxWait": 3600000,
87
+ "lifetime": 3600,
88
+ "freeRetries": 4
77
89
  }
78
90
  },
79
91
  "privacy": {
@@ -70,6 +70,18 @@
70
70
  "maxWait": 360000,
71
71
  "lifetime": 3600,
72
72
  "freeRetries": 10
73
+ },
74
+ "otc_verification_enumeration": {
75
+ "minWait": 600000,
76
+ "maxWait": 43200000,
77
+ "lifetime": 43200,
78
+ "freeRetries": 8
79
+ },
80
+ "otc_verification": {
81
+ "minWait": 300000,
82
+ "maxWait": 3600000,
83
+ "lifetime": 3600,
84
+ "freeRetries": 4
73
85
  }
74
86
  },
75
87
  "privacy": {
@@ -48,6 +48,7 @@ const PRIVATE_FEATURES = [
48
48
  'contentVisibilityAlpha',
49
49
  'emailCustomization',
50
50
  'membersSigninOTC',
51
+ 'membersSigninOTCAlpha',
51
52
  'tagsX',
52
53
  'utmTracking',
53
54
  'emailUniqueid'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost",
3
- "version": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "The professional publishing platform",
5
5
  "author": "Ghost Foundation",
6
6
  "homepage": "https://ghost.org",
@@ -86,7 +86,7 @@
86
86
  "@tryghost/helpers": "1.1.97",
87
87
  "@tryghost/html-to-plaintext": "1.0.4",
88
88
  "@tryghost/http-cache-utils": "0.1.20",
89
- "@tryghost/i18n": "file:components/tryghost-i18n-6.2.0.tgz",
89
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.3.0.tgz",
90
90
  "@tryghost/image-transform": "1.4.6",
91
91
  "@tryghost/job-manager": "1.0.3",
92
92
  "@tryghost/kg-card-factory": "5.1.2",
@@ -196,7 +196,7 @@
196
196
  "moment": "2.24.0",
197
197
  "moment-timezone": "0.5.45",
198
198
  "multer": "2.0.2",
199
- "mysql2": "3.15.1",
199
+ "mysql2": "3.15.2",
200
200
  "nconf": "0.13.0",
201
201
  "node-fetch": "2.7.0",
202
202
  "node-jose": "2.2.0",
@@ -208,7 +208,7 @@
208
208
  "probe-image-size": "7.2.3",
209
209
  "rss": "1.2.2",
210
210
  "sanitize-html": "2.17.0",
211
- "semver": "7.7.2",
211
+ "semver": "7.7.3",
212
212
  "simple-dom": "1.4.0",
213
213
  "stoppable": "1.1.0",
214
214
  "stripe": "8.222.0",
@@ -273,7 +273,7 @@
273
273
  "jackspeak": "2.3.6",
274
274
  "moment": "2.24.0",
275
275
  "moment-timezone": "0.5.45",
276
- "@tryghost/i18n": "file:components/tryghost-i18n-6.2.0.tgz"
276
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.3.0.tgz"
277
277
  },
278
278
  "nx": {
279
279
  "targets": {