ghost 6.1.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-Bu9qXr9c.mjs → CodeEditorView-CHa5Y-LX.mjs} +3 -3
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-o4Q9MNrB.mjs → index-8WxO2QXI.mjs} +3017 -2827
- package/core/built/admin/assets/admin-x-settings/{index-qEdfz2hd.mjs → index-CGFCkAXn.mjs} +10 -6
- package/core/built/admin/assets/admin-x-settings/{index-BEpRBH9g.mjs → index-Cg4zMcj4.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-BgCSf8S1.mjs → index-DD3HKlR3.mjs} +306 -315
- package/core/built/admin/assets/admin-x-settings/{modals-BtQORnS4.mjs → modals-DH5H9Tgk.mjs} +8801 -8807
- package/core/built/admin/assets/{chunk.397.e5d027e53a68dff31d76.js → chunk.397.a720333cfffc99c47e71.js} +5 -4
- package/core/built/admin/assets/{chunk.524.2aa0847042f20c9a2a00.js → chunk.524.aac61953956de04feb53.js} +6 -6
- package/core/built/admin/assets/{chunk.582.9182c19afab95991771e.js → chunk.582.0a1461429ddbaef85ea9.js} +7 -7
- package/core/built/admin/assets/{ghost-9c47d152972b304cab0fb982dc3fccc1.js → ghost-1bfab97cb7f550726e894fae6650a808.js} +24 -22
- 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 +30617 -30330
- package/core/built/admin/assets/stats/stats.js +21342 -21272
- 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/utils/schema.js +11 -6
- package/core/server/data/migrations/versions/6.2/2025-09-30-14-28-09-add-utm-fields.js +24 -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/commands.js +21 -6
- package/core/server/data/schema/default-settings/default-settings.json +4 -0
- package/core/server/data/schema/schema.js +24 -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/email-service/EmailRenderer.js +1 -1
- package/core/server/services/lib/MailgunClient.js +4 -3
- package/core/server/services/lib/magic-link/MagicLink.js +9 -9
- package/core/server/services/mail/GhostMailer.js +4 -1
- 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/MembersConfigProvider.js +0 -15
- package/core/server/services/members/SingleUseTokenProvider.js +8 -8
- 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 +8 -8
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +288 -292
- package/components/tryghost-i18n-6.1.0.tgz +0 -0
- package/core/built/admin/assets/ghost-791574a9e2efe65c88412947d2e80170.css +0 -1
- package/core/built/admin/assets/ghost-dark-1a7d101d525c0fdcf406ac0abd98540f.css +0 -1
- /package/core/built/admin/assets/{chunk.397.e5d027e53a68dff31d76.js.LICENSE.txt → chunk.397.a720333cfffc99c47e71.js.LICENSE.txt} +0 -0
|
@@ -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
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const logging = require('@tryghost/logging');
|
|
2
2
|
const {URL} = require('url');
|
|
3
|
-
const crypto = require('crypto');
|
|
4
3
|
const createKeypair = require('keypair');
|
|
5
4
|
|
|
6
5
|
class MembersConfigProvider {
|
|
@@ -42,20 +41,6 @@ class MembersConfigProvider {
|
|
|
42
41
|
return this._settingsHelpers.isStripeConnected();
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
getAuthSecret() {
|
|
46
|
-
const hexSecret = this._settingsCache.get('members_email_auth_secret');
|
|
47
|
-
if (!hexSecret) {
|
|
48
|
-
logging.warn('Could not find members_email_auth_secret, using dynamically generated secret');
|
|
49
|
-
return crypto.randomBytes(64);
|
|
50
|
-
}
|
|
51
|
-
const secret = Buffer.from(hexSecret, 'hex');
|
|
52
|
-
if (secret.length < 64) {
|
|
53
|
-
logging.warn('members_email_auth_secret not large enough (64 bytes), using dynamically generated secret');
|
|
54
|
-
return crypto.randomBytes(64);
|
|
55
|
-
}
|
|
56
|
-
return secret;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
44
|
getAllowSelfSignup() {
|
|
60
45
|
// Free signups are allowed only if the site subscription is set to "Full-access"
|
|
61
46
|
// It is blocked for "Invite-only", "Paid-members-only" and "None" accesses
|
|
@@ -193,7 +193,7 @@ class SingleUseTokenProvider {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
try {
|
|
196
|
-
const model = await this.model.findOne({
|
|
196
|
+
const model = await this.model.findOne({uuid: otcRef});
|
|
197
197
|
|
|
198
198
|
if (!model) {
|
|
199
199
|
return false;
|
|
@@ -208,16 +208,16 @@ class SingleUseTokenProvider {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
* @method
|
|
212
|
-
* Retrieves the
|
|
211
|
+
* @method getRefByToken
|
|
212
|
+
* Retrieves the ref associated with a given token.
|
|
213
213
|
*
|
|
214
214
|
* @param {string} token - The token to look up.
|
|
215
|
-
* @returns {Promise<string|null>} The
|
|
215
|
+
* @returns {Promise<string|null>} The ref if found, or null if not found or on error.
|
|
216
216
|
*/
|
|
217
|
-
async
|
|
217
|
+
async getRefByToken(token) {
|
|
218
218
|
try {
|
|
219
219
|
const model = await this.model.findOne({token});
|
|
220
|
-
return model ? model.get('
|
|
220
|
+
return model ? model.get('uuid') : null;
|
|
221
221
|
} catch (err) {
|
|
222
222
|
return null;
|
|
223
223
|
}
|
|
@@ -232,7 +232,7 @@ class SingleUseTokenProvider {
|
|
|
232
232
|
*/
|
|
233
233
|
async getTokenByRef(ref) {
|
|
234
234
|
try {
|
|
235
|
-
const model = await this.model.findOne({
|
|
235
|
+
const model = await this.model.findOne({uuid: ref});
|
|
236
236
|
return model ? model.get('token') : null;
|
|
237
237
|
} catch (err) {
|
|
238
238
|
return null;
|
|
@@ -300,7 +300,7 @@ class SingleUseTokenProvider {
|
|
|
300
300
|
return false;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
const tokenId = await this.
|
|
303
|
+
const tokenId = await this.getRefByToken(token);
|
|
304
304
|
if (!tokenId) {
|
|
305
305
|
return false;
|
|
306
306
|
}
|
|
@@ -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
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const moment = require('moment');
|
|
2
|
+
const errors = require('@tryghost/errors');
|
|
2
3
|
|
|
3
4
|
// Import centralized date utilities
|
|
4
5
|
const {getDateBoundaries, applyDateFilter} = require('./utils/date-utils');
|
|
@@ -455,6 +456,138 @@ class ReferrersStatsService {
|
|
|
455
456
|
meta: {}
|
|
456
457
|
};
|
|
457
458
|
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Get UTM growth stats broken down by UTM parameter (fixture data for now)
|
|
462
|
+
* @param {Object} options
|
|
463
|
+
* @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
|
|
464
|
+
* @param {string} [options.order='free_members desc'] - Sort order
|
|
465
|
+
* @param {number} [options.limit=50] - Maximum number of results
|
|
466
|
+
* @param {string} [options.post_id] - Optional filter by post ID
|
|
467
|
+
* @returns {Promise<{data: UtmGrowthStat[], meta: {}}>}
|
|
468
|
+
*/
|
|
469
|
+
async getUtmGrowthStats(options = {}) {
|
|
470
|
+
const utmField = options.utm_type || 'utm_source';
|
|
471
|
+
const limit = options.limit || 50;
|
|
472
|
+
const postId = options.post_id;
|
|
473
|
+
|
|
474
|
+
// Validate utm_type is a valid UTM field
|
|
475
|
+
const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
476
|
+
if (!validUtmFields.includes(utmField)) {
|
|
477
|
+
throw new errors.BadRequestError({
|
|
478
|
+
message: `Invalid utm_type: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Fixture data; will replace with real data once members service is wired up fully
|
|
483
|
+
let fixtureData = this._getUtmFixtureData(utmField);
|
|
484
|
+
|
|
485
|
+
// If filtering by post, scale down the data
|
|
486
|
+
if (postId) {
|
|
487
|
+
fixtureData = fixtureData.map(item => ({
|
|
488
|
+
...item,
|
|
489
|
+
free_members: Math.floor(item.free_members * 0.3), // 30% of global
|
|
490
|
+
paid_members: Math.floor(item.paid_members * 0.25), // 25% of global
|
|
491
|
+
mrr: Math.floor(item.mrr * 0.25) // 25% of global
|
|
492
|
+
})).filter(item => item.free_members > 0 || item.paid_members > 0); // Only include items with data
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const sortedData = this._sortUtmData(fixtureData, options.order);
|
|
496
|
+
const limitedData = postId ? sortedData : (limit > 0 ? sortedData.slice(0, limit) : sortedData);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
data: limitedData,
|
|
500
|
+
meta: {}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Generate fixture data for UTM parameters
|
|
506
|
+
* @private
|
|
507
|
+
* @param {string} utmField - The UTM field ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
|
|
508
|
+
* @returns {UtmGrowthStat[]}
|
|
509
|
+
*/
|
|
510
|
+
_getUtmFixtureData(utmField) {
|
|
511
|
+
const fixtures = {
|
|
512
|
+
utm_source: [
|
|
513
|
+
{utm_value: 'google', utm_type: 'utm_source', free_members: 100, paid_members: 20, mrr: 10000},
|
|
514
|
+
{utm_value: 'facebook', utm_type: 'utm_source', free_members: 80, paid_members: 15, mrr: 7500},
|
|
515
|
+
{utm_value: 'twitter', utm_type: 'utm_source', free_members: 60, paid_members: 10, mrr: 5000},
|
|
516
|
+
{utm_value: 'newsletter', utm_type: 'utm_source', free_members: 40, paid_members: 25, mrr: 12500},
|
|
517
|
+
{utm_value: 'linkedin', utm_type: 'utm_source', free_members: 35, paid_members: 12, mrr: 6000},
|
|
518
|
+
{utm_value: 'reddit', utm_type: 'utm_source', free_members: 25, paid_members: 5, mrr: 2500},
|
|
519
|
+
{utm_value: 'youtube', utm_type: 'utm_source', free_members: 20, paid_members: 8, mrr: 4000},
|
|
520
|
+
{utm_value: 'instagram', utm_type: 'utm_source', free_members: 18, paid_members: 6, mrr: 3000}
|
|
521
|
+
],
|
|
522
|
+
utm_medium: [
|
|
523
|
+
{utm_value: 'organic', utm_type: 'utm_medium', free_members: 150, paid_members: 30, mrr: 15000},
|
|
524
|
+
{utm_value: 'cpc', utm_type: 'utm_medium', free_members: 90, paid_members: 20, mrr: 10000},
|
|
525
|
+
{utm_value: 'email', utm_type: 'utm_medium', free_members: 70, paid_members: 20, mrr: 10000},
|
|
526
|
+
{utm_value: 'social', utm_type: 'utm_medium', free_members: 30, paid_members: 10, mrr: 5000},
|
|
527
|
+
{utm_value: 'referral', utm_type: 'utm_medium', free_members: 25, paid_members: 8, mrr: 4000},
|
|
528
|
+
{utm_value: 'display', utm_type: 'utm_medium', free_members: 15, paid_members: 3, mrr: 1500}
|
|
529
|
+
],
|
|
530
|
+
utm_campaign: [
|
|
531
|
+
{utm_value: 'spring-sale', utm_type: 'utm_campaign', free_members: 120, paid_members: 35, mrr: 17500},
|
|
532
|
+
{utm_value: 'product-launch', utm_type: 'utm_campaign', free_members: 80, paid_members: 20, mrr: 10000},
|
|
533
|
+
{utm_value: 'webinar-series', utm_type: 'utm_campaign', free_members: 60, paid_members: 15, mrr: 7500},
|
|
534
|
+
{utm_value: 'holiday-promo', utm_type: 'utm_campaign', free_members: 45, paid_members: 18, mrr: 9000},
|
|
535
|
+
{utm_value: 'content-upgrade', utm_type: 'utm_campaign', free_members: 30, paid_members: 8, mrr: 4000},
|
|
536
|
+
{utm_value: 'partner-collab', utm_type: 'utm_campaign', free_members: 25, paid_members: 12, mrr: 6000}
|
|
537
|
+
],
|
|
538
|
+
utm_term: [
|
|
539
|
+
{utm_value: 'best-email-marketing', utm_type: 'utm_term', free_members: 85, paid_members: 22, mrr: 11000},
|
|
540
|
+
{utm_value: 'ghost-cms', utm_type: 'utm_term', free_members: 70, paid_members: 18, mrr: 9000},
|
|
541
|
+
{utm_value: 'newsletter-platform', utm_type: 'utm_term', free_members: 55, paid_members: 15, mrr: 7500},
|
|
542
|
+
{utm_value: 'content-management', utm_type: 'utm_term', free_members: 40, paid_members: 10, mrr: 5000},
|
|
543
|
+
{utm_value: 'publishing-platform', utm_type: 'utm_term', free_members: 30, paid_members: 8, mrr: 4000},
|
|
544
|
+
{utm_value: 'membership-software', utm_type: 'utm_term', free_members: 20, paid_members: 5, mrr: 2500}
|
|
545
|
+
],
|
|
546
|
+
utm_content: [
|
|
547
|
+
{utm_value: 'hero-cta', utm_type: 'utm_content', free_members: 95, paid_members: 25, mrr: 12500},
|
|
548
|
+
{utm_value: 'sidebar-banner', utm_type: 'utm_content', free_members: 75, paid_members: 18, mrr: 9000},
|
|
549
|
+
{utm_value: 'footer-link', utm_type: 'utm_content', free_members: 50, paid_members: 12, mrr: 6000},
|
|
550
|
+
{utm_value: 'email-button', utm_type: 'utm_content', free_members: 45, paid_members: 15, mrr: 7500},
|
|
551
|
+
{utm_value: 'popup-form', utm_type: 'utm_content', free_members: 35, paid_members: 8, mrr: 4000},
|
|
552
|
+
{utm_value: 'text-link', utm_type: 'utm_content', free_members: 25, paid_members: 6, mrr: 3000}
|
|
553
|
+
]
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
return fixtures[utmField] || fixtures.utm_source;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Sort UTM data by the specified order
|
|
561
|
+
* @private
|
|
562
|
+
* @param {UtmGrowthStat[]} data
|
|
563
|
+
* @param {string} [order='free_members desc']
|
|
564
|
+
* @returns {UtmGrowthStat[]}
|
|
565
|
+
*/
|
|
566
|
+
_sortUtmData(data, order = 'free_members desc') {
|
|
567
|
+
const [field, direction] = order.split(' ');
|
|
568
|
+
const validFields = ['free_members', 'paid_members', 'mrr', 'utm_value'];
|
|
569
|
+
|
|
570
|
+
if (!validFields.includes(field)) {
|
|
571
|
+
return data;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return [...data].sort((a, b) => {
|
|
575
|
+
let valueA = a[field];
|
|
576
|
+
let valueB = b[field];
|
|
577
|
+
|
|
578
|
+
// Handle string sorting for utm_value
|
|
579
|
+
if (field === 'utm_value') {
|
|
580
|
+
valueA = String(valueA).toLowerCase();
|
|
581
|
+
valueB = String(valueB).toLowerCase();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (direction === 'asc') {
|
|
585
|
+
return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
|
|
586
|
+
}
|
|
587
|
+
// Default to desc
|
|
588
|
+
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
458
591
|
}
|
|
459
592
|
|
|
460
593
|
module.exports = ReferrersStatsService;
|
|
@@ -507,3 +640,13 @@ module.exports.normalizeSource = normalizeSource;
|
|
|
507
640
|
* @property {number} mrr Total MRR from this source (in cents)
|
|
508
641
|
* @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
|
|
509
642
|
**/
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* @typedef {object} UtmGrowthStat
|
|
646
|
+
* @type {Object}
|
|
647
|
+
* @property {string} utm_value - The UTM parameter value (e.g., 'google', 'facebook')
|
|
648
|
+
* @property {string} utm_type - The UTM parameter type ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
|
|
649
|
+
* @property {number} free_members - Count of free member signups
|
|
650
|
+
* @property {number} paid_members - Count of paid member conversions
|
|
651
|
+
* @property {number} mrr - Total MRR from this UTM parameter (in cents)
|
|
652
|
+
**/
|