ghost 6.0.6 → 6.0.8
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.0.8.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-BRzGrD-C.mjs → index-1EXYCtPI.mjs} +26876 -22657
- package/core/built/admin/assets/admin-x-activitypub/{index-Co80faUx.mjs → index-If44c6h0.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-B4W7CQcA.mjs → CodeEditorView-CzXlGImM.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-Bmm3Xeuw.mjs → index-BgCSf8S1.mjs} +4 -5
- package/core/built/admin/assets/admin-x-settings/{index-CuwMM9FM.mjs → index-D2pIApbM.mjs} +26 -11
- package/core/built/admin/assets/admin-x-settings/{index-jv9DN3ZO.mjs → index-RKA3H0Lh.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-CUGEPPYA.mjs → modals-D0f6kxWg.mjs} +2 -2
- package/core/built/admin/assets/{chunk.524.56bb70d3e8660d34aef1.js → chunk.524.099dcd3975a0e60c5579.js} +6 -6
- package/core/built/admin/assets/{chunk.582.ae0341229e71a85d0b2d.js → chunk.582.6830378a89a17aeedd0b.js} +8 -8
- package/core/built/admin/assets/{ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js → ghost-138bb4718f8b9d666bdd7a2b45330d58.js} +34 -33
- package/core/built/admin/assets/{ghost-2c537ee89c36199137eafc1768fd7de8.css → ghost-a7a53bf80dc45c37ae9c174a0d02a882.css} +1 -1
- package/core/built/admin/assets/{ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css → ghost-dark-6e0062029f988d8676e87f22d8e7f4a3.css} +1 -1
- package/core/built/admin/assets/posts/posts.js +83336 -82274
- package/core/built/admin/assets/stats/stats.js +26957 -26799
- package/core/built/admin/index.html +4 -4
- package/core/frontend/helpers/ghost_head.js +9 -9
- package/core/frontend/public/ghost-stats.min.js +3 -3
- package/core/frontend/public/member-attribution.min.js +1 -1
- package/core/frontend/src/ghost-stats/ghost-stats.js +18 -4
- package/core/frontend/src/member-attribution/member-attribution.js +28 -18
- package/core/frontend/src/utils/url-attribution.js +53 -40
- package/core/server/api/endpoints/utils/serializers/input/posts.js +7 -5
- package/core/server/data/tinybird/endpoints/api_top_utm_campaigns.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_contents.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_mediums.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_sources.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_terms.pipe +31 -0
- package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +108 -0
- package/core/server/services/lib/magic-link/MagicLink.js +17 -11
- package/core/server/services/members/MembersConfigProvider.js +11 -1
- package/core/server/services/members/SingleUseTokenProvider.js +159 -6
- package/core/server/services/members/api.js +1 -1
- package/core/server/services/members/emails/signin.js +9 -5
- package/core/server/services/members/members-api/controllers/RouterController.js +95 -19
- package/core/server/services/members/members-api/members-api.js +8 -4
- package/core/server/services/members/members-ssr.js +5 -3
- package/core/server/services/members/middleware.js +2 -2
- package/core/server/services/tinybird/TinybirdService.js +6 -1
- package/core/server/services/update-check/UpdateCheckService.js +1 -1
- package/core/server/web/members/app.js +12 -3
- package/core/shared/config/defaults.json +1 -1
- package/core/shared/labs.js +3 -1
- package/package.json +7 -7
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +380 -104
- package/components/tryghost-i18n-6.0.6.tgz +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
|
|
2
|
+
- name: Date range
|
|
3
|
+
description: All fixture data
|
|
4
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC
|
|
5
|
+
expected_result: |
|
|
6
|
+
{"utm_source":"google","visits":6}
|
|
7
|
+
{"utm_source":"linkedin","visits":3}
|
|
8
|
+
{"utm_source":"twitter","visits":3}
|
|
9
|
+
{"utm_source":"newsletter","visits":3}
|
|
10
|
+
{"utm_source":"instagram","visits":1}
|
|
11
|
+
|
|
12
|
+
- name: Filtered by browser - Chrome
|
|
13
|
+
description: Filtered by browser - Chrome
|
|
14
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
|
|
15
|
+
expected_result: |
|
|
16
|
+
{"utm_source":"google","visits":4}
|
|
17
|
+
{"utm_source":"linkedin","visits":1}
|
|
18
|
+
{"utm_source":"twitter","visits":1}
|
|
19
|
+
{"utm_source":"newsletter","visits":1}
|
|
20
|
+
|
|
21
|
+
- name: Filtered by device - desktop
|
|
22
|
+
description: Filtered by device - desktop
|
|
23
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
|
|
24
|
+
expected_result: |
|
|
25
|
+
{"utm_source":"google","visits":6}
|
|
26
|
+
{"utm_source":"twitter","visits":3}
|
|
27
|
+
{"utm_source":"newsletter","visits":3}
|
|
28
|
+
{"utm_source":"linkedin","visits":2}
|
|
29
|
+
{"utm_source":"instagram","visits":1}
|
|
30
|
+
|
|
31
|
+
- name: Filtered by location - UK
|
|
32
|
+
description: Filtered by location - UK
|
|
33
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
|
|
34
|
+
expected_result: |
|
|
35
|
+
{"utm_source":"google","visits":3}
|
|
36
|
+
{"utm_source":"linkedin","visits":2}
|
|
37
|
+
{"utm_source":"newsletter","visits":2}
|
|
38
|
+
{"utm_source":"twitter","visits":1}
|
|
39
|
+
|
|
40
|
+
- name: Filtered by OS - Windows
|
|
41
|
+
description: Filtered by OS - Windows
|
|
42
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
|
|
43
|
+
expected_result: |
|
|
44
|
+
{"utm_source":"google","visits":5}
|
|
45
|
+
{"utm_source":"twitter","visits":3}
|
|
46
|
+
{"utm_source":"newsletter","visits":3}
|
|
47
|
+
{"utm_source":"linkedin","visits":2}
|
|
48
|
+
{"utm_source":"instagram","visits":1}
|
|
49
|
+
|
|
50
|
+
- name: Filtered by pathname - /about/
|
|
51
|
+
description: Filtered by pathname - /about/
|
|
52
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
|
|
53
|
+
expected_result: |
|
|
54
|
+
{"utm_source":"google","visits":4}
|
|
55
|
+
{"utm_source":"twitter","visits":2}
|
|
56
|
+
{"utm_source":"linkedin","visits":1}
|
|
57
|
+
{"utm_source":"newsletter","visits":1}
|
|
58
|
+
|
|
59
|
+
- name: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
|
|
60
|
+
description: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
|
|
61
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&post_uuid=06b1b0c9-fb53-4a15-a060-3db3fde7b1fc
|
|
62
|
+
expected_result: |
|
|
63
|
+
{"utm_source":"google","visits":4}
|
|
64
|
+
{"utm_source":"twitter","visits":2}
|
|
65
|
+
{"utm_source":"linkedin","visits":1}
|
|
66
|
+
{"utm_source":"newsletter","visits":1}
|
|
67
|
+
|
|
68
|
+
- name: Filtered by source - bing.com
|
|
69
|
+
description: Filtered by source - bing.com
|
|
70
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com
|
|
71
|
+
expected_result: |
|
|
72
|
+
{"utm_source":"twitter","visits":2}
|
|
73
|
+
|
|
74
|
+
- name: Filtered by member status - paid
|
|
75
|
+
description: Filtered by member status - paid
|
|
76
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=paid
|
|
77
|
+
expected_result: |
|
|
78
|
+
{"utm_source":"google","visits":2}
|
|
79
|
+
{"utm_source":"newsletter","visits":2}
|
|
80
|
+
{"utm_source":"twitter","visits":1}
|
|
81
|
+
|
|
82
|
+
- name: Filtered by member status - undefined
|
|
83
|
+
description: Filtered by member status - undefined
|
|
84
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=undefined
|
|
85
|
+
expected_result: |
|
|
86
|
+
{"utm_source":"linkedin","visits":3}
|
|
87
|
+
{"utm_source":"google","visits":1}
|
|
88
|
+
{"utm_source":"instagram","visits":1}
|
|
89
|
+
{"utm_source":"newsletter","visits":1}
|
|
90
|
+
|
|
91
|
+
- name: Filtered by timezone - America/Los_Angeles
|
|
92
|
+
description: Filtered by timezone - America/Los_Angeles
|
|
93
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=America/Los_Angeles
|
|
94
|
+
expected_result: |
|
|
95
|
+
{"utm_source":"google","visits":5}
|
|
96
|
+
{"utm_source":"newsletter","visits":3}
|
|
97
|
+
{"utm_source":"linkedin","visits":2}
|
|
98
|
+
{"utm_source":"twitter","visits":2}
|
|
99
|
+
{"utm_source":"instagram","visits":1}
|
|
100
|
+
|
|
101
|
+
- name: Test with multiple filters combined
|
|
102
|
+
description: Test with multiple filters combined
|
|
103
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
|
|
104
|
+
expected_result: |
|
|
105
|
+
{"utm_source":"newsletter","visits":2}
|
|
106
|
+
{"utm_source":"linkedin","visits":1}
|
|
107
|
+
{"utm_source":"twitter","visits":1}
|
|
108
|
+
{"utm_source":"instagram","visits":1}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
|
|
2
|
+
- name: Date range
|
|
3
|
+
description: All fixture data
|
|
4
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC
|
|
5
|
+
expected_result: |
|
|
6
|
+
{"utm_term":"ghost cms","visits":6}
|
|
7
|
+
{"utm_term":"blog software","visits":3}
|
|
8
|
+
{"utm_term":"content management","visits":3}
|
|
9
|
+
{"utm_term":"newsletter platform","visits":3}
|
|
10
|
+
{"utm_term":"membership site","visits":1}
|
|
11
|
+
|
|
12
|
+
- name: Filtered by browser - Chrome
|
|
13
|
+
description: Filtered by browser - Chrome
|
|
14
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
|
|
15
|
+
expected_result: |
|
|
16
|
+
{"utm_term":"ghost cms","visits":4}
|
|
17
|
+
{"utm_term":"blog software","visits":1}
|
|
18
|
+
{"utm_term":"content management","visits":1}
|
|
19
|
+
{"utm_term":"newsletter platform","visits":1}
|
|
20
|
+
|
|
21
|
+
- name: Filtered by device - desktop
|
|
22
|
+
description: Filtered by device - desktop
|
|
23
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
|
|
24
|
+
expected_result: |
|
|
25
|
+
{"utm_term":"ghost cms","visits":6}
|
|
26
|
+
{"utm_term":"blog software","visits":3}
|
|
27
|
+
{"utm_term":"content management","visits":3}
|
|
28
|
+
{"utm_term":"newsletter platform","visits":2}
|
|
29
|
+
{"utm_term":"membership site","visits":1}
|
|
30
|
+
|
|
31
|
+
- name: Filtered by location - UK
|
|
32
|
+
description: Filtered by location - UK
|
|
33
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
|
|
34
|
+
expected_result: |
|
|
35
|
+
{"utm_term":"ghost cms","visits":3}
|
|
36
|
+
{"utm_term":"blog software","visits":2}
|
|
37
|
+
{"utm_term":"newsletter platform","visits":2}
|
|
38
|
+
{"utm_term":"content management","visits":1}
|
|
39
|
+
|
|
40
|
+
- name: Filtered by OS - Windows
|
|
41
|
+
description: Filtered by OS - Windows
|
|
42
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
|
|
43
|
+
expected_result: |
|
|
44
|
+
{"utm_term":"ghost cms","visits":5}
|
|
45
|
+
{"utm_term":"blog software","visits":3}
|
|
46
|
+
{"utm_term":"content management","visits":3}
|
|
47
|
+
{"utm_term":"newsletter platform","visits":2}
|
|
48
|
+
{"utm_term":"membership site","visits":1}
|
|
49
|
+
|
|
50
|
+
- name: Filtered by pathname - /about/
|
|
51
|
+
description: Filtered by pathname - /about/
|
|
52
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
|
|
53
|
+
expected_result: |
|
|
54
|
+
{"utm_term":"ghost cms","visits":4}
|
|
55
|
+
{"utm_term":"content management","visits":2}
|
|
56
|
+
{"utm_term":"blog software","visits":1}
|
|
57
|
+
{"utm_term":"newsletter platform","visits":1}
|
|
58
|
+
|
|
59
|
+
- name: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
|
|
60
|
+
description: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
|
|
61
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&post_uuid=06b1b0c9-fb53-4a15-a060-3db3fde7b1fc
|
|
62
|
+
expected_result: |
|
|
63
|
+
{"utm_term":"ghost cms","visits":4}
|
|
64
|
+
{"utm_term":"content management","visits":2}
|
|
65
|
+
{"utm_term":"blog software","visits":1}
|
|
66
|
+
{"utm_term":"newsletter platform","visits":1}
|
|
67
|
+
|
|
68
|
+
- name: Filtered by source - bing.com
|
|
69
|
+
description: Filtered by source - bing.com
|
|
70
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com
|
|
71
|
+
expected_result: |
|
|
72
|
+
{"utm_term":"content management","visits":2}
|
|
73
|
+
|
|
74
|
+
- name: Filtered by member status - paid
|
|
75
|
+
description: Filtered by member status - paid
|
|
76
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=paid
|
|
77
|
+
expected_result: |
|
|
78
|
+
{"utm_term":"blog software","visits":2}
|
|
79
|
+
{"utm_term":"ghost cms","visits":2}
|
|
80
|
+
{"utm_term":"content management","visits":1}
|
|
81
|
+
|
|
82
|
+
- name: Filtered by member status - undefined
|
|
83
|
+
description: Filtered by member status - undefined
|
|
84
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=undefined
|
|
85
|
+
expected_result: |
|
|
86
|
+
{"utm_term":"newsletter platform","visits":3}
|
|
87
|
+
{"utm_term":"blog software","visits":1}
|
|
88
|
+
{"utm_term":"membership site","visits":1}
|
|
89
|
+
{"utm_term":"ghost cms","visits":1}
|
|
90
|
+
|
|
91
|
+
- name: Filtered by timezone - America/Los_Angeles
|
|
92
|
+
description: Filtered by timezone - America/Los_Angeles
|
|
93
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=America/Los_Angeles
|
|
94
|
+
expected_result: |
|
|
95
|
+
{"utm_term":"ghost cms","visits":5}
|
|
96
|
+
{"utm_term":"blog software","visits":3}
|
|
97
|
+
{"utm_term":"content management","visits":2}
|
|
98
|
+
{"utm_term":"newsletter platform","visits":2}
|
|
99
|
+
{"utm_term":"membership site","visits":1}
|
|
100
|
+
|
|
101
|
+
- name: Test with multiple filters combined
|
|
102
|
+
description: Test with multiple filters combined
|
|
103
|
+
parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
|
|
104
|
+
expected_result: |
|
|
105
|
+
{"utm_term":"blog software","visits":2}
|
|
106
|
+
{"utm_term":"membership site","visits":1}
|
|
107
|
+
{"utm_term":"content management","visits":1}
|
|
108
|
+
{"utm_term":"newsletter platform","visits":1}
|
|
@@ -12,14 +12,19 @@ const messages = {
|
|
|
12
12
|
* @typedef { string } URL
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} TokenValidateOptions
|
|
17
|
+
* @prop {string} [otcVerification] - "timestamp:hash" string used to verify an OTC-bound token
|
|
18
|
+
*/
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* @template T
|
|
17
22
|
* @template D
|
|
18
23
|
* @typedef {Object} TokenProvider<T, D>
|
|
19
24
|
* @prop {(data: D) => Promise<T>} create
|
|
20
|
-
* @prop {(token: T) => Promise<D>} validate
|
|
25
|
+
* @prop {(token: T, options?: TokenValidateOptions) => Promise<D>} validate
|
|
21
26
|
* @prop {(token: T) => Promise<string | null>} [getIdByToken]
|
|
22
|
-
* @prop {(
|
|
27
|
+
* @prop {(otcRef: string, tokenValue: T) => string} [deriveOTC]
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
/**
|
|
@@ -32,7 +37,7 @@ class MagicLink {
|
|
|
32
37
|
* @param {object} options
|
|
33
38
|
* @param {MailTransporter} options.transporter
|
|
34
39
|
* @param {TokenProvider<Token, TokenData>} options.tokenProvider
|
|
35
|
-
* @param {(token: Token, type: string, referrer?: string) => URL} options.getSigninURL
|
|
40
|
+
* @param {(token: Token, type: string, referrer?: string, otcVerification?: string) => URL} options.getSigninURL
|
|
36
41
|
* @param {typeof defaultGetText} [options.getText]
|
|
37
42
|
* @param {typeof defaultGetHTML} [options.getHTML]
|
|
38
43
|
* @param {typeof defaultGetSubject} [options.getSubject]
|
|
@@ -62,7 +67,7 @@ class MagicLink {
|
|
|
62
67
|
* @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
|
|
63
68
|
* @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
|
|
64
69
|
* @param {boolean} [options.includeOTC=false] - Whether to send a one-time-code in the email.
|
|
65
|
-
* @returns {Promise<{token: Token,
|
|
70
|
+
* @returns {Promise<{token: Token, otcRef: string | null, info: SentMessageInfo}>}
|
|
66
71
|
*/
|
|
67
72
|
async sendMagicLink(options) {
|
|
68
73
|
this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});
|
|
@@ -96,21 +101,21 @@ class MagicLink {
|
|
|
96
101
|
html: this.getHTML(url, type, options.email, otc)
|
|
97
102
|
});
|
|
98
103
|
|
|
99
|
-
// return
|
|
104
|
+
// return otcRef so we can pass it as a reference to the client so it
|
|
100
105
|
// can pass it back as a reference when verifying the OTC. We only do
|
|
101
106
|
// this if we've successfully generated an OTC to avoid clients showing
|
|
102
107
|
// a token input field when the email doesn't contain an OTC
|
|
103
|
-
let
|
|
108
|
+
let otcRef = null;
|
|
104
109
|
if (this.labsService?.isSet('membersSigninOTC') && otc) {
|
|
105
110
|
try {
|
|
106
|
-
|
|
111
|
+
otcRef = await this.getIdFromToken(token);
|
|
107
112
|
} catch (err) {
|
|
108
113
|
this.sentry?.captureException?.(err);
|
|
109
|
-
|
|
114
|
+
otcRef = null;
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
return {token,
|
|
118
|
+
return {token, otcRef, info};
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/**
|
|
@@ -165,10 +170,11 @@ class MagicLink {
|
|
|
165
170
|
* getDataFromToken
|
|
166
171
|
*
|
|
167
172
|
* @param {Token} token - The token to decode
|
|
173
|
+
* @param {string} [otcVerification] - Optional "timestamp:hash" to bind token usage to an OTC verification window
|
|
168
174
|
* @returns {Promise<TokenData>} data - The data object associated with the magic link
|
|
169
175
|
*/
|
|
170
|
-
async getDataFromToken(token) {
|
|
171
|
-
const tokenData = await this.tokenProvider.validate(token);
|
|
176
|
+
async getDataFromToken(token, otcVerification) {
|
|
177
|
+
const tokenData = await this.tokenProvider.validate(token, {otcVerification});
|
|
172
178
|
return tokenData;
|
|
173
179
|
}
|
|
174
180
|
}
|
|
@@ -82,7 +82,14 @@ class MembersConfigProvider {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} token
|
|
87
|
+
* @param {string} type - also known as "action", e.g. "signin" or "signup"
|
|
88
|
+
* @param {string} [referrer] - optional URL for redirecting to after signin
|
|
89
|
+
* @param {string} [otcVerification] - optional for verifying an OTC signin redirect
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
getSigninURL(token, type, referrer, otcVerification) {
|
|
86
93
|
const siteUrl = this._urlUtils.urlFor({relativeUrl: '/members/'}, true);
|
|
87
94
|
const signinURL = new URL(siteUrl);
|
|
88
95
|
signinURL.searchParams.set('token', token);
|
|
@@ -90,6 +97,9 @@ class MembersConfigProvider {
|
|
|
90
97
|
if (referrer) {
|
|
91
98
|
signinURL.searchParams.set('r', referrer);
|
|
92
99
|
}
|
|
100
|
+
if (otcVerification) {
|
|
101
|
+
signinURL.searchParams.set('otc_verification', otcVerification);
|
|
102
|
+
}
|
|
93
103
|
return signinURL.toString();
|
|
94
104
|
}
|
|
95
105
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
const {ValidationError} = require('@tryghost/errors');
|
|
3
|
+
const tpl = require('@tryghost/tpl');
|
|
3
4
|
const crypto = require('node:crypto');
|
|
4
5
|
const {hotp} = require('otplib');
|
|
5
6
|
|
|
7
|
+
const messages = {
|
|
8
|
+
OTC_SECRET_NOT_CONFIGURED: 'OTC secret not configured',
|
|
9
|
+
INVALID_OTC_VERIFICATION_HASH: 'Invalid OTC verification hash',
|
|
10
|
+
INVALID_TOKEN: 'Invalid token provided',
|
|
11
|
+
TOKEN_EXPIRED: 'Token expired',
|
|
12
|
+
DERIVE_OTC_MISSING_INPUT: 'tokenId and tokenValue are required'
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
class SingleUseTokenProvider {
|
|
7
16
|
/**
|
|
8
17
|
* @param {Object} dependencies
|
|
@@ -43,6 +52,9 @@ class SingleUseTokenProvider {
|
|
|
43
52
|
* If the token is invalid the returned Promise will reject.
|
|
44
53
|
*
|
|
45
54
|
* @param {string} token
|
|
55
|
+
* @param {Object} [options] - Optional configuration object
|
|
56
|
+
* @param {Object} [options.transacting] - Database transaction object
|
|
57
|
+
* @param {string} [options.otcVerification] - OTC verification hash for additional validation
|
|
46
58
|
*
|
|
47
59
|
* @returns {Promise<Object<string, any>>}
|
|
48
60
|
*/
|
|
@@ -56,17 +68,29 @@ class SingleUseTokenProvider {
|
|
|
56
68
|
});
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
if (options.otcVerification) {
|
|
72
|
+
const isValidOTCVerification = await this._validateOTCVerificationHash(options.otcVerification, token);
|
|
73
|
+
if (!isValidOTCVerification) {
|
|
74
|
+
throw new ValidationError({
|
|
75
|
+
message: tpl(messages.INVALID_OTC_VERIFICATION_HASH),
|
|
76
|
+
code: 'INVALID_OTC_VERIFICATION_HASH'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
|
|
60
82
|
|
|
61
83
|
if (!model) {
|
|
62
84
|
throw new ValidationError({
|
|
63
|
-
message:
|
|
85
|
+
message: tpl(messages.INVALID_TOKEN),
|
|
86
|
+
code: 'INVALID_TOKEN'
|
|
64
87
|
});
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
if (model.get('used_count') >= this.maxUsageCount) {
|
|
68
91
|
throw new ValidationError({
|
|
69
|
-
message:
|
|
92
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
93
|
+
code: 'TOKEN_EXPIRED'
|
|
70
94
|
});
|
|
71
95
|
}
|
|
72
96
|
|
|
@@ -79,7 +103,8 @@ class SingleUseTokenProvider {
|
|
|
79
103
|
|
|
80
104
|
if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
|
|
81
105
|
throw new ValidationError({
|
|
82
|
-
message:
|
|
106
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
107
|
+
code: 'TOKEN_EXPIRED'
|
|
83
108
|
});
|
|
84
109
|
}
|
|
85
110
|
}
|
|
@@ -87,7 +112,8 @@ class SingleUseTokenProvider {
|
|
|
87
112
|
|
|
88
113
|
if (tokenLifetimeMilliseconds > this.validityPeriod) {
|
|
89
114
|
throw new ValidationError({
|
|
90
|
-
message:
|
|
115
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
116
|
+
code: 'TOKEN_EXPIRED'
|
|
91
117
|
});
|
|
92
118
|
}
|
|
93
119
|
|
|
@@ -137,13 +163,15 @@ class SingleUseTokenProvider {
|
|
|
137
163
|
deriveOTC(tokenId, tokenValue) {
|
|
138
164
|
if (!this.secret) {
|
|
139
165
|
throw new ValidationError({
|
|
140
|
-
message:
|
|
166
|
+
message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
|
|
167
|
+
code: 'OTC_SECRET_NOT_CONFIGURED'
|
|
141
168
|
});
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
if (!tokenId || !tokenValue) {
|
|
145
172
|
throw new ValidationError({
|
|
146
|
-
message:
|
|
173
|
+
message: tpl(messages.DERIVE_OTC_MISSING_INPUT),
|
|
174
|
+
code: 'DERIVE_OTC_MISSING_INPUT'
|
|
147
175
|
});
|
|
148
176
|
}
|
|
149
177
|
|
|
@@ -151,6 +179,34 @@ class SingleUseTokenProvider {
|
|
|
151
179
|
return hotp.generate(this.secret, counter);
|
|
152
180
|
}
|
|
153
181
|
|
|
182
|
+
/**
|
|
183
|
+
* @method verifyOTC
|
|
184
|
+
* Verifies an OTC (one-time code) by looking up the token and performing HOTP verification
|
|
185
|
+
*
|
|
186
|
+
* @param {string} otcRef - Reference for the one-time code
|
|
187
|
+
* @param {string} otc - The one-time code to verify
|
|
188
|
+
* @returns {Promise<boolean>} Returns true if the OTC is valid, false otherwise
|
|
189
|
+
*/
|
|
190
|
+
async verifyOTC(otcRef, otc) {
|
|
191
|
+
if (!this.secret || !otcRef || !otc) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const model = await this.model.findOne({id: otcRef});
|
|
197
|
+
|
|
198
|
+
if (!model) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const tokenValue = model.get('token');
|
|
203
|
+
const counter = this.deriveCounter(otcRef, tokenValue);
|
|
204
|
+
return hotp.verify({token: otc, secret: this.secret, counter});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
154
210
|
/**
|
|
155
211
|
* @method getIdByToken
|
|
156
212
|
* Retrieves the ID associated with a given token.
|
|
@@ -166,6 +222,103 @@ class SingleUseTokenProvider {
|
|
|
166
222
|
return null;
|
|
167
223
|
}
|
|
168
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @method getTokenByRef
|
|
228
|
+
* Retrieves the token associated with a given reference.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} ref - The reference to look up.
|
|
231
|
+
* @returns {Promise<string|null>} The token if found, or null if not found or on error.
|
|
232
|
+
*/
|
|
233
|
+
async getTokenByRef(ref) {
|
|
234
|
+
try {
|
|
235
|
+
const model = await this.model.findOne({id: ref});
|
|
236
|
+
return model ? model.get('token') : null;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @method createOTCVerificationHash
|
|
244
|
+
* Creates an OTC verification hash for a given token and one-time code.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} otc - The one-time code
|
|
247
|
+
* @param {string} token - The token value
|
|
248
|
+
* @param {number} [timestamp] - Optional timestamp to use for the hash, defaults to current time
|
|
249
|
+
* @returns {string} The OTC verification hash
|
|
250
|
+
*/
|
|
251
|
+
createOTCVerificationHash(otc, token, timestamp) {
|
|
252
|
+
if (!this.secret) {
|
|
253
|
+
throw new ValidationError({
|
|
254
|
+
message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
|
|
255
|
+
code: 'OTC_SECRET_NOT_CONFIGURED'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// timestamp allows us to restrict the hash's lifetime window
|
|
260
|
+
timestamp ??= Math.floor(Date.now() / 1000);
|
|
261
|
+
|
|
262
|
+
const dataToHash = `${otc}:${token}:${timestamp}`;
|
|
263
|
+
|
|
264
|
+
const secret = Buffer.from(this.secret, 'hex');
|
|
265
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
266
|
+
hmac.update(dataToHash);
|
|
267
|
+
|
|
268
|
+
return hmac.digest('hex');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @private
|
|
273
|
+
* @method _validateOTCVerificationHash
|
|
274
|
+
* Validates OTC verification hash by recreating and comparing the hash.
|
|
275
|
+
* Private because it's only used internally by the public validate method.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} otcVerificationHash - The hash to validate (timestamp:hash format)
|
|
278
|
+
* @param {string} token - The token value
|
|
279
|
+
* @returns {Promise<boolean>} - True if hash is valid, false otherwise
|
|
280
|
+
*/
|
|
281
|
+
async _validateOTCVerificationHash(otcVerificationHash, token) {
|
|
282
|
+
try {
|
|
283
|
+
if (!this.secret || !otcVerificationHash || !token) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Parse timestamp:hash format
|
|
288
|
+
const parts = otcVerificationHash.split(':');
|
|
289
|
+
if (parts.length !== 2) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const timestamp = parseInt(parts[0]);
|
|
294
|
+
const providedHash = parts[1];
|
|
295
|
+
|
|
296
|
+
// Check if hash is expired (5 minute window)
|
|
297
|
+
const now = Math.floor(Date.now() / 1000);
|
|
298
|
+
const maxAge = 5 * 60; // 5 minutes in seconds
|
|
299
|
+
if (now - timestamp > maxAge) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const tokenId = await this.getIdByToken(token);
|
|
304
|
+
if (!tokenId) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Derive the original OTC that was used to create this hash
|
|
309
|
+
const otc = this.deriveOTC(tokenId, token);
|
|
310
|
+
|
|
311
|
+
const expectedHash = this.createOTCVerificationHash(otc, token, timestamp);
|
|
312
|
+
|
|
313
|
+
// Compare the hashes using constant-time comparison to prevent timing attacks
|
|
314
|
+
return crypto.timingSafeEqual(
|
|
315
|
+
Buffer.from(providedHash, 'hex'),
|
|
316
|
+
Buffer.from(expectedHash, 'hex')
|
|
317
|
+
);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
169
322
|
}
|
|
170
323
|
|
|
171
324
|
module.exports = SingleUseTokenProvider;
|
|
@@ -90,7 +90,7 @@ function createApiInstance(config) {
|
|
|
90
90
|
case 'signin':
|
|
91
91
|
default:
|
|
92
92
|
if (otc) {
|
|
93
|
-
return `🔑 ${t('
|
|
93
|
+
return `🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}`;
|
|
94
94
|
} else {
|
|
95
95
|
return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
|
|
96
96
|
}
|
|
@@ -6,7 +6,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
|
|
|
6
6
|
<meta name="viewport" content="width=device-width">
|
|
7
7
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
8
8
|
${otc
|
|
9
|
-
? `<title>🔑 ${t('
|
|
9
|
+
? `<title>🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}</title>`
|
|
10
10
|
: `<title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
|
|
11
11
|
}
|
|
12
12
|
<style>
|
|
@@ -111,7 +111,11 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
|
|
|
111
111
|
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
|
|
112
112
|
|
|
113
113
|
<!-- START CENTERED CONTAINER -->
|
|
114
|
-
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
|
|
114
|
+
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${otc ? t('Welcome back to {siteTitle}! Your verification code is {otc}.', {siteTitle, otc, interpolation: {escapeValue: false}}) : t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
|
|
115
|
+
<!-- SPACING FOR PREVIEW TEXT -->
|
|
116
|
+
<div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
|
|
117
|
+
${'‌ '.repeat(75)}
|
|
118
|
+
</div>
|
|
115
119
|
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
|
|
116
120
|
|
|
117
121
|
<!-- START MAIN CONTENT AREA -->
|
|
@@ -122,7 +126,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
|
|
|
122
126
|
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
|
|
123
127
|
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 24px; margin: 0; margin-bottom: 15px;">${t('Hey there,')}</p>
|
|
124
128
|
${otc ?
|
|
125
|
-
`<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t(`Welcome back! Here's your code to sign in to {siteTitle}. For your security, it's valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
|
|
129
|
+
`<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t(`Welcome back! Here's your code to sign in to {siteTitle}. For your security, it's only valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
|
|
126
130
|
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box; margin-bottom: 32px;">
|
|
127
131
|
<tbody>
|
|
128
132
|
<tr>
|
|
@@ -132,7 +136,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
|
|
|
132
136
|
</tr>
|
|
133
137
|
</tbody>
|
|
134
138
|
</table>
|
|
135
|
-
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t('Or, skip the code and
|
|
139
|
+
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t('Or, skip the code and sign in directly')}:</p>
|
|
136
140
|
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
|
137
141
|
<tbody>
|
|
138
142
|
<tr>
|
|
@@ -148,7 +152,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
|
|
|
148
152
|
</tr>
|
|
149
153
|
</tbody>
|
|
150
154
|
</table>
|
|
151
|
-
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('
|
|
155
|
+
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p>`
|
|
152
156
|
:
|
|
153
157
|
`<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 32px;">${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}</p>
|
|
154
158
|
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|