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
@@ -6,7 +6,7 @@
6
6
  <title>Ghost</title>
7
7
 
8
8
 
9
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.2%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2213355f26c2%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%22d668621a75%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%22cf5b4df358%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%22f5dea8dc06%22%2C%22adminXActivitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
9
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.3%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2274f827c663%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%22f69553e153%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%22bae67bc277%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%223dadbc8059%22%2C%22adminXActivitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
10
10
 
11
11
  <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
12
12
  <meta name="pinterest" content="nopin" />
@@ -28,7 +28,7 @@
28
28
  </style>
29
29
 
30
30
  <link integrity="" rel="stylesheet" href="assets/vendor-0ede59da8efb5e28fa929557f7ff7154.css">
31
- <link integrity="" rel="stylesheet" href="assets/ghost-49475952d56ffe89bd47ab9d9c64ada8.css" title="light">
31
+ <link integrity="" rel="stylesheet" href="assets/ghost-8ade80412a20088a4f0a9a1159f0bdba.css" title="light">
32
32
 
33
33
 
34
34
  </head>
@@ -48,8 +48,8 @@
48
48
  <div id="ember-basic-dropdown-wormhole"></div>
49
49
 
50
50
  <script src="assets/vendor-aed0068cf9b67d042dd23a6343545b7b.js"></script>
51
- <script src="assets/chunk.397.d5e25bb9baf088f52499.js"></script>
52
- <script src="assets/chunk.524.70595796c7b8c6003a2d.js"></script>
53
- <script src="assets/ghost-2066304fd0b166e1c16d397dd73ef7b2.js"></script>
51
+ <script src="assets/chunk.397.a720333cfffc99c47e71.js"></script>
52
+ <script src="assets/chunk.524.5ac0aa6b2e0374d43fa1.js"></script>
53
+ <script src="assets/ghost-1bfab97cb7f550726e894fae6650a808.js"></script>
54
54
  </body>
55
55
  </html>
@@ -64,7 +64,8 @@ function getMembersHelper(data, frontendKey, excludeList) {
64
64
  key: frontendKey,
65
65
  api: urlUtils.urlFor('api', {type: 'content'}, true),
66
66
  locale: settingsCache.get('locale') || 'en',
67
- 'members-signin-otc': labs.isSet('membersSigninOTC') // html.dataset converts dash-attrs to camelCase
67
+ 'members-signin-otc': labs.isSet('membersSigninOTC'), // html.dataset converts dash-attrs to camelCase
68
+ 'members-signin-otc-alpha': labs.isSet('membersSigninOTCAlpha') // html.dataset converts dash-attrs to camelCase
68
69
  };
69
70
  if (colorString) {
70
71
  attributes['accent-color'] = colorString;
@@ -514,7 +514,7 @@ const controller = {
514
514
  },
515
515
  options: [
516
516
  'order',
517
- 'limit',
517
+ 'limit',
518
518
  'date_from',
519
519
  'date_to',
520
520
  'timezone',
@@ -541,6 +541,42 @@ const controller = {
541
541
  async query(frame) {
542
542
  return await statsService.api.getTopSourcesWithRange(frame.options);
543
543
  }
544
+ },
545
+ utmGrowth: {
546
+ headers: {
547
+ cacheInvalidate: false
548
+ },
549
+ options: [
550
+ 'utm_type',
551
+ 'order',
552
+ 'limit',
553
+ 'date_from',
554
+ 'date_to',
555
+ 'timezone',
556
+ 'post_id'
557
+ ],
558
+ permissions: {
559
+ docName: 'posts',
560
+ method: 'browse'
561
+ },
562
+ cache: statsService.cache,
563
+ generateCacheKeyData(frame) {
564
+ return {
565
+ method: 'utmGrowth',
566
+ options: {
567
+ utm_type: frame.options.utm_type,
568
+ order: frame.options.order,
569
+ limit: frame.options.limit,
570
+ date_from: frame.options.date_from,
571
+ date_to: frame.options.date_to,
572
+ timezone: frame.options.timezone,
573
+ post_id: frame.options.post_id
574
+ }
575
+ };
576
+ },
577
+ async query(frame) {
578
+ return await statsService.api.getUtmGrowthStats(frame.options);
579
+ }
544
580
  }
545
581
 
546
582
  };
@@ -3,6 +3,7 @@ const keyGroupMapping = {
3
3
  members_public_key: 'core',
4
4
  members_private_key: 'core',
5
5
  members_email_auth_secret: 'core',
6
+ members_otc_secret: 'core',
6
7
  db_hash: 'core',
7
8
  next_update_check: 'core',
8
9
  notifications: 'core',
@@ -31,6 +31,7 @@ const keyTypeMapping = {
31
31
  members_public_key: 'string',
32
32
  members_private_key: 'string',
33
33
  members_email_auth_secret: 'string',
34
+ members_otc_secret: 'string',
34
35
  default_content_visibility: 'string',
35
36
  stripe_secret_key: 'string',
36
37
  stripe_publishable_key: 'string',
@@ -0,0 +1,9 @@
1
+ const {addSetting} = require('../../utils');
2
+ const crypto = require('crypto');
3
+
4
+ module.exports = addSetting({
5
+ key: 'members_otc_secret',
6
+ value: crypto.randomBytes(64).toString('hex'),
7
+ type: 'string',
8
+ group: 'core'
9
+ });
@@ -60,6 +60,10 @@
60
60
  "defaultValue": null,
61
61
  "type": "string"
62
62
  },
63
+ "members_otc_secret": {
64
+ "defaultValue": null,
65
+ "type": "string"
66
+ },
63
67
  "site_uuid": {
64
68
  "defaultValue": null,
65
69
  "type": "string",
@@ -57,6 +57,7 @@ function parseDefaultSettings() {
57
57
  members_public_key: () => getMembersKey('public'),
58
58
  members_private_key: () => getMembersKey('private'),
59
59
  members_email_auth_secret: () => crypto.randomBytes(64).toString('hex'),
60
+ members_otc_secret: () => crypto.randomBytes(64).toString('hex'),
60
61
  ghost_public_key: () => getGhostKey('public'),
61
62
  ghost_private_key: () => getGhostKey('private'),
62
63
  site_uuid: () => getOrGenerateSiteUuid()
@@ -19,7 +19,12 @@ class DonationBookshelfRepository {
19
19
  attribution_type: event.attributionType,
20
20
  referrer_source: event.referrerSource,
21
21
  referrer_medium: event.referrerMedium,
22
- referrer_url: event.referrerUrl
22
+ referrer_url: event.referrerUrl,
23
+ utm_source: event.utmSource,
24
+ utm_medium: event.utmMedium,
25
+ utm_campaign: event.utmCampaign,
26
+ utm_term: event.utmTerm,
27
+ utm_content: event.utmContent
23
28
  });
24
29
  }
25
30
  }
@@ -23,6 +23,11 @@ type DonationEventModelInstance = BookshelfModelInstance & {
23
23
  referrer_source: string | null;
24
24
  referrer_medium: string | null;
25
25
  referrer_url: string | null;
26
+ utm_source: string | null;
27
+ utm_medium: string | null;
28
+ utm_campaign: string | null;
29
+ utm_term: string | null;
30
+ utm_content: string | null;
26
31
  }
27
32
  type DonationPaymentEventBookshelfModel = BookshelfModel<DonationEventModelInstance>;
28
33
 
@@ -47,7 +52,12 @@ export class DonationBookshelfRepository implements DonationRepository {
47
52
  attribution_type: event.attributionType,
48
53
  referrer_source: event.referrerSource,
49
54
  referrer_medium: event.referrerMedium,
50
- referrer_url: event.referrerUrl
55
+ referrer_url: event.referrerUrl,
56
+ utm_source: event.utmSource,
57
+ utm_medium: event.utmMedium,
58
+ utm_campaign: event.utmCampaign,
59
+ utm_term: event.utmTerm,
60
+ utm_content: event.utmContent
51
61
  });
52
62
  }
53
63
  }
@@ -15,6 +15,11 @@ class DonationPaymentEvent {
15
15
  referrerSource;
16
16
  referrerMedium;
17
17
  referrerUrl;
18
+ utmSource;
19
+ utmMedium;
20
+ utmCampaign;
21
+ utmTerm;
22
+ utmContent;
18
23
  constructor(data, timestamp) {
19
24
  this.timestamp = timestamp;
20
25
  this.name = data.name;
@@ -29,6 +34,11 @@ class DonationPaymentEvent {
29
34
  this.referrerSource = data.referrerSource;
30
35
  this.referrerMedium = data.referrerMedium;
31
36
  this.referrerUrl = data.referrerUrl;
37
+ this.utmSource = data.utmSource;
38
+ this.utmMedium = data.utmMedium;
39
+ this.utmCampaign = data.utmCampaign;
40
+ this.utmTerm = data.utmTerm;
41
+ this.utmContent = data.utmContent;
32
42
  }
33
43
  static create(data, timestamp) {
34
44
  return new DonationPaymentEvent(data, timestamp ?? new Date());
@@ -13,6 +13,11 @@ export class DonationPaymentEvent {
13
13
  referrerSource: string | null;
14
14
  referrerMedium: string | null;
15
15
  referrerUrl: string | null;
16
+ utmSource: string | null;
17
+ utmMedium: string | null;
18
+ utmCampaign: string | null;
19
+ utmTerm: string | null;
20
+ utmContent: string | null;
16
21
 
17
22
  constructor(data: Omit<DonationPaymentEvent, 'timestamp'>, timestamp: Date) {
18
23
  this.timestamp = timestamp;
@@ -30,6 +35,11 @@ export class DonationPaymentEvent {
30
35
  this.referrerSource = data.referrerSource;
31
36
  this.referrerMedium = data.referrerMedium;
32
37
  this.referrerUrl = data.referrerUrl;
38
+ this.utmSource = data.utmSource;
39
+ this.utmMedium = data.utmMedium;
40
+ this.utmCampaign = data.utmCampaign;
41
+ this.utmTerm = data.utmTerm;
42
+ this.utmContent = data.utmContent;
33
43
  }
34
44
 
35
45
  static create(data: Omit<DonationPaymentEvent, 'timestamp'>, timestamp?: Date) {
@@ -7,6 +7,11 @@
7
7
  * @prop {string|null} referrerSource
8
8
  * @prop {string|null} referrerMedium
9
9
  * @prop {string|null} referrerUrl
10
+ * @prop {string|null} utmSource
11
+ * @prop {string|null} utmMedium
12
+ * @prop {string|null} utmCampaign
13
+ * @prop {string|null} utmTerm
14
+ * @prop {string|null} utmContent
10
15
  */
11
16
 
12
17
  class Attribution {
@@ -21,9 +26,14 @@ class Attribution {
21
26
  * @param {string|null} [data.referrerSource]
22
27
  * @param {string|null} [data.referrerMedium]
23
28
  * @param {string|null} [data.referrerUrl]
29
+ * @param {string|null} [data.utmSource]
30
+ * @param {string|null} [data.utmMedium]
31
+ * @param {string|null} [data.utmCampaign]
32
+ * @param {string|null} [data.utmTerm]
33
+ * @param {string|null} [data.utmContent]
24
34
  */
25
35
  constructor({
26
- id, url, type, referrerSource, referrerMedium, referrerUrl
36
+ id, url, type, referrerSource, referrerMedium, referrerUrl, utmSource, utmMedium, utmCampaign, utmTerm, utmContent
27
37
  }, {urlTranslator}) {
28
38
  this.id = id;
29
39
  this.url = url;
@@ -31,6 +41,11 @@ class Attribution {
31
41
  this.referrerSource = referrerSource;
32
42
  this.referrerMedium = referrerMedium;
33
43
  this.referrerUrl = referrerUrl;
44
+ this.utmSource = utmSource;
45
+ this.utmMedium = utmMedium;
46
+ this.utmCampaign = utmCampaign;
47
+ this.utmTerm = utmTerm;
48
+ this.utmContent = utmContent;
34
49
 
35
50
  /**
36
51
  * @private
@@ -57,7 +72,12 @@ class Attribution {
57
72
  title: null,
58
73
  referrerSource: this.referrerSource,
59
74
  referrerMedium: this.referrerMedium,
60
- referrerUrl: this.referrerUrl
75
+ referrerUrl: this.referrerUrl,
76
+ utmSource: this.utmSource,
77
+ utmMedium: this.utmMedium,
78
+ utmCampaign: this.utmCampaign,
79
+ utmTerm: this.utmTerm,
80
+ utmContent: this.utmContent
61
81
  };
62
82
  }
63
83
  return {
@@ -67,7 +87,12 @@ class Attribution {
67
87
  title: this.#urlTranslator.getUrlTitle(this.url),
68
88
  referrerSource: this.referrerSource,
69
89
  referrerMedium: this.referrerMedium,
70
- referrerUrl: this.referrerUrl
90
+ referrerUrl: this.referrerUrl,
91
+ utmSource: this.utmSource,
92
+ utmMedium: this.utmMedium,
93
+ utmCampaign: this.utmCampaign,
94
+ utmTerm: this.utmTerm,
95
+ utmContent: this.utmContent
71
96
  };
72
97
  }
73
98
 
@@ -80,7 +105,12 @@ class Attribution {
80
105
  title: model.get('title') ?? model.get('name') ?? this.#urlTranslator.getUrlTitle(this.url),
81
106
  referrerSource: this.referrerSource,
82
107
  referrerMedium: this.referrerMedium,
83
- referrerUrl: this.referrerUrl
108
+ referrerUrl: this.referrerUrl,
109
+ utmSource: this.utmSource,
110
+ utmMedium: this.utmMedium,
111
+ utmCampaign: this.utmCampaign,
112
+ utmTerm: this.utmTerm,
113
+ utmContent: this.utmContent
84
114
  };
85
115
  }
86
116
 
@@ -118,14 +148,19 @@ class AttributionBuilder {
118
148
  /**
119
149
  * Creates an Attribution object with the dependencies injected
120
150
  */
121
- build({id, url, type, referrerSource, referrerMedium, referrerUrl}) {
151
+ build({id, url, type, referrerSource, referrerMedium, referrerUrl, utmSource, utmMedium, utmCampaign, utmTerm, utmContent}) {
122
152
  return new Attribution({
123
153
  id,
124
154
  url,
125
155
  type,
126
156
  referrerSource,
127
157
  referrerMedium,
128
- referrerUrl
158
+ referrerUrl,
159
+ utmSource,
160
+ utmMedium,
161
+ utmCampaign,
162
+ utmTerm,
163
+ utmContent
129
164
  }, {urlTranslator: this.urlTranslator});
130
165
  }
131
166
 
@@ -142,14 +177,24 @@ class AttributionBuilder {
142
177
  type: null,
143
178
  referrerSource: null,
144
179
  referrerMedium: null,
145
- referrerUrl: null
180
+ referrerUrl: null,
181
+ utmSource: null,
182
+ utmMedium: null,
183
+ utmCampaign: null,
184
+ utmTerm: null,
185
+ utmContent: null
146
186
  });
147
187
  }
148
188
 
149
189
  const referrerData = this.referrerTranslator.getReferrerDetails(history) || {
150
190
  referrerSource: null,
151
191
  referrerMedium: null,
152
- referrerUrl: null
192
+ referrerUrl: null,
193
+ utmSource: null,
194
+ utmMedium: null,
195
+ utmCampaign: null,
196
+ utmTerm: null,
197
+ utmContent: null
153
198
  };
154
199
 
155
200
  // Start at the end. Return the first post we find
@@ -194,10 +239,10 @@ class AttributionBuilder {
194
239
 
195
240
  // We only have history items without a path that have invalid ids
196
241
  return this.build({
197
- ...referrerData,
198
242
  id: null,
199
243
  url: null,
200
- type: null
244
+ type: null,
245
+ ...referrerData
201
246
  });
202
247
  }
203
248
  }
@@ -0,0 +1,101 @@
1
+ # Member Attribution Service
2
+
3
+ The Member Attribution Service tracks how members discover and sign up to a Ghost site. It captures attribution data (source pages, referrer information, UTM parameters) and associates it with member signup and subscription events.
4
+
5
+ ## Features
6
+
7
+ ### Core Attribution Tracking
8
+ - **Page Attribution**: Tracks which pages (posts, pages, authors, tags) visitors viewed before becoming members
9
+ - **Referrer Attribution**: Identifies external sources (search engines, social media, direct links) that brought visitors to the site
10
+ - **UTM Parameter Tracking**: Captures UTM campaign parameters (source, medium, campaign, term, content) for marketing attribution
11
+ - **Last Post Algorithm**: Prioritizes the last post viewed in the visitor's journey as the primary attribution source
12
+
13
+ ### Attribution Sources
14
+ - **Content Attribution**: Posts, pages, authors, and tags visited by members
15
+ - **External Referrers**: Tracks referrer sources like Google, Facebook, Twitter, etc. using `@tryghost/referrer-parser`
16
+ - **Manual Creation**: Tracks members created via Admin UI, API, or import tools
17
+ - **Integration Attribution**: Associates members created via integrations with the integration name
18
+ - **Newsletter Links**: Adds attribution tracking to outbound links in newsletters with `?ref=` parameters
19
+
20
+ ### Settings
21
+ - **Member Source Tracking**: Can be enabled/disabled via `members_track_sources` setting
22
+ - **Outbound Link Tagging**: Can be enabled/disabled via `outbound_link_tagging` setting
23
+
24
+ ## Architecture
25
+
26
+ ### Component Overview
27
+
28
+ ```mermaid
29
+ graph TD
30
+ A[Frontend Browser<br/>member-attribution.js<br/>Captures URL history in session] -->|URLHistory Array| B[MemberAttributionService<br/>Main service interface<br/>Coordinates attribution logic]
31
+
32
+ B --> C[AttributionBuilder<br/>Converts URLHistory into Attribution objects<br/>Implements Last Post Algorithm]
33
+ B --> D[UrlTranslator<br/>Converts paths to resource IDs and types<br/>Fetches Post/Page/Tag/Author models]
34
+ B --> E[ReferrerTranslator<br/>Parses referrer URLs to identify sources<br/>Extracts UTM parameters]
35
+ B --> F[OutboundLinkTagger<br/>Adds ?ref parameters to external links]
36
+
37
+ C --> D
38
+ C --> E
39
+ ```
40
+
41
+ ### Components
42
+
43
+ #### 1. **MemberAttributionService** (`MemberAttributionService.js`)
44
+ Main service interface that coordinates all attribution logic.
45
+
46
+ #### 2. **AttributionBuilder** (`AttributionBuilder.js`)
47
+ Converts URL history into attribution resources using the "Last Post Algorithm™️".
48
+
49
+ **Key Classes:**
50
+ - `Attribution`: Represents attribution data with methods to fetch and enrich resources
51
+ - `AttributionBuilder`: Factory for creating `Attribution` instances
52
+
53
+ #### 3. **UrlHistory** (`UrlHistory.js`)
54
+ Validated container for URL history arrays from the frontend.
55
+
56
+ #### 4. **UrlTranslator** (`UrlTranslator.js`)
57
+ Translates between URLs and Ghost resources.
58
+
59
+ #### 5. **ReferrerTranslator** (`ReferrerTranslator.js`)
60
+ Parses referrer information into source and medium classifications.
61
+
62
+ #### 6. **OutboundLinkTagger** (`OutboundLinkTagger.js`)
63
+ Adds `?ref=` parameters to external links in newsletters.
64
+
65
+ #### 7. **Frontend Script** (`member-attribution.js`)
66
+ Browser-side script that captures visitor journey in sessionStorage.
67
+
68
+ ## Attribution Types
69
+
70
+ The service supports these attribution types:
71
+
72
+ | Type | Description | Has ID | Resource Model |
73
+ |----------|------------------------------------------|--------|----------------|
74
+ | `post` | Blog post | ✓ | Post |
75
+ | `page` | Static page | ✓ | Post |
76
+ | `author` | Author page | ✓ | User |
77
+ | `tag` | Tag page | ✓ | Tag |
78
+ | `url` | Generic URL (no specific resource) | ✗ | None |
79
+ | `null` | No attribution (tracking disabled/empty) | ✗ | None |
80
+
81
+ ## Internal Context Sources
82
+
83
+ When members are created through Ghost's internal systems:
84
+
85
+ | Context | referrerSource | referrerMedium |
86
+ |--------------|-----------------------|-----------------|
87
+ | `import` | Imported | Member Importer |
88
+ | `admin` | Created manually | Ghost Admin |
89
+ | `api` | Created via API | Admin API |
90
+ | `integration`| Integration: {name} | Admin API |
91
+
92
+ ## Testing
93
+
94
+ Tests are located in:
95
+ - `test/unit/server/services/member-attribution/attribution.test.js`
96
+ - `test/unit/server/services/member-attribution/history.test.js`
97
+ - `test/unit/server/services/member-attribution/service.test.js`
98
+ - `test/unit/server/services/member-attribution/url-translator.test.js`
99
+ - `test/unit/server/services/member-attribution/referrer-translator.test.js`
100
+ - `test/unit/server/services/member-attribution/outbound-link-tagger.test.js`
101
+ - `test/e2e-server/services/member-attribution.test.js`
@@ -3,6 +3,11 @@
3
3
  * @prop {string|null} [referrerSource]
4
4
  * @prop {string|null} [referrerMedium]
5
5
  * @prop {string|null} [referrerUrl]
6
+ * @prop {string|null} [utmSource]
7
+ * @prop {string|null} [utmMedium]
8
+ * @prop {string|null} [utmCampaign]
9
+ * @prop {string|null} [utmTerm]
10
+ * @prop {string|null} [utmContent]
6
11
  */
7
12
 
8
13
  const {ReferrerParser} = require('@tryghost/referrer-parser');
@@ -36,10 +41,40 @@ class ReferrerTranslator {
36
41
  return {
37
42
  referrerSource: null,
38
43
  referrerMedium: null,
39
- referrerUrl: null
44
+ referrerUrl: null,
45
+ utmSource: null,
46
+ utmMedium: null,
47
+ utmCampaign: null,
48
+ utmTerm: null,
49
+ utmContent: null
40
50
  };
41
51
  }
42
52
 
53
+ // Look for UTM parameters (earliest entry with UTM data)
54
+ // Note: history is ordered newest-to-oldest, so we want the LAST match
55
+ // This captures the original campaign source rather than subsequent navigations
56
+ let utmData = {
57
+ utmSource: null,
58
+ utmMedium: null,
59
+ utmCampaign: null,
60
+ utmTerm: null,
61
+ utmContent: null
62
+ };
63
+
64
+ // In finding the 'campaign' that got the user here, we want the earliest entry with UTM data
65
+ for (const item of history) {
66
+ if (item.utmSource || item.utmMedium || item.utmCampaign || item.utmTerm || item.utmContent) {
67
+ utmData = {
68
+ utmSource: item.utmSource || null,
69
+ utmMedium: item.utmMedium || null,
70
+ utmCampaign: item.utmCampaign || null,
71
+ utmTerm: item.utmTerm || null,
72
+ utmContent: item.utmContent || null
73
+ };
74
+ }
75
+ }
76
+
77
+ // In finding the 'content' that got the user to sign up, we want the latest entry with referrer data
43
78
  for (const item of history) {
44
79
  let refUrl = this.getUrlFromStr(item.referrerUrl);
45
80
  if (refUrl?.hostname === 'checkout.stripe.com') {
@@ -53,7 +88,8 @@ class ReferrerTranslator {
53
88
  return {
54
89
  referrerSource,
55
90
  referrerMedium,
56
- referrerUrl
91
+ referrerUrl,
92
+ ...utmData
57
93
  };
58
94
  }
59
95
  }
@@ -61,7 +97,8 @@ class ReferrerTranslator {
61
97
  return {
62
98
  referrerSource: 'Direct',
63
99
  referrerMedium: null,
64
- referrerUrl: null
100
+ referrerUrl: null,
101
+ ...utmData
65
102
  };
66
103
  }
67
104
 
@@ -6,6 +6,11 @@
6
6
  * @prop {string} [referrerSource]
7
7
  * @prop {string} [referrerMedium]
8
8
  * @prop {string} [referrerUrl]
9
+ * @prop {string} [utmSource]
10
+ * @prop {string} [utmMedium]
11
+ * @prop {string} [utmCampaign]
12
+ * @prop {string} [utmTerm]
13
+ * @prop {string} [utmContent]
9
14
  * @prop {number} time
10
15
  */
11
16
 
@@ -58,7 +58,7 @@ function createApiInstance(config) {
58
58
  validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
59
59
  validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
60
60
  maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT,
61
- secret: settingsCache.get('members_email_auth_secret')
61
+ secret: settingsCache.get('members_otc_secret')
62
62
  })
63
63
  },
64
64
  mail: {
@@ -203,6 +203,11 @@ module.exports = class RouterController {
203
203
  delete metadata.referrer_source;
204
204
  delete metadata.referrer_medium;
205
205
  delete metadata.referrer_url;
206
+ delete metadata.utm_source;
207
+ delete metadata.utm_medium;
208
+ delete metadata.utm_campaign;
209
+ delete metadata.utm_term;
210
+ delete metadata.utm_content;
206
211
 
207
212
  if (metadata.urlHistory) {
208
213
  // The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values)
@@ -236,6 +241,27 @@ module.exports = class RouterController {
236
241
  if (attribution.referrerUrl) {
237
242
  metadata.referrer_url = attribution.referrerUrl;
238
243
  }
244
+
245
+ // UTM parameters
246
+ if (attribution.utmSource) {
247
+ metadata.utm_source = attribution.utmSource;
248
+ }
249
+
250
+ if (attribution.utmMedium) {
251
+ metadata.utm_medium = attribution.utmMedium;
252
+ }
253
+
254
+ if (attribution.utmCampaign) {
255
+ metadata.utm_campaign = attribution.utmCampaign;
256
+ }
257
+
258
+ if (attribution.utmTerm) {
259
+ metadata.utm_term = attribution.utmTerm;
260
+ }
261
+
262
+ if (attribution.utmContent) {
263
+ metadata.utm_content = attribution.utmContent;
264
+ }
239
265
  }
240
266
  }
241
267
 
@@ -1179,7 +1179,12 @@ module.exports = class MemberRepository {
1179
1179
  type: data.attribution?.type ?? stripeSubscriptionData.metadata?.attribution_type ?? null,
1180
1180
  referrerSource: data.attribution?.referrerSource ?? stripeSubscriptionData.metadata?.referrer_source ?? null,
1181
1181
  referrerMedium: data.attribution?.referrerMedium ?? stripeSubscriptionData.metadata?.referrer_medium ?? null,
1182
- referrerUrl: data.attribution?.referrerUrl ?? stripeSubscriptionData.metadata?.referrer_url ?? null
1182
+ referrerUrl: data.attribution?.referrerUrl ?? stripeSubscriptionData.metadata?.referrer_url ?? null,
1183
+ utmSource: data.attribution?.utmSource ?? stripeSubscriptionData.metadata?.utm_source ?? null,
1184
+ utmMedium: data.attribution?.utmMedium ?? stripeSubscriptionData.metadata?.utm_medium ?? null,
1185
+ utmCampaign: data.attribution?.utmCampaign ?? stripeSubscriptionData.metadata?.utm_campaign ?? null,
1186
+ utmTerm: data.attribution?.utmTerm ?? stripeSubscriptionData.metadata?.utm_term ?? null,
1187
+ utmContent: data.attribution?.utmContent ?? stripeSubscriptionData.metadata?.utm_content ?? null
1183
1188
  };
1184
1189
 
1185
1190
  const subscriptionCreatedEvent = SubscriptionCreatedEvent.create({
@@ -35,6 +35,11 @@ class EventStorage {
35
35
  referrer_source: attribution?.referrerSource ?? null,
36
36
  referrer_medium: attribution?.referrerMedium ?? null,
37
37
  referrer_url: attribution?.referrerUrl ?? null,
38
+ utm_source: attribution?.utmSource ?? null,
39
+ utm_medium: attribution?.utmMedium ?? null,
40
+ utm_campaign: attribution?.utmCampaign ?? null,
41
+ utm_term: attribution?.utmTerm ?? null,
42
+ utm_content: attribution?.utmContent ?? null,
38
43
  batch_id: event.data.batchId ?? null
39
44
  });
40
45
  });
@@ -52,6 +57,11 @@ class EventStorage {
52
57
  referrer_source: attribution?.referrerSource ?? null,
53
58
  referrer_medium: attribution?.referrerMedium ?? null,
54
59
  referrer_url: attribution?.referrerUrl ?? null,
60
+ utm_source: attribution?.utmSource ?? null,
61
+ utm_medium: attribution?.utmMedium ?? null,
62
+ utm_campaign: attribution?.utmCampaign ?? null,
63
+ utm_term: attribution?.utmTerm ?? null,
64
+ utm_content: attribution?.utmContent ?? null,
55
65
  batch_id: event.data.batchId ?? null
56
66
  });
57
67
  });