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.
Files changed (53) hide show
  1. package/components/tryghost-i18n-6.0.8.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
  3. package/core/built/admin/assets/admin-x-activitypub/{index-BRzGrD-C.mjs → index-1EXYCtPI.mjs} +26876 -22657
  4. package/core/built/admin/assets/admin-x-activitypub/{index-Co80faUx.mjs → index-If44c6h0.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-B4W7CQcA.mjs → CodeEditorView-CzXlGImM.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{index-Bmm3Xeuw.mjs → index-BgCSf8S1.mjs} +4 -5
  8. package/core/built/admin/assets/admin-x-settings/{index-CuwMM9FM.mjs → index-D2pIApbM.mjs} +26 -11
  9. package/core/built/admin/assets/admin-x-settings/{index-jv9DN3ZO.mjs → index-RKA3H0Lh.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{modals-CUGEPPYA.mjs → modals-D0f6kxWg.mjs} +2 -2
  11. package/core/built/admin/assets/{chunk.524.56bb70d3e8660d34aef1.js → chunk.524.099dcd3975a0e60c5579.js} +6 -6
  12. package/core/built/admin/assets/{chunk.582.ae0341229e71a85d0b2d.js → chunk.582.6830378a89a17aeedd0b.js} +8 -8
  13. package/core/built/admin/assets/{ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js → ghost-138bb4718f8b9d666bdd7a2b45330d58.js} +34 -33
  14. package/core/built/admin/assets/{ghost-2c537ee89c36199137eafc1768fd7de8.css → ghost-a7a53bf80dc45c37ae9c174a0d02a882.css} +1 -1
  15. package/core/built/admin/assets/{ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css → ghost-dark-6e0062029f988d8676e87f22d8e7f4a3.css} +1 -1
  16. package/core/built/admin/assets/posts/posts.js +83336 -82274
  17. package/core/built/admin/assets/stats/stats.js +26957 -26799
  18. package/core/built/admin/index.html +4 -4
  19. package/core/frontend/helpers/ghost_head.js +9 -9
  20. package/core/frontend/public/ghost-stats.min.js +3 -3
  21. package/core/frontend/public/member-attribution.min.js +1 -1
  22. package/core/frontend/src/ghost-stats/ghost-stats.js +18 -4
  23. package/core/frontend/src/member-attribution/member-attribution.js +28 -18
  24. package/core/frontend/src/utils/url-attribution.js +53 -40
  25. package/core/server/api/endpoints/utils/serializers/input/posts.js +7 -5
  26. package/core/server/data/tinybird/endpoints/api_top_utm_campaigns.pipe +31 -0
  27. package/core/server/data/tinybird/endpoints/api_top_utm_contents.pipe +31 -0
  28. package/core/server/data/tinybird/endpoints/api_top_utm_mediums.pipe +31 -0
  29. package/core/server/data/tinybird/endpoints/api_top_utm_sources.pipe +31 -0
  30. package/core/server/data/tinybird/endpoints/api_top_utm_terms.pipe +31 -0
  31. package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +108 -0
  32. package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +108 -0
  33. package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +108 -0
  34. package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +108 -0
  35. package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +108 -0
  36. package/core/server/services/lib/magic-link/MagicLink.js +17 -11
  37. package/core/server/services/members/MembersConfigProvider.js +11 -1
  38. package/core/server/services/members/SingleUseTokenProvider.js +159 -6
  39. package/core/server/services/members/api.js +1 -1
  40. package/core/server/services/members/emails/signin.js +9 -5
  41. package/core/server/services/members/members-api/controllers/RouterController.js +95 -19
  42. package/core/server/services/members/members-api/members-api.js +8 -4
  43. package/core/server/services/members/members-ssr.js +5 -3
  44. package/core/server/services/members/middleware.js +2 -2
  45. package/core/server/services/tinybird/TinybirdService.js +6 -1
  46. package/core/server/services/update-check/UpdateCheckService.js +1 -1
  47. package/core/server/web/members/app.js +12 -3
  48. package/core/shared/config/defaults.json +1 -1
  49. package/core/shared/labs.js +3 -1
  50. package/package.json +7 -7
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/yarn.lock +380 -104
  53. 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 {(tokenId: string, tokenValue: T) => string} [deriveOTC]
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, tokenId: string | null, info: SentMessageInfo}>}
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 tokenId so we can pass it as a reference to the client so it
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 tokenId = null;
108
+ let otcRef = null;
104
109
  if (this.labsService?.isSet('membersSigninOTC') && otc) {
105
110
  try {
106
- tokenId = await this.getIdFromToken(token);
111
+ otcRef = await this.getIdFromToken(token);
107
112
  } catch (err) {
108
113
  this.sentry?.captureException?.(err);
109
- tokenId = null;
114
+ otcRef = null;
110
115
  }
111
116
  }
112
117
 
113
- return {token, tokenId, info};
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
- getSigninURL(token, type, referrer) {
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: 'Invalid token provided'
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: 'Token expired'
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: 'Token expired'
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: 'Token expired'
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: 'Cannot derive OTC: secret not configured'
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: 'Cannot derive OTC: tokenId and tokenValue are required'
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('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}`;
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('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
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
+ ${'&zwnj;&nbsp;'.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 log in directly')}:</p>
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('Alternatively you can copy and paste this URL to your browser')}:</p>`
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;">