ghost 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/tryghost-i18n-6.3.0.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-DmCoswaX.mjs → index-C8tyOPu-.mjs} +2 -2
- package/core/built/admin/assets/admin-x-activitypub/{index-lT95Q15h.mjs → index-QqbAPyqT.mjs} +77 -76
- 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.aac61953956de04feb53.js} +6 -6
- package/core/built/admin/assets/{chunk.582.d9b970b71da671ac1b7b.js → chunk.582.0a1461429ddbaef85ea9.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 +30561 -30283
- 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 +143 -0
- 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 +5 -5
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +178 -180
- 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
|
@@ -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.
|
|
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%221e670e9cd7%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%22c53b194bad%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%2265427ccad3%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-
|
|
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.
|
|
52
|
-
<script src="assets/chunk.524.
|
|
53
|
-
<script src="assets/ghost-
|
|
51
|
+
<script src="assets/chunk.397.a720333cfffc99c47e71.js"></script>
|
|
52
|
+
<script src="assets/chunk.524.aac61953956de04feb53.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
|
};
|
|
@@ -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',
|
|
@@ -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('
|
|
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
|
});
|