ghost 6.0.4 → 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.
- package/components/tryghost-i18n-6.0.6.tgz +0 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
- package/core/built/admin/assets/admin-x-activitypub/{index-DzkhcDJy.mjs → index-BRzGrD-C.mjs} +10569 -10480
- package/core/built/admin/assets/admin-x-activitypub/{index-BGpQ-bIj.mjs → index-Co80faUx.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-CFP3ZKm2.mjs → CodeEditorView-B4W7CQcA.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-qJnVka7B.mjs → index-CuwMM9FM.mjs} +6603 -6565
- package/core/built/admin/assets/admin-x-settings/{index-CNPz6XrY.mjs → index-jv9DN3ZO.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-CZ910p4i.mjs → modals-CUGEPPYA.mjs} +6 -6
- package/core/built/admin/assets/{chunk.397.f965bec6bb556d2750de.js → chunk.397.1904882a4a78e2922f07.js} +2 -2
- package/core/built/admin/assets/{chunk.524.d7ccca2fe46a0364b56a.js → chunk.524.56bb70d3e8660d34aef1.js} +7 -7
- package/core/built/admin/assets/{chunk.582.6bb47c177a8f2bf9503f.js → chunk.582.ae0341229e71a85d0b2d.js} +10 -10
- package/core/built/admin/assets/{ghost-754c74a251b36c8775d3cb9ca6a1a85d.js → ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js} +38 -35
- package/core/built/admin/assets/{ghost-a0676bad4687584c2c1b3b6b8bed5b31.css → ghost-2c537ee89c36199137eafc1768fd7de8.css} +1 -1
- package/core/built/admin/assets/{ghost-dark-65295e8112b12fc9b301111e1c446c51.css → ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css} +1 -1
- package/core/built/admin/assets/posts/posts.js +25911 -25852
- package/core/built/admin/assets/stats/stats.js +26175 -26121
- package/core/built/admin/index.html +5 -5
- package/core/frontend/helpers/facebook_url.js +3 -0
- package/core/frontend/helpers/ghost_head.js +2 -1
- package/core/frontend/helpers/twitter_url.js +3 -0
- package/core/frontend/public/ghost-stats.min.js +1 -1
- package/core/frontend/public/member-attribution.min.js +1 -1
- package/core/frontend/src/ghost-stats/ghost-stats.js +1 -8
- package/core/frontend/src/member-attribution/member-attribution.js +4 -2
- package/core/server/data/tinybird/README.md +38 -10
- package/core/server/data/tinybird/datasources/analytics_events.datasource +3 -2
- package/core/server/data/tinybird/datasources/analytics_events_test.datasource +3 -2
- package/core/server/data/tinybird/scripts/configure-ghost.sh +4 -2
- package/core/server/services/koenig/node-renderers/header-v2-renderer.js +74 -4
- package/core/server/services/lib/magic-link/MagicLink.js +88 -8
- package/core/server/services/members/SingleUseTokenProvider.js +61 -1
- package/core/server/services/members/api.js +18 -8
- package/core/server/services/members/emails/signin.js +38 -4
- package/core/server/services/members/members-api/controllers/RouterController.js +14 -3
- package/core/server/services/members/members-api/members-api.js +4 -3
- package/core/server/services/stats/PostsStatsService.js +26 -9
- package/core/server/services/stats/StatsService.js +1 -1
- package/core/shared/config/defaults.json +2 -2
- package/core/shared/labs.js +2 -1
- package/ghost.js +0 -1
- package/package.json +11 -11
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +253 -307
- package/components/tryghost-i18n-6.0.4.tgz +0 -0
- /package/core/built/admin/assets/{chunk.397.f965bec6bb556d2750de.js.LICENSE.txt → chunk.397.1904882a4a78e2922f07.js.LICENSE.txt} +0 -0
|
@@ -68,6 +68,73 @@ function cardTemplate(nodeData, options = {}) {
|
|
|
68
68
|
`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function generateMSOSplitHeaderImage(nodeData) {
|
|
72
|
+
const {backgroundSize, backgroundImageSrc, backgroundColor} = nodeData;
|
|
73
|
+
|
|
74
|
+
if (backgroundSize === 'contain') {
|
|
75
|
+
return `
|
|
76
|
+
<!--[if mso]>
|
|
77
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" stroke="false" style="width:600px;height:320px;">
|
|
78
|
+
<v:fill type="frame" aspect="atmost" size="225pt,120pt" src="${backgroundImageSrc}" color="${backgroundColor}" />
|
|
79
|
+
<v:textbox inset="0,0,0,0">
|
|
80
|
+
</v:textbox>
|
|
81
|
+
</v:rect>
|
|
82
|
+
<![endif]-->
|
|
83
|
+
`;
|
|
84
|
+
} else {
|
|
85
|
+
return `
|
|
86
|
+
<!--[if mso]>
|
|
87
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:600px;height:320px;">
|
|
88
|
+
<v:fill type="frame" aspect="atleast" src="${backgroundImageSrc}" color="${backgroundColor}" />
|
|
89
|
+
<v:textbox inset="0,0,0,0">
|
|
90
|
+
</v:textbox>
|
|
91
|
+
</v:rect>
|
|
92
|
+
<![endif]-->
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function generateMSOContentWrapper(nodeData) {
|
|
98
|
+
const {backgroundImageSrc, backgroundColor} = nodeData;
|
|
99
|
+
const hasContainAndSplit = nodeData.backgroundSize === 'contain' && nodeData.layout === 'split';
|
|
100
|
+
const hasImageWithoutSplit = nodeData.backgroundImageSrc && nodeData.layout !== 'split';
|
|
101
|
+
|
|
102
|
+
// Outlook clients will return the first td, all other clients will return the second td
|
|
103
|
+
const msoOpenTag = `
|
|
104
|
+
<!--[if mso]>
|
|
105
|
+
<td class="kg-header-card-content" style="${hasImageWithoutSplit ? 'padding: 0;' : 'padding: 40px;'}${hasContainAndSplit ? 'padding-top: 0;' : ''}">
|
|
106
|
+
<![endif]-->
|
|
107
|
+
<!--[if !mso]><!-->
|
|
108
|
+
<td class="kg-header-card-content" style="${hasContainAndSplit ? 'padding-top: 0;' : ''}">
|
|
109
|
+
<!--<![endif]-->
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const msoImageVML = hasImageWithoutSplit ? `
|
|
113
|
+
<!--[if mso]>
|
|
114
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:600px;">
|
|
115
|
+
<v:fill src="${backgroundImageSrc}" color="${backgroundColor}" type="frame" aspect="atleast" focusposition="0.5,0.5" />
|
|
116
|
+
<v:textbox inset="30pt,30pt,30pt,30pt" style="mso-fit-shape-to-text:true;">
|
|
117
|
+
<![endif]-->
|
|
118
|
+
` : '';
|
|
119
|
+
|
|
120
|
+
return msoOpenTag + msoImageVML;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function generateMSOContentClosing(nodeData) {
|
|
124
|
+
const hasImageWithoutSplit = nodeData.backgroundImageSrc && nodeData.layout !== 'split';
|
|
125
|
+
|
|
126
|
+
if (!hasImageWithoutSplit) {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `
|
|
131
|
+
<!--[if mso]>
|
|
132
|
+
</v:textbox>
|
|
133
|
+
</v:rect>
|
|
134
|
+
<![endif]-->
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
71
138
|
function emailTemplate(nodeData, options) {
|
|
72
139
|
const backgroundAccent = nodeData.backgroundColor === 'accent' ? `background-color: ${nodeData.accentColor};` : '';
|
|
73
140
|
const alignment = nodeData.alignment === 'center' ? 'text-align: center;' : '';
|
|
@@ -88,17 +155,19 @@ function emailTemplate(nodeData, options) {
|
|
|
88
155
|
|
|
89
156
|
return (
|
|
90
157
|
`
|
|
91
|
-
<div class="kg-header-card kg-v2${hasDarkBg ? '
|
|
158
|
+
<div class="kg-header-card kg-v2 ${hasDarkBg ? 'kg-header-card-dark-bg' : 'kg-header-card-light-bg'}" style="color:${nodeData.textColor}; ${alignment} ${backgroundImageStyle} ${backgroundAccent}">
|
|
92
159
|
${nodeData.layout === 'split' && nodeData.backgroundImageSrc ? `
|
|
93
160
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
|
94
161
|
<tr>
|
|
95
|
-
<td background="${nodeData.backgroundImageSrc}" style="${splitImageStyle}" class="kg-header-card-image"
|
|
162
|
+
<td background="${nodeData.backgroundImageSrc}" style="${splitImageStyle}" class="kg-header-card-image" bgcolor="${nodeData.backgroundColor}" align="center">
|
|
163
|
+
${generateMSOSplitHeaderImage(nodeData) /* mso-only img, no shared markup */}
|
|
164
|
+
</td>
|
|
96
165
|
</tr>
|
|
97
166
|
</table>
|
|
98
167
|
` : ''}
|
|
99
168
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="color:${nodeData.textColor}; ${alignment} ${backgroundImageStyle} ${backgroundAccent}">
|
|
100
169
|
<tr>
|
|
101
|
-
|
|
170
|
+
${generateMSOContentWrapper(nodeData) /* creates correct opening td tag for any platform */}
|
|
102
171
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
|
103
172
|
<tr>
|
|
104
173
|
<td align="${nodeData.alignment}">
|
|
@@ -118,6 +187,7 @@ function emailTemplate(nodeData, options) {
|
|
|
118
187
|
` : ''}
|
|
119
188
|
</tr>
|
|
120
189
|
</table>
|
|
190
|
+
${generateMSOContentClosing(nodeData) /* mso-only closing tags, no shared markup */}
|
|
121
191
|
</td>
|
|
122
192
|
</tr>
|
|
123
193
|
</table>
|
|
@@ -209,4 +279,4 @@ function getCardClasses(nodeData) {
|
|
|
209
279
|
return cardClasses;
|
|
210
280
|
}
|
|
211
281
|
|
|
212
|
-
module.exports = renderHeaderNodeV2;
|
|
282
|
+
module.exports = renderHeaderNodeV2;
|
|
@@ -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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|