ghost 6.0.5 → 6.0.6

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 (25) hide show
  1. package/components/tryghost-i18n-6.0.6.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
  3. package/core/built/admin/assets/admin-x-activitypub/{index-DBxGycCG.mjs → index-BRzGrD-C.mjs} +7465 -7424
  4. package/core/built/admin/assets/admin-x-activitypub/{index-Db9aLAi4.mjs → index-Co80faUx.mjs} +2 -2
  5. package/core/built/admin/assets/{chunk.524.0e2bff9b664f925d7af7.js → chunk.524.56bb70d3e8660d34aef1.js} +5 -5
  6. package/core/built/admin/assets/{chunk.582.a8f6c436bbec6f9ba678.js → chunk.582.ae0341229e71a85d0b2d.js} +9 -9
  7. package/core/built/admin/assets/{ghost-2b02de85a93ec9a5623180b373cece35.js → ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js} +31 -28
  8. package/core/built/admin/assets/posts/posts.js +23734 -23709
  9. package/core/built/admin/assets/stats/stats.js +22831 -22812
  10. package/core/built/admin/index.html +3 -3
  11. package/core/server/data/tinybird/datasources/analytics_events.datasource +3 -2
  12. package/core/server/data/tinybird/datasources/analytics_events_test.datasource +3 -2
  13. package/core/server/data/tinybird/scripts/configure-ghost.sh +4 -2
  14. package/core/server/services/lib/magic-link/MagicLink.js +88 -8
  15. package/core/server/services/members/SingleUseTokenProvider.js +61 -1
  16. package/core/server/services/members/api.js +18 -8
  17. package/core/server/services/members/emails/signin.js +38 -4
  18. package/core/server/services/members/members-api/controllers/RouterController.js +14 -3
  19. package/core/server/services/members/members-api/members-api.js +4 -3
  20. package/core/server/services/stats/PostsStatsService.js +26 -9
  21. package/core/server/services/stats/StatsService.js +1 -1
  22. package/package.json +7 -7
  23. package/tsconfig.tsbuildinfo +1 -1
  24. package/yarn.lock +172 -276
  25. package/components/tryghost-i18n-6.0.5.tgz +0 -0
@@ -6,7 +6,7 @@
6
6
  <title>Ghost</title>
7
7
 
8
8
 
9
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.0%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2269d97c1a9c%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%2214e7fdfed0%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%22e3419af21a%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%22b3af6b976f%22%2C%22adminXActivitypubCustomUrl%22%3A%22https%3A%2F%2Fcdn.jsdelivr.net%2Fghost%2Fadmin-x-activitypub%400%2Fdist%2Fadmin-x-activitypub.js%22%7D" />
9
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.0%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2269d97c1a9c%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%222845591bec%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%22d90bc80047%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%220d19030cfd%22%2C%22adminXActivitypubCustomUrl%22%3A%22https%3A%2F%2Fcdn.jsdelivr.net%2Fghost%2Fadmin-x-activitypub%400%2Fdist%2Fadmin-x-activitypub.js%22%2C%22adminXActivitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
10
10
 
11
11
  <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
12
12
  <meta name="pinterest" content="nopin" />
@@ -49,7 +49,7 @@
49
49
 
50
50
  <script src="assets/vendor-aed0068cf9b67d042dd23a6343545b7b.js"></script>
51
51
  <script src="assets/chunk.397.1904882a4a78e2922f07.js"></script>
52
- <script src="assets/chunk.524.0e2bff9b664f925d7af7.js"></script>
53
- <script src="assets/ghost-2b02de85a93ec9a5623180b373cece35.js"></script>
52
+ <script src="assets/chunk.524.56bb70d3e8660d34aef1.js"></script>
53
+ <script src="assets/ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js"></script>
54
54
  </body>
55
55
  </html>
@@ -7,11 +7,12 @@ SCHEMA >
7
7
  `action` LowCardinality(String) `json:$.action`,
8
8
  `version` LowCardinality(String) `json:$.version`,
9
9
  `payload` String `json:$.payload`,
10
- `site_uuid` LowCardinality(String) `json:$.payload.site_uuid`
10
+ `site_uuid` LowCardinality(String) `json:$.payload.site_uuid`,
11
+ `inserted_at` DateTime64(3) DEFAULT now64() `json:$.inserted_at`
11
12
 
12
13
  ENGINE "MergeTree"
13
14
  ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
14
15
  ENGINE_SORTING_KEY "site_uuid, timestamp"
15
16
 
16
17
  FORWARD_QUERY >
17
- SELECT timestamp, session_id, action, version, toString(payload) as payload, site_uuid
18
+ SELECT timestamp, session_id, action, version, toString(payload) as payload, site_uuid, defaultValueOfTypeName('DateTime64(3)') AS inserted_at
@@ -9,11 +9,12 @@ SCHEMA >
9
9
  `action` LowCardinality(String) `json:$.action`,
10
10
  `version` LowCardinality(String) `json:$.version`,
11
11
  `payload` String `json:$.payload`,
12
- `site_uuid` LowCardinality(String) `json:$.payload.site_uuid`
12
+ `site_uuid` LowCardinality(String) `json:$.payload.site_uuid`,
13
+ `inserted_at` DateTime64(3) DEFAULT now64() `json:$.inserted_at`
13
14
 
14
15
  ENGINE "MergeTree"
15
16
  ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
16
17
  ENGINE_SORTING_KEY "site_uuid, timestamp"
17
18
 
18
19
  FORWARD_QUERY >
19
- SELECT timestamp, session_id, action, version, toString(payload) as payload, site_uuid
20
+ SELECT timestamp, session_id, action, version, toString(payload) as payload, site_uuid, defaultValueOfTypeName('DateTime64(3)') AS inserted_at
@@ -55,11 +55,13 @@ export TINYBIRD_TRACKER_TOKEN="$TRACKER_TOKEN"
55
55
  export tinybird__adminToken="$TINYBIRD_ADMIN_TOKEN"
56
56
  export tinybird__workspaceId="$TINYBIRD_WORKSPACE_ID"
57
57
  export tinybird__stats__endpoint="http://localhost:7181"
58
+ export tinybird__tracker__endpoint="http://localhost:3000/api/v1/page_hit"
58
59
 
59
60
  # If running in GitHub Actions, also export to GITHUB_ENV
60
61
  if [ -n "$GITHUB_ENV" ]; then
61
62
  echo "tinybird__adminToken=$TINYBIRD_ADMIN_TOKEN" >> $GITHUB_ENV
62
63
  echo "tinybird__workspaceId=$TINYBIRD_WORKSPACE_ID" >> $GITHUB_ENV
63
- echo "tinybird__stats__endpoint=http://localhost:7181" >> $GITHUB_ENV
64
+ echo "tinybird__stats__endpoint=$tinybird__stats__endpoint" >> $GITHUB_ENV
64
65
  echo "TINYBIRD_TRACKER_TOKEN=$TINYBIRD_TRACKER_TOKEN" >> $GITHUB_ENV
65
- fi
66
+ echo "tinybird__tracker__endpoint=$tinybird__tracker__endpoint" >> $GITHUB_ENV
67
+ fi
@@ -1,6 +1,7 @@
1
1
  const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors');
2
2
  const {isEmail} = require('@tryghost/validator');
3
3
  const tpl = require('@tryghost/tpl');
4
+
4
5
  const messages = {
5
6
  invalidEmail: 'Email is not valid'
6
7
  };
@@ -17,6 +18,8 @@ const messages = {
17
18
  * @typedef {Object} TokenProvider<T, D>
18
19
  * @prop {(data: D) => Promise<T>} create
19
20
  * @prop {(token: T) => Promise<D>} validate
21
+ * @prop {(token: T) => Promise<string | null>} [getIdByToken]
22
+ * @prop {(tokenId: string, tokenValue: T) => string} [deriveOTC]
20
23
  */
21
24
 
22
25
  /**
@@ -34,6 +37,7 @@ class MagicLink {
34
37
  * @param {typeof defaultGetHTML} [options.getHTML]
35
38
  * @param {typeof defaultGetSubject} [options.getSubject]
36
39
  * @param {object} [options.sentry]
40
+ * @param {{isSet(name: string): boolean}} [options.labsService]
37
41
  */
38
42
  constructor(options) {
39
43
  if (!options || !options.transporter || !options.tokenProvider || !options.getSigninURL) {
@@ -46,6 +50,7 @@ class MagicLink {
46
50
  this.getHTML = options.getHTML || defaultGetHTML;
47
51
  this.getSubject = options.getSubject || defaultGetSubject;
48
52
  this.sentry = options.sentry || undefined;
53
+ this.labsService = options.labsService || undefined;
49
54
  }
50
55
 
51
56
  /**
@@ -56,7 +61,8 @@ class MagicLink {
56
61
  * @param {TokenData} options.tokenData - The data for token
57
62
  * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
58
63
  * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
59
- * @returns {Promise<{token: Token, info: SentMessageInfo}>}
64
+ * @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}>}
60
66
  */
61
67
  async sendMagicLink(options) {
62
68
  this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});
@@ -73,14 +79,38 @@ class MagicLink {
73
79
 
74
80
  const url = this.getSigninURL(token, type, options.referrer);
75
81
 
82
+ let otc = null;
83
+ if (this.labsService?.isSet('membersSigninOTC') && options.includeOTC) {
84
+ try {
85
+ otc = await this.getOTCFromToken(token);
86
+ } catch (err) {
87
+ this.sentry?.captureException?.(err);
88
+ otc = null;
89
+ }
90
+ }
91
+
76
92
  const info = await this.transporter.sendMail({
77
93
  to: options.email,
78
- subject: this.getSubject(type),
79
- text: this.getText(url, type, options.email),
80
- html: this.getHTML(url, type, options.email)
94
+ subject: this.getSubject(type, otc),
95
+ text: this.getText(url, type, options.email, otc),
96
+ html: this.getHTML(url, type, options.email, otc)
81
97
  });
82
98
 
83
- return {token, info};
99
+ // return tokenId so we can pass it as a reference to the client so it
100
+ // can pass it back as a reference when verifying the OTC. We only do
101
+ // this if we've successfully generated an OTC to avoid clients showing
102
+ // a token input field when the email doesn't contain an OTC
103
+ let tokenId = null;
104
+ if (this.labsService?.isSet('membersSigninOTC') && otc) {
105
+ try {
106
+ tokenId = await this.getIdFromToken(token);
107
+ } catch (err) {
108
+ this.sentry?.captureException?.(err);
109
+ tokenId = null;
110
+ }
111
+ }
112
+
113
+ return {token, tokenId, info};
84
114
  }
85
115
 
86
116
  /**
@@ -99,6 +129,38 @@ class MagicLink {
99
129
  return this.getSigninURL(token, type, options.referrer);
100
130
  }
101
131
 
132
+ /**
133
+ * getIdFromToken
134
+ *
135
+ * @param {Token} token - The token to get the id from
136
+ * @returns {Promise<string|null>} id - The id of the token
137
+ */
138
+ async getIdFromToken(token) {
139
+ if (typeof this.tokenProvider.getIdByToken !== 'function') {
140
+ return null;
141
+ }
142
+
143
+ const id = await this.tokenProvider.getIdByToken(token);
144
+ return id;
145
+ }
146
+
147
+ /**
148
+ * getOTCFromToken
149
+ *
150
+ * @param {Token} token - The token to get the otc from
151
+ * @returns {Promise<string|null>} otc - The otc of the token
152
+ */
153
+ async getOTCFromToken(token) {
154
+ const tokenId = await this.getIdFromToken(token);
155
+
156
+ if (!tokenId || typeof this.tokenProvider.deriveOTC !== 'function') {
157
+ return null;
158
+ }
159
+
160
+ const otc = await this.tokenProvider.deriveOTC(tokenId, token);
161
+ return otc;
162
+ }
163
+
102
164
  /**
103
165
  * getDataFromToken
104
166
  *
@@ -117,13 +179,19 @@ class MagicLink {
117
179
  * @param {URL} url - The url which will trigger sign in flow
118
180
  * @param {string} type - The type of email to send e.g. signin, signup
119
181
  * @param {string} email - The recipient of the email to send
182
+ * @param {string} otc - Optional one-time-code
120
183
  * @returns {string} text - The text content of an email to send
121
184
  */
122
- function defaultGetText(url, type, email) {
185
+ function defaultGetText(url, type, email, otc) {
123
186
  let msg = 'sign in';
124
187
  if (type === 'signup') {
125
188
  msg = 'confirm your email address';
126
189
  }
190
+
191
+ if (otc) {
192
+ return `Enter the code ${otc} or click here to ${msg} ${url}. This msg was sent to ${email}`;
193
+ }
194
+
127
195
  return `Click here to ${msg} ${url}. This msg was sent to ${email}`;
128
196
  }
129
197
 
@@ -133,13 +201,19 @@ function defaultGetText(url, type, email) {
133
201
  * @param {URL} url - The url which will trigger sign in flow
134
202
  * @param {string} type - The type of email to send e.g. signin, signup
135
203
  * @param {string} email - The recipient of the email to send
204
+ * @param {string} otc - Optional one-time-code
136
205
  * @returns {string} HTML - The HTML content of an email to send
137
206
  */
138
- function defaultGetHTML(url, type, email) {
207
+ function defaultGetHTML(url, type, email, otc) {
139
208
  let msg = 'sign in';
140
209
  if (type === 'signup') {
141
210
  msg = 'confirm your email address';
142
211
  }
212
+
213
+ if (otc) {
214
+ return `Enter the code ${otc} or <a href="${url}">click here to ${msg}</a> This msg was sent to ${email}`;
215
+ }
216
+
143
217
  return `<a href="${url}">Click here to ${msg}</a> This msg was sent to ${email}`;
144
218
  }
145
219
 
@@ -147,12 +221,18 @@ function defaultGetHTML(url, type, email) {
147
221
  * defaultGetSubject
148
222
  *
149
223
  * @param {string} type - The type of email to send e.g. signin, signup
224
+ * @param {string} otc - Optional one-time-code
150
225
  * @returns {string} subject - The subject of an email to send
151
226
  */
152
- function defaultGetSubject(type) {
227
+ function defaultGetSubject(type, otc) {
153
228
  if (type === 'signup') {
154
229
  return `Signup!`;
155
230
  }
231
+
232
+ if (otc) {
233
+ return `Your signin verification code is ${otc}`;
234
+ }
235
+
156
236
  return `Signin!`;
157
237
  }
158
238
 
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
  const {ValidationError} = require('@tryghost/errors');
3
+ const crypto = require('node:crypto');
4
+ const {hotp} = require('otplib');
3
5
 
4
6
  class SingleUseTokenProvider {
5
7
  /**
@@ -8,12 +10,14 @@ class SingleUseTokenProvider {
8
10
  * @param {number} dependencies.validityPeriod - How long a token is valid for from it's creation in milliseconds.
9
11
  * @param {number} dependencies.validityPeriodAfterUsage - How long a token is valid after first usage, in milliseconds.
10
12
  * @param {number} dependencies.maxUsageCount - How many times a token can be used.
13
+ * @param {string} [dependencies.secret] - Secret for generating and verifying OTP codes.
11
14
  */
12
- constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount}) {
15
+ constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount, secret}) {
13
16
  this.model = SingleUseTokenModel;
14
17
  this.validityPeriod = validityPeriod;
15
18
  this.validityPeriodAfterUsage = validityPeriodAfterUsage;
16
19
  this.maxUsageCount = maxUsageCount;
20
+ this.secret = secret;
17
21
  }
18
22
 
19
23
  /**
@@ -106,6 +110,62 @@ class SingleUseTokenProvider {
106
110
  return {};
107
111
  }
108
112
  }
113
+
114
+ /**
115
+ * @private
116
+ * @method deriveCounter
117
+ * Derives a counter from a token ID and value
118
+ *
119
+ * @param {string} tokenId
120
+ * @param {string} tokenValue
121
+ * @returns {number}
122
+ */
123
+ deriveCounter(tokenId, tokenValue) {
124
+ const msg = `${tokenId}|${tokenValue}`;
125
+ const digest = crypto.createHash('sha256').update(msg).digest();
126
+ return digest.readUInt32BE(0);
127
+ }
128
+
129
+ /**
130
+ * @method deriveOTC
131
+ * Derives an OTC (one-time code) from a token ID and value
132
+ *
133
+ * @param {string} tokenId - Token ID
134
+ * @param {string} tokenValue - Token value
135
+ * @returns {string} The generated one-time code
136
+ */
137
+ deriveOTC(tokenId, tokenValue) {
138
+ if (!this.secret) {
139
+ throw new ValidationError({
140
+ message: 'Cannot derive OTC: secret not configured'
141
+ });
142
+ }
143
+
144
+ if (!tokenId || !tokenValue) {
145
+ throw new ValidationError({
146
+ message: 'Cannot derive OTC: tokenId and tokenValue are required'
147
+ });
148
+ }
149
+
150
+ const counter = this.deriveCounter(tokenId, tokenValue);
151
+ return hotp.generate(this.secret, counter);
152
+ }
153
+
154
+ /**
155
+ * @method getIdByToken
156
+ * Retrieves the ID associated with a given token.
157
+ *
158
+ * @param {string} token - The token to look up.
159
+ * @returns {Promise<string|null>} The ID if found, or null if not found or on error.
160
+ */
161
+ async getIdByToken(token) {
162
+ try {
163
+ const model = await this.model.findOne({token});
164
+ return model ? model.get('id') : null;
165
+ } catch (err) {
166
+ return null;
167
+ }
168
+ }
109
169
  }
110
170
 
111
171
  module.exports = SingleUseTokenProvider;
@@ -57,7 +57,8 @@ function createApiInstance(config) {
57
57
  SingleUseTokenModel: models.SingleUseToken,
58
58
  validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
59
59
  validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
60
- maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT
60
+ maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT,
61
+ secret: settingsCache.get('members_email_auth_secret')
61
62
  })
62
63
  },
63
64
  mail: {
@@ -75,7 +76,7 @@ function createApiInstance(config) {
75
76
  return ghostMailer.send(msg);
76
77
  }
77
78
  },
78
- getSubject(type) {
79
+ getSubject(type, otc) {
79
80
  const siteTitle = settingsCache.get('title');
80
81
  switch (type) {
81
82
  case 'subscribe':
@@ -88,10 +89,14 @@ function createApiInstance(config) {
88
89
  return `📫 ${t(`Confirm your email update for {siteTitle}!`, {siteTitle, interpolation: {escapeValue: false}})}`;
89
90
  case 'signin':
90
91
  default:
91
- return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
92
+ if (otc) {
93
+ return `🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}`;
94
+ } else {
95
+ return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
96
+ }
92
97
  }
93
98
  },
94
- getText(url, type, email) {
99
+ getText(url, type, email, otc) {
95
100
  const siteTitle = settingsCache.get('title');
96
101
  switch (type) {
97
102
  case 'subscribe':
@@ -162,10 +167,14 @@ function createApiInstance(config) {
162
167
  `;
163
168
  case 'signin':
164
169
  default:
170
+ /* eslint-disable indent */
165
171
  return trimLeadingWhitespace`
166
172
  ${t(`Hey there,`)}
167
173
 
168
- ${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}
174
+ ${otc
175
+ ? `${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}: ${otc}\n\n${t('Or use this link to securely sign in', {interpolation: {escapeValue: false}})}:`
176
+ : `${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}`
177
+ }
169
178
 
170
179
  ${url}
171
180
 
@@ -177,10 +186,11 @@ function createApiInstance(config) {
177
186
 
178
187
  ${t('Sent to {email}', {email})}
179
188
  ${t('If you did not make this request, you can safely ignore this email.')}
180
- `;
189
+ `;
190
+ /* eslint-enable indent */
181
191
  }
182
192
  },
183
- getHTML(url, type, email) {
193
+ getHTML(url, type, email, otc) {
184
194
  const siteTitle = settingsCache.get('title');
185
195
  const siteUrl = urlUtils.urlFor('home', true);
186
196
  const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
@@ -197,7 +207,7 @@ function createApiInstance(config) {
197
207
  return updateEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
198
208
  case 'signin':
199
209
  default:
200
- return signinEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
210
+ return signinEmail({t, url, otc, email, siteTitle, accentColor, siteDomain, siteUrl});
201
211
  }
202
212
  }
203
213
  },
@@ -1,10 +1,14 @@
1
- module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain, siteUrl}) => `
1
+ /* eslint-disable indent */
2
+ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteDomain, siteUrl}) => `
2
3
  <!doctype html>
3
4
  <html>
4
5
  <head>
5
6
  <meta name="viewport" content="width=device-width">
6
7
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
- <title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>
8
+ ${otc
9
+ ? `<title>🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
10
+ : `<title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
11
+ }
8
12
  <style>
9
13
  /* -------------------------------------
10
14
  RESPONSIVE AND MOBILE FRIENDLY STYLES
@@ -117,7 +121,36 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
117
121
  <tr>
118
122
  <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;">
119
123
  <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>
120
- <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>
124
+ ${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>
126
+ <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
+ <tbody>
128
+ <tr>
129
+ <td style="padding: 16px; background-color: #F4F5F6; border-radius: 8px; text-align: center; vertical-align: middle;" valign="middle">
130
+ <h2 style="text-align: center; vertical-align: center; letter-spacing: 5px; font-size: 24px; color: #15212A; font-weight: 600; line-height: 24px; margin: 0;">${otc}</h2>
131
+ </td>
132
+ </tr>
133
+ </tbody>
134
+ </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>
136
+ <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
+ <tbody>
138
+ <tr>
139
+ <td align="center" 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; vertical-align: top; padding-bottom: 35px;">
140
+ <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
141
+ <tbody>
142
+ <tr>
143
+ <td align="center" 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; vertical-align: top; background-color: ${accentColor}; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: ${accentColor}; border: solid 1px ${accentColor}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: ${accentColor};">${t('Sign in now')}</a> </td>
144
+ </tr>
145
+ </tbody>
146
+ </table>
147
+ </td>
148
+ </tr>
149
+ </tbody>
150
+ </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>`
152
+ :
153
+ `<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>
121
154
  <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;">
122
155
  <tbody>
123
156
  <tr>
@@ -136,7 +169,8 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
136
169
  <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; line-height: 24px; margin: 0; margin-bottom: 11px;">${t('For your security, the link will expire in 24 hours time.')}</p>
137
170
  <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; line-height: 24px; margin: 0; margin-bottom: 30px;">${t('See you soon!')}</p>
138
171
  <hr/>
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: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p>
172
+ <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>`
173
+ }
140
174
  <p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 22px; margin-top:0; color: #3A464C;">${url}</p>
141
175
  </td>
142
176
  </tr>
@@ -626,7 +626,12 @@ module.exports = class RouterController {
626
626
  if (emailType === 'signup' || emailType === 'subscribe') {
627
627
  await this._handleSignup(req, normalizedEmail, referrer);
628
628
  } else {
629
- await this._handleSignin(req, normalizedEmail, referrer);
629
+ const signIn = await this._handleSignin(req, normalizedEmail, referrer);
630
+
631
+ if (this.labsService.isSet('membersSigninOTC') && signIn.tokenId) {
632
+ res.writeHead(201, {'Content-Type': 'application/json'});
633
+ return res.end(JSON.stringify({otc_ref: signIn.tokenId}));
634
+ }
630
635
  }
631
636
 
632
637
  res.writeHead(201);
@@ -679,7 +684,13 @@ module.exports = class RouterController {
679
684
  }
680
685
 
681
686
  async _handleSignin(req, normalizedEmail, referrer = null) {
682
- const {emailType} = req.body;
687
+ const {emailType, otc} = req.body;
688
+
689
+ let includeOTC = false;
690
+
691
+ if (this.labsService.isSet('membersSigninOTC') && (otc === true || otc === 'true')) {
692
+ includeOTC = true;
693
+ }
683
694
 
684
695
  const member = await this._memberRepository.get({email: normalizedEmail});
685
696
 
@@ -690,7 +701,7 @@ module.exports = class RouterController {
690
701
  }
691
702
 
692
703
  const tokenData = {};
693
- return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
704
+ return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer, includeOTC});
694
705
  }
695
706
 
696
707
  /**
@@ -158,7 +158,8 @@ module.exports = function MembersAPI({
158
158
  getText,
159
159
  getHTML,
160
160
  getSubject,
161
- sentry
161
+ sentry,
162
+ labsService
162
163
  });
163
164
 
164
165
  const paymentsService = new PaymentsService({
@@ -207,7 +208,7 @@ module.exports = function MembersAPI({
207
208
 
208
209
  const users = memberRepository;
209
210
 
210
- async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
211
+ async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null, includeOTC = false}) {
211
212
  let type = requestedType;
212
213
  if (!options.forceEmailType) {
213
214
  const member = await users.get({email});
@@ -217,7 +218,7 @@ module.exports = function MembersAPI({
217
218
  type = 'signup';
218
219
  }
219
220
  }
220
- return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
221
+ return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer, includeOTC});
221
222
  }
222
223
 
223
224
  /**
@@ -74,7 +74,7 @@ const {getDateBoundaries, applyDateFilter} = require('./utils/date-utils');
74
74
  /**
75
75
  * @typedef {Object} NewsletterSubscriberStats
76
76
  * @property {number} total - Total current subscriber count
77
- * @property {Array<{date: string, value: number}>} deltas - Daily subscription deltas
77
+ * @property {Array<{date: string, value: number}>} values - Daily subscription cumulative values
78
78
  */
79
79
 
80
80
  class PostsStatsService {
@@ -932,7 +932,7 @@ class PostsStatsService {
932
932
  * @param {string} [options.date_from] - Optional start date filter (YYYY-MM-DD)
933
933
  * @param {string} [options.date_to] - Optional end date filter (YYYY-MM-DD)
934
934
  * @param {string} [options.timezone] - Timezone to use for date interpretation
935
- * @returns {Promise<{data: Array<{total: number, deltas: Array<{date: string, value: number}>}>}>} The newsletter subscriber stats
935
+ * @returns {Promise<{data: Array<{total: number, values: Array<{date: string, value: number}>}>}>} The newsletter subscriber stats with cumulative values
936
936
  */
937
937
  async getNewsletterSubscriberStats(newsletterId, options = {}) {
938
938
  try {
@@ -956,25 +956,42 @@ class PostsStatsService {
956
956
  const totalValue = totalResult[0] ? totalResult[0].total : 0;
957
957
  const total = parseInt(String(totalValue), 10);
958
958
 
959
- // Transform raw database results to properly typed objects
960
- const deltas = [];
959
+ // Transform raw database results (daily changes) to cumulative values
960
+ const values = [];
961
+ let cumulativeTotal = 0;
962
+
963
+ // First pass: collect all daily changes from database
964
+ const dailyChanges = [];
961
965
  for (const row of rawDeltas) {
962
966
  if (row) {
963
967
  // @ts-ignore
964
968
  const dateValue = row.date || '';
965
969
  // @ts-ignore
966
- const deltaValue = row.value || 0;
967
- deltas.push({
970
+ const changeValue = row.value || 0;
971
+ dailyChanges.push({
968
972
  date: String(dateValue),
969
- value: parseInt(String(deltaValue), 10)
973
+ change: parseInt(String(changeValue), 10)
970
974
  });
971
975
  }
972
976
  }
977
+
978
+ // Calculate the starting point by working backwards from the current total
979
+ const totalChange = dailyChanges.reduce((sum, item) => sum + item.change, 0);
980
+ cumulativeTotal = total - totalChange;
981
+
982
+ // Second pass: build cumulative values from daily changes
983
+ for (const dayData of dailyChanges) {
984
+ cumulativeTotal += dayData.change;
985
+ values.push({
986
+ date: dayData.date,
987
+ value: cumulativeTotal
988
+ });
989
+ }
973
990
 
974
991
  return {
975
992
  data: [{
976
993
  total,
977
- deltas
994
+ values
978
995
  }]
979
996
  };
980
997
  } catch (error) {
@@ -982,7 +999,7 @@ class PostsStatsService {
982
999
  return {
983
1000
  data: [{
984
1001
  total: 0,
985
- deltas: []
1002
+ values: []
986
1003
  }]
987
1004
  };
988
1005
  }
@@ -156,7 +156,7 @@ class StatsService {
156
156
 
157
157
  // If no newsletterId is provided, we can't get specific stats
158
158
  if (!newsletterId) {
159
- return {data: [{total: 0, deltas: []}]};
159
+ return {data: [{total: 0, values: []}]};
160
160
  }
161
161
 
162
162
  const result = await this.posts.getNewsletterSubscriberStats(newsletterId, otherOptions);