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.
- package/components/tryghost-i18n-6.3.1.tgz +0 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
- package/core/built/admin/assets/admin-x-activitypub/{index-lT95Q15h.mjs → index-CdMLWVnk.mjs} +13477 -12814
- package/core/built/admin/assets/admin-x-activitypub/{index-DmCoswaX.mjs → index-DsmVTjDw.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-UxqLGRTu.mjs → CodeEditorView-CHa5Y-LX.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-B5r0jdJS.mjs → index-CGFCkAXn.mjs} +9 -5
- package/core/built/admin/assets/admin-x-settings/{index-Co907MFn.mjs → index-Cg4zMcj4.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-B7j9sxR4.mjs → modals-DH5H9Tgk.mjs} +1036 -1034
- package/core/built/admin/assets/{chunk.397.d5e25bb9baf088f52499.js → chunk.397.a720333cfffc99c47e71.js} +5 -4
- package/core/built/admin/assets/{chunk.524.70595796c7b8c6003a2d.js → chunk.524.5ac0aa6b2e0374d43fa1.js} +6 -6
- package/core/built/admin/assets/{chunk.582.d9b970b71da671ac1b7b.js → chunk.582.944f56b6e36ff0afdc80.js} +8 -8
- package/core/built/admin/assets/{ghost-2066304fd0b166e1c16d397dd73ef7b2.js → ghost-1bfab97cb7f550726e894fae6650a808.js} +23 -21
- package/core/built/admin/assets/ghost-8ade80412a20088a4f0a9a1159f0bdba.css +1 -0
- package/core/built/admin/assets/ghost-dark-b128f29fc44b34b6cfb0fc8492266c2a.css +1 -0
- package/core/built/admin/assets/posts/posts.js +30571 -30284
- package/core/built/admin/assets/stats/stats.js +21340 -21270
- package/core/built/admin/index.html +5 -5
- package/core/frontend/helpers/ghost_head.js +2 -1
- package/core/server/api/endpoints/stats.js +37 -1
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +1 -0
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +1 -0
- package/core/server/data/migrations/versions/6.3/2025-10-02-15-13-31-add-members-otc-secret-setting.js +9 -0
- package/core/server/data/schema/default-settings/default-settings.json +4 -0
- package/core/server/models/settings.js +1 -0
- package/core/server/services/donations/DonationBookshelfRepository.js +6 -1
- package/core/server/services/donations/DonationBookshelfRepository.ts +11 -1
- package/core/server/services/donations/DonationPaymentEvent.js +10 -0
- package/core/server/services/donations/DonationPaymentEvent.ts +10 -0
- package/core/server/services/member-attribution/AttributionBuilder.js +55 -10
- package/core/server/services/member-attribution/README.md +101 -0
- package/core/server/services/member-attribution/ReferrerTranslator.js +40 -3
- package/core/server/services/member-attribution/UrlHistory.js +5 -0
- package/core/server/services/members/api.js +1 -1
- package/core/server/services/members/members-api/controllers/RouterController.js +26 -0
- package/core/server/services/members/members-api/repositories/MemberRepository.js +6 -1
- package/core/server/services/members-events/EventStorage.js +10 -0
- package/core/server/services/stats/ReferrersStatsService.js +281 -12
- package/core/server/services/stats/StatsService.js +17 -0
- package/core/server/services/stripe/StripeAPI.js +7 -2
- package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +6 -1
- package/core/server/web/api/endpoints/admin/routes.js +1 -0
- package/core/server/web/members/app.js +2 -0
- package/core/server/web/shared/middleware/api/spam-prevention.js +76 -0
- package/core/server/web/shared/middleware/brute.js +23 -0
- package/core/shared/config/defaults.json +13 -1
- package/core/shared/config/env/config.testing-browser.json +12 -0
- package/core/shared/config/env/config.testing-mysql.json +12 -0
- package/core/shared/config/env/config.testing.json +12 -0
- package/core/shared/labs.js +1 -0
- package/package.json +6 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +294 -254
- package/components/tryghost-i18n-6.2.0.tgz +0 -0
- package/core/built/admin/assets/ghost-49475952d56ffe89bd47ab9d9c64ada8.css +0 -1
- package/core/built/admin/assets/ghost-dark-27877727751b91f03261d449d74e33b9.css +0 -1
- /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.
|
|
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": {
|