ghost 6.2.0 → 6.3.1

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.1.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-lT95Q15h.mjs → index-CdMLWVnk.mjs} +13477 -12814
  4. package/core/built/admin/assets/admin-x-activitypub/{index-DmCoswaX.mjs → index-DsmVTjDw.mjs} +2 -2
  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.5ac0aa6b2e0374d43fa1.js} +6 -6
  12. package/core/built/admin/assets/{chunk.582.d9b970b71da671ac1b7b.js → chunk.582.944f56b6e36ff0afdc80.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 +30571 -30284
  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 +281 -12
  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 +6 -6
  52. package/tsconfig.tsbuildinfo +1 -1
  53. package/yarn.lock +294 -254
  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');
@@ -261,7 +262,7 @@ class ReferrersStatsService {
261
262
  async fetchMrrSourcesWithRange(options) {
262
263
  const knex = this.knex;
263
264
  const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
264
-
265
+
265
266
  // Join subscription created events with paid subscription events to get MRR changes
266
267
  let query = knex('members_subscription_created_events as msce')
267
268
  .join('members_paid_subscription_events as mpse', function () {
@@ -275,10 +276,10 @@ class ReferrersStatsService {
275
276
  .whereNotNull('msce.referrer_source') // Only entries with attribution
276
277
  .groupBy('date', 'msce.referrer_source')
277
278
  .orderBy('date');
278
-
279
+
279
280
  // Apply centralized date filtering
280
281
  applyDateFilter(query, startDateTime, endDateTime, 'msce.created_at');
281
-
282
+
282
283
  const rows = await query;
283
284
 
284
285
  return rows;
@@ -293,7 +294,7 @@ class ReferrersStatsService {
293
294
  async fetchMemberCountsBySource(options) {
294
295
  const knex = this.knex;
295
296
  const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
296
-
297
+
297
298
  // Query 1: Free members who haven't converted to paid within the same time window
298
299
  const freeSignupsQuery = knex('members_created_events as mce')
299
300
  .select('mce.referrer_source as source')
@@ -306,7 +307,7 @@ class ReferrersStatsService {
306
307
  })
307
308
  .whereNull('msce.id')
308
309
  .groupBy('mce.referrer_source');
309
-
310
+
310
311
  // Apply date filtering to the main query
311
312
  applyDateFilter(freeSignupsQuery, startDateTime, endDateTime, 'mce.created_at');
312
313
 
@@ -315,7 +316,7 @@ class ReferrersStatsService {
315
316
  .select('msce.referrer_source as source')
316
317
  .select(knex.raw('COUNT(DISTINCT msce.member_id) as paid_conversions'))
317
318
  .groupBy('msce.referrer_source');
318
-
319
+
319
320
  // Apply date filtering to the paid conversions query
320
321
  applyDateFilter(paidConversionsQuery, startDateTime, endDateTime, 'msce.created_at');
321
322
 
@@ -327,7 +328,7 @@ class ReferrersStatsService {
327
328
 
328
329
  // Combine results by source
329
330
  const sourceMap = new Map();
330
-
331
+
331
332
  // Add free signups
332
333
  freeResults.forEach((row) => {
333
334
  sourceMap.set(row.source, {
@@ -336,7 +337,7 @@ class ReferrersStatsService {
336
337
  paid_conversions: 0
337
338
  });
338
339
  });
339
-
340
+
340
341
  // Add paid conversions
341
342
  paidResults.forEach((row) => {
342
343
  const existing = sourceMap.get(row.source);
@@ -367,11 +368,11 @@ class ReferrersStatsService {
367
368
  */
368
369
  async getTopSourcesWithRange(options = {}) {
369
370
  const {orderBy = 'signups desc', limit = 50} = options;
370
-
371
+
371
372
  // Get deduplicated member counts and MRR data in parallel
372
373
  const [memberCounts, mrrEntries] = await Promise.all([
373
374
  this.fetchMemberCountsBySource(options),
374
- this.fetchMrrSourcesWithRange(options)
375
+ this.fetchMrrSourcesWithRange(options)
375
376
  ]);
376
377
 
377
378
  // Aggregate by source (not by date + source)
@@ -416,10 +417,10 @@ class ReferrersStatsService {
416
417
 
417
418
  // Apply sorting - only allow descending sorts for sources
418
419
  const [field] = orderBy.split(' ');
419
-
420
+
420
421
  results.sort((a, b) => {
421
422
  let valueA; let valueB;
422
-
423
+
423
424
  switch (field) {
424
425
  case 'signups':
425
426
  valueA = a.signups;
@@ -455,6 +456,264 @@ class ReferrersStatsService {
455
456
  meta: {}
456
457
  };
457
458
  }
459
+
460
+ /**
461
+ * Fetch free member counts by UTM parameter with date range
462
+ * Returns members who haven't converted to paid within the same time window
463
+ * @param {string} utmField - The UTM field to group by
464
+ * @param {Object} options - Query options
465
+ * @returns {Promise<{utm_value: string, signups: number}[]>}
466
+ **/
467
+ async fetchMemberCountsByUtm(utmField, options) {
468
+ // Validate utm_field for defense-in-depth (even though caller validates)
469
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
470
+ if (!validUtmFields.includes(utmField)) {
471
+ throw new errors.BadRequestError({
472
+ message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
473
+ });
474
+ }
475
+
476
+ const knex = this.knex;
477
+ const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
478
+ const {post_id: postId} = options;
479
+
480
+ // Query: Free members who haven't converted to paid within the same time window
481
+ const freeSignupsQuery = knex('members_created_events as mce')
482
+ .select(knex.raw(`mce.${utmField} as utm_value`))
483
+ .select(knex.raw('COUNT(DISTINCT mce.member_id) as signups'))
484
+ .leftJoin('members_subscription_created_events as msce', function () {
485
+ this.on('mce.member_id', '=', 'msce.member_id')
486
+ // Filter msce.created_at: only count conversions within the same time window
487
+ // This ensures we don't count conversions that happened outside our date range
488
+ .andOn('msce.created_at', '>=', knex.raw('?', [startDateTime]))
489
+ .andOn('msce.created_at', '<=', knex.raw('?', [endDateTime]));
490
+ })
491
+ .whereNull('msce.id')
492
+ .whereNotNull(`mce.${utmField}`)
493
+ .groupBy(`mce.${utmField}`);
494
+
495
+ // Filter mce.created_at: only include members created within the date range
496
+ applyDateFilter(freeSignupsQuery, startDateTime, endDateTime, 'mce.created_at');
497
+
498
+ // Apply post filtering if post_id is provided
499
+ if (postId) {
500
+ freeSignupsQuery
501
+ .where('mce.attribution_id', postId)
502
+ .where('mce.attribution_type', 'post');
503
+ }
504
+
505
+ const results = await freeSignupsQuery;
506
+ return results.map(row => ({
507
+ utm_value: row.utm_value,
508
+ signups: parseInt(row.signups) || 0
509
+ }));
510
+ }
511
+
512
+ /**
513
+ * Fetch paid conversion counts by UTM parameter with date range
514
+ * @param {string} utmField - The UTM field to group by
515
+ * @param {Object} options - Query options
516
+ * @returns {Promise<{utm_value: string, paid_conversions: number}[]>}
517
+ **/
518
+ async fetchPaidConversionsByUtm(utmField, options) {
519
+ // Validate utm_field for defense-in-depth (even though caller validates)
520
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
521
+ if (!validUtmFields.includes(utmField)) {
522
+ throw new errors.BadRequestError({
523
+ message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
524
+ });
525
+ }
526
+
527
+ const knex = this.knex;
528
+ const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
529
+ const {post_id: postId} = options;
530
+
531
+ const paidConversionsQuery = knex('members_subscription_created_events as msce')
532
+ .select(knex.raw(`msce.${utmField} as utm_value`))
533
+ .select(knex.raw('COUNT(DISTINCT msce.member_id) as paid_conversions'))
534
+ .whereNotNull(`msce.${utmField}`)
535
+ .groupBy(`msce.${utmField}`);
536
+
537
+ // Apply date filtering
538
+ applyDateFilter(paidConversionsQuery, startDateTime, endDateTime, 'msce.created_at');
539
+
540
+ // Apply post filtering if post_id is provided
541
+ if (postId) {
542
+ paidConversionsQuery
543
+ .where('msce.attribution_id', postId)
544
+ .where('msce.attribution_type', 'post');
545
+ }
546
+
547
+ const results = await paidConversionsQuery;
548
+ return results.map(row => ({
549
+ utm_value: row.utm_value,
550
+ paid_conversions: parseInt(row.paid_conversions) || 0
551
+ }));
552
+ }
553
+
554
+ /**
555
+ * Fetch MRR by UTM parameter with date range
556
+ * @param {string} utmField - The UTM field to group by
557
+ * @param {Object} options - Query options
558
+ * @returns {Promise<{utm_value: string, mrr: number}[]>}
559
+ **/
560
+ async fetchMrrByUtm(utmField, options) {
561
+ // Validate utm_field for defense-in-depth (even though caller validates)
562
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
563
+ if (!validUtmFields.includes(utmField)) {
564
+ throw new errors.BadRequestError({
565
+ message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
566
+ });
567
+ }
568
+
569
+ const knex = this.knex;
570
+ const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
571
+ const {post_id: postId} = options;
572
+
573
+ // Join subscription created events with paid subscription events to get MRR changes
574
+ let query = knex('members_subscription_created_events as msce')
575
+ .join('members_paid_subscription_events as mpse', function () {
576
+ this.on('msce.member_id', '=', 'mpse.member_id')
577
+ .andOn('msce.subscription_id', '=', 'mpse.subscription_id');
578
+ })
579
+ .select(knex.raw(`msce.${utmField} as utm_value`))
580
+ .select(knex.raw(`SUM(mpse.mrr_delta) as mrr`))
581
+ .where('mpse.mrr_delta', '>', 0) // Only positive MRR changes (new subscriptions)
582
+ .whereNotNull(`msce.${utmField}`)
583
+ .groupBy(`msce.${utmField}`);
584
+
585
+ // Apply date filtering
586
+ applyDateFilter(query, startDateTime, endDateTime, 'msce.created_at');
587
+
588
+ // Apply post filtering if post_id is provided
589
+ if (postId) {
590
+ query
591
+ .where('msce.attribution_id', postId)
592
+ .where('msce.attribution_type', 'post');
593
+ }
594
+
595
+ const results = await query;
596
+ return results.map(row => ({
597
+ utm_value: row.utm_value,
598
+ mrr: parseInt(row.mrr) || 0
599
+ }));
600
+ }
601
+
602
+ /**
603
+ * Get UTM growth stats broken down by UTM parameter
604
+ * @param {Object} options
605
+ * @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
606
+ * @param {string} [options.order='free_members desc'] - Sort order
607
+ * @param {number} [options.limit=50] - Maximum number of results
608
+ * @param {string} [options.date_from] - Start date in YYYY-MM-DD format
609
+ * @param {string} [options.date_to] - End date in YYYY-MM-DD format
610
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
611
+ * @param {string} [options.post_id] - Optional filter by post ID
612
+ * @returns {Promise<{data: UtmGrowthStat[], meta: {}}>}
613
+ */
614
+ async getUtmGrowthStats(options = {}) {
615
+ const utmField = options.utm_type || 'utm_source';
616
+ const limit = options.limit || 50;
617
+ const postId = options.post_id;
618
+ const orderBy = options.order || 'free_members desc';
619
+
620
+ // Validate utm_type is a valid UTM field
621
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
622
+ if (!validUtmFields.includes(utmField)) {
623
+ throw new errors.BadRequestError({
624
+ message: `Invalid utm_type: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
625
+ });
626
+ }
627
+
628
+ // Fetch data from database in parallel
629
+ const [freeMembers, paidConversions, mrrData] = await Promise.all([
630
+ this.fetchMemberCountsByUtm(utmField, options),
631
+ this.fetchPaidConversionsByUtm(utmField, options),
632
+ this.fetchMrrByUtm(utmField, options)
633
+ ]);
634
+
635
+ // Combine results by utm_value
636
+ const utmMap = new Map();
637
+
638
+ // Add free members
639
+ freeMembers.forEach((row) => {
640
+ utmMap.set(row.utm_value, {
641
+ utm_value: row.utm_value,
642
+ utm_type: utmField,
643
+ free_members: row.signups,
644
+ paid_members: 0,
645
+ mrr: 0
646
+ });
647
+ });
648
+
649
+ // Add paid conversions
650
+ paidConversions.forEach((row) => {
651
+ const existing = utmMap.get(row.utm_value);
652
+ if (existing) {
653
+ existing.paid_members = row.paid_conversions;
654
+ } else {
655
+ utmMap.set(row.utm_value, {
656
+ utm_value: row.utm_value,
657
+ utm_type: utmField,
658
+ free_members: 0,
659
+ paid_members: row.paid_conversions,
660
+ mrr: 0
661
+ });
662
+ }
663
+ });
664
+
665
+ // Add MRR data
666
+ mrrData.forEach((row) => {
667
+ const existing = utmMap.get(row.utm_value);
668
+ if (existing) {
669
+ existing.mrr = row.mrr;
670
+ } else {
671
+ utmMap.set(row.utm_value, {
672
+ utm_value: row.utm_value,
673
+ utm_type: utmField,
674
+ free_members: 0,
675
+ paid_members: 0,
676
+ mrr: row.mrr
677
+ });
678
+ }
679
+ });
680
+
681
+ // Convert to array
682
+ let results = Array.from(utmMap.values());
683
+
684
+ // Apply sorting
685
+ const [field, direction] = orderBy.split(' ');
686
+ const validFields = ['free_members', 'paid_members', 'mrr', 'utm_value'];
687
+
688
+ if (validFields.includes(field)) {
689
+ results.sort((a, b) => {
690
+ let valueA = a[field];
691
+ let valueB = b[field];
692
+
693
+ // Handle string sorting for utm_value
694
+ if (field === 'utm_value') {
695
+ valueA = String(valueA).toLowerCase();
696
+ valueB = String(valueB).toLowerCase();
697
+ }
698
+
699
+ if (direction === 'asc') {
700
+ return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
701
+ }
702
+ // Default to desc
703
+ return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
704
+ });
705
+ }
706
+
707
+ // Apply limit (but not when filtering by post as per original implementation)
708
+ if (!postId && limit && limit > 0) {
709
+ results = results.slice(0, limit);
710
+ }
711
+
712
+ return {
713
+ data: results,
714
+ meta: {}
715
+ };
716
+ }
458
717
  }
459
718
 
460
719
  module.exports = ReferrersStatsService;
@@ -507,3 +766,13 @@ module.exports.normalizeSource = normalizeSource;
507
766
  * @property {number} mrr Total MRR from this source (in cents)
508
767
  * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
509
768
  **/
769
+
770
+ /**
771
+ * @typedef {object} UtmGrowthStat
772
+ * @type {Object}
773
+ * @property {string} utm_value - The UTM parameter value (e.g., 'google', 'facebook')
774
+ * @property {string} utm_type - The UTM parameter type ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
775
+ * @property {number} free_members - Count of free member signups
776
+ * @property {number} paid_members - Count of paid member conversions
777
+ * @property {number} mrr - Total MRR from this UTM parameter (in cents)
778
+ **/
@@ -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
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
255
+ * @param {string} [options.date_to] - End date in YYYY-MM-DD format
256
+ * @param {string} [options.timezone] - Timezone to use for date interpretation
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": {