ghost 6.33.0 → 6.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{code-editor-view-ClWZH2b9.mjs → code-editor-view-B9Y9-ria.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-DFPdw8A9.mjs → index-D5IkCdkn.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-hTRteIub.mjs → index-WNL04L8u.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-BvKUS_Ix.mjs → index-bwPWeQa9.mjs} +5 -5
- package/core/built/admin/assets/admin-x-settings/{modals-H4owEPK6.mjs → modals-D-vFoCmN.mjs} +5917 -5925
- package/core/built/admin/assets/{chunk.526.96b1ceeb47f193bbd5cf.js → chunk.289.799159a53c663e5c4afd.js} +3 -3
- package/core/built/admin/assets/{chunk.524.3015bfad0b069d85881d.js → chunk.524.fd32ec309de4906c5734.js} +4 -4
- package/core/built/admin/assets/{chunk.582.21a5a8ae91c8e92e76cd.js → chunk.582.696652c545ccf71958b8.js} +6 -6
- package/core/built/admin/assets/{ghost-f48d0d808daca3e7828effda55ce351d.js → ghost-2b736ae44ae91e9b1034ed7f4041ed5c.js} +2 -2
- package/core/built/admin/index.html +4 -4
- package/core/frontend/public/admin-auth/admin-auth.min.js +1 -1
- package/core/frontend/src/admin-auth/message-handler.js +49 -91
- package/core/server/adapters/lib/redis/AdapterCacheRedis.js +61 -4
- package/core/server/api/endpoints/authentication.js +5 -4
- package/core/server/api/endpoints/users.js +28 -3
- package/core/server/data/seeders/importers/members-importer.js +1 -13
- package/core/server/lib/get-inbox-links.js +6 -2
- package/core/server/lib/get-inbox-links.ts +7 -6
- package/core/server/models/session.js +5 -4
- package/core/server/models/user.js +5 -4
- package/core/server/services/auth/session/session-service.js +29 -0
- package/core/server/services/email-suppression-list/mailgun-email-suppression-list.js +18 -14
- package/core/server/services/gifts/gift-service-wrapper.js +17 -0
- package/core/server/services/gifts/gift-service.js +21 -9
- package/core/server/services/gifts/gift-service.ts +28 -12
- package/core/server/services/members/members-api/services/geolocation-service.js +5 -1
- package/core/server/services/members/members-api/services/member-bread-service.js +5 -0
- package/core/server/services/oembed/oembed-service.js +12 -0
- package/package.json +1 -1
- package/pnpm-lock.yaml +57 -57
|
@@ -48,12 +48,16 @@ const buildUrl = (baseHref, key, value) => {
|
|
|
48
48
|
result.searchParams.set(key, value);
|
|
49
49
|
return result.toString();
|
|
50
50
|
};
|
|
51
|
-
const encodeRecipientForGmailUrl = (recipient) => (encodeURIComponent(recipient).replaceAll('%40', '@'));
|
|
52
51
|
const PROVIDERS = [
|
|
53
52
|
{
|
|
54
53
|
name: 'gmail',
|
|
55
54
|
domains: ['gmail.com', 'googlemail.com', 'google.com'],
|
|
56
|
-
|
|
55
|
+
// Gmail's `/mail/u/<X>/` path expects a numeric account index. Passing a
|
|
56
|
+
// raw email only resolves when that account happens to be signed in at
|
|
57
|
+
// that slot; Workspace accounts and signed-out users hit a 404 before
|
|
58
|
+
// the `#search` fragment runs. `authuser` is Gmail's own account
|
|
59
|
+
// resolver and falls through to sign-in instead of erroring.
|
|
60
|
+
getDesktopLink: ({ recipient, sender }) => (`https://mail.google.com/mail/u/0/?authuser=${encodeURIComponent(recipient)}#search/from%3A(${encodeURIComponent(sender)})+in%3Aanywhere+newer_than%3A1h`),
|
|
57
61
|
getAndroidLink: () => getAndroidIntentUrl('com.google.android.gm', 'https://mail.google.com/')
|
|
58
62
|
},
|
|
59
63
|
{
|
|
@@ -61,18 +61,19 @@ const buildUrl = (baseHref: string, key: string, value: string): string => {
|
|
|
61
61
|
return result.toString();
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const encodeRecipientForGmailUrl = (recipient: string) => (
|
|
65
|
-
encodeURIComponent(recipient).replaceAll('%40', '@')
|
|
66
|
-
);
|
|
67
|
-
|
|
68
64
|
const PROVIDERS: ReadonlyArray<Provider> = [
|
|
69
65
|
{
|
|
70
66
|
name: 'gmail',
|
|
71
67
|
domains: ['gmail.com', 'googlemail.com', 'google.com'],
|
|
68
|
+
// Gmail's `/mail/u/<X>/` path expects a numeric account index. Passing a
|
|
69
|
+
// raw email only resolves when that account happens to be signed in at
|
|
70
|
+
// that slot; Workspace accounts and signed-out users hit a 404 before
|
|
71
|
+
// the `#search` fragment runs. `authuser` is Gmail's own account
|
|
72
|
+
// resolver and falls through to sign-in instead of erroring.
|
|
72
73
|
getDesktopLink: ({recipient, sender}) => (
|
|
73
|
-
`https://mail.google.com/mail/u
|
|
74
|
+
`https://mail.google.com/mail/u/0/?authuser=${encodeURIComponent(
|
|
74
75
|
recipient
|
|
75
|
-
)}
|
|
76
|
+
)}#search/from%3A(${encodeURIComponent(
|
|
76
77
|
sender
|
|
77
78
|
)})+in%3Aanywhere+newer_than%3A1h`
|
|
78
79
|
),
|
|
@@ -40,12 +40,13 @@ const Session = ghostBookshelf.Model.extend({
|
|
|
40
40
|
}
|
|
41
41
|
const options = this.filterOptions(unfilteredOptions, 'destroy');
|
|
42
42
|
|
|
43
|
-
// Fetch the object before destroying it, so that the changed data is available to events
|
|
43
|
+
// Fetch the object before destroying it, so that the changed data is available to events.
|
|
44
|
+
// A missing row is treated as success: password-change flows destroy
|
|
45
|
+
// every session for the user, then call req.session.regenerate(), which
|
|
46
|
+
// asks the store to destroy the same session_id a second time.
|
|
44
47
|
return this.forge({session_id: options.session_id})
|
|
45
48
|
.fetch(options)
|
|
46
|
-
.then(
|
|
47
|
-
return obj.destroy(options);
|
|
48
|
-
});
|
|
49
|
+
.then(obj => obj?.destroy(options));
|
|
49
50
|
},
|
|
50
51
|
|
|
51
52
|
destroyAll() {
|
|
@@ -1056,7 +1056,6 @@ User = ghostBookshelf.Model.extend({
|
|
|
1056
1056
|
const userId = object.user_id;
|
|
1057
1057
|
const oldPassword = object.oldPassword;
|
|
1058
1058
|
const isLoggedInUser = userId === options.context.user;
|
|
1059
|
-
const skipSessionID = unfilteredOptions.skipSessionID;
|
|
1060
1059
|
|
|
1061
1060
|
options.require = true;
|
|
1062
1061
|
options.withRelated = ['sessions'];
|
|
@@ -1072,11 +1071,13 @@ User = ghostBookshelf.Model.extend({
|
|
|
1072
1071
|
|
|
1073
1072
|
const updatedUser = await user.save({password: newPassword});
|
|
1074
1073
|
|
|
1074
|
+
// Destroy every active session for this user. The caller must mint a
|
|
1075
|
+
// fresh session (with a new session_id) for self password-changes so
|
|
1076
|
+
// that stolen or cloned cookies are invalidated alongside distinct
|
|
1077
|
+
// concurrent sessions.
|
|
1075
1078
|
const sessions = user.related('sessions');
|
|
1076
1079
|
for (const session of sessions) {
|
|
1077
|
-
|
|
1078
|
-
await session.destroy(options);
|
|
1079
|
-
}
|
|
1080
|
+
await session.destroy(options);
|
|
1080
1081
|
}
|
|
1081
1082
|
|
|
1082
1083
|
return updatedUser;
|
|
@@ -258,6 +258,34 @@ module.exports = function createSessionService({
|
|
|
258
258
|
invalidateAuthCodeChallenge(session);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* rotateAndAssignVerifiedUserToSession
|
|
263
|
+
* Regenerates the Express session (issuing a new session_id) and then
|
|
264
|
+
* assigns the verified user to the new session. Used after a password
|
|
265
|
+
* change or reset so that any cloned or stolen copy of the pre-change
|
|
266
|
+
* cookie is rejected on its next request.
|
|
267
|
+
*
|
|
268
|
+
* @param {{req: Req, user: User, ip?: string}} options
|
|
269
|
+
* @returns {Promise<void>}
|
|
270
|
+
*/
|
|
271
|
+
async function rotateAndAssignVerifiedUserToSession({req, user, ip}) {
|
|
272
|
+
await new Promise((resolve, reject) => {
|
|
273
|
+
req.session.regenerate((err) => {
|
|
274
|
+
if (err) {
|
|
275
|
+
reject(err);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
resolve();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
await assignVerifiedUserToSession({
|
|
282
|
+
session: req.session,
|
|
283
|
+
user,
|
|
284
|
+
origin: getOriginOfRequest(req),
|
|
285
|
+
ip
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
261
289
|
/**
|
|
262
290
|
* generateAuthCodeForUser
|
|
263
291
|
*
|
|
@@ -494,6 +522,7 @@ module.exports = function createSessionService({
|
|
|
494
522
|
createSessionForUser,
|
|
495
523
|
createVerifiedSessionForUser,
|
|
496
524
|
assignVerifiedUserToSession,
|
|
525
|
+
rotateAndAssignVerifiedUserToSession,
|
|
497
526
|
removeUserForSession,
|
|
498
527
|
verifySession,
|
|
499
528
|
isVerifiedSession,
|
|
@@ -117,31 +117,35 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
|
|
|
117
117
|
async init() {
|
|
118
118
|
this.Suppression = models.Suppression;
|
|
119
119
|
const handleEvent = reason => async (event) => {
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
120
|
+
if (reason === 'bounce') {
|
|
121
|
+
if (!Number.isInteger(event.error?.code)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (event.error.code !== 607 && event.error.code !== 605) {
|
|
125
|
+
return;
|
|
128
126
|
}
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
129
|
await this.Suppression.add({
|
|
130
130
|
email: event.email,
|
|
131
131
|
email_id: event.emailId,
|
|
132
132
|
reason: reason,
|
|
133
133
|
created_at: event.timestamp
|
|
134
134
|
});
|
|
135
|
-
DomainEvents.dispatch(EmailSuppressedEvent.create({
|
|
136
|
-
emailAddress: event.email,
|
|
137
|
-
emailId: event.emailId,
|
|
138
|
-
reason: reason
|
|
139
|
-
}, event.timestamp));
|
|
140
135
|
} catch (err) {
|
|
141
|
-
if (err.code !== 'ER_DUP_ENTRY') {
|
|
136
|
+
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
142
137
|
logging.error(err);
|
|
138
|
+
return;
|
|
143
139
|
}
|
|
140
|
+
// Suppression already exists — still dispatch so any drifted
|
|
141
|
+
// member state (e.g. email_disabled=false) gets corrected.
|
|
142
|
+
logging.info(`Re-dispatching EmailSuppressedEvent for existing suppression (${reason}): ${event.email}`);
|
|
144
143
|
}
|
|
144
|
+
DomainEvents.dispatch(EmailSuppressedEvent.create({
|
|
145
|
+
emailAddress: event.email,
|
|
146
|
+
emailId: event.emailId,
|
|
147
|
+
reason: reason
|
|
148
|
+
}, event.timestamp));
|
|
145
149
|
};
|
|
146
150
|
DomainEvents.subscribe(EmailBouncedEvent, handleEvent('bounce'));
|
|
147
151
|
DomainEvents.subscribe(SpamComplaintEvent, handleEvent('spam'));
|
|
@@ -16,6 +16,9 @@ class GiftServiceWrapper {
|
|
|
16
16
|
const tiersService = require('../tiers');
|
|
17
17
|
const staffService = require('../staff');
|
|
18
18
|
const labsService = require('../../../shared/labs');
|
|
19
|
+
const DomainEvents = require('@tryghost/domain-events');
|
|
20
|
+
const logging = require('@tryghost/logging');
|
|
21
|
+
const {SubscriptionActivatedEvent} = require('../../../shared/events');
|
|
19
22
|
|
|
20
23
|
const {GhostMailer} = require('../mail');
|
|
21
24
|
const settingsCache = require('../../../shared/settings-cache');
|
|
@@ -53,6 +56,20 @@ class GiftServiceWrapper {
|
|
|
53
56
|
tiersService,
|
|
54
57
|
labsService: labsService
|
|
55
58
|
});
|
|
59
|
+
|
|
60
|
+
DomainEvents.subscribe(SubscriptionActivatedEvent, async (event) => {
|
|
61
|
+
try {
|
|
62
|
+
const gift = await this.service.getActiveByMember(event.data.memberId);
|
|
63
|
+
|
|
64
|
+
if (!gift) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await this.service.consume(gift.token);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logging.error(err, 'Failed to consume gift on paid subscription activation');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
56
73
|
}
|
|
57
74
|
}
|
|
58
75
|
|
|
@@ -237,6 +237,24 @@ class GiftService {
|
|
|
237
237
|
});
|
|
238
238
|
return true;
|
|
239
239
|
}
|
|
240
|
+
async consume(token, options = {}) {
|
|
241
|
+
const run = async (transacting) => {
|
|
242
|
+
// Fetch with a row lock to prevent race conditions under concurrency
|
|
243
|
+
const gift = await this.deps.giftRepository.getByToken(token, { transacting, forUpdate: true });
|
|
244
|
+
if (!gift || gift.status !== 'redeemed') {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const consumed = gift.consume();
|
|
248
|
+
if (!consumed) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
await this.deps.giftRepository.update(consumed, { transacting });
|
|
252
|
+
return consumed;
|
|
253
|
+
};
|
|
254
|
+
return options.transacting
|
|
255
|
+
? await run(options.transacting)
|
|
256
|
+
: await this.deps.giftRepository.transaction(run);
|
|
257
|
+
}
|
|
240
258
|
async processConsumed() {
|
|
241
259
|
const toConsume = await this.deps.giftRepository.findPendingConsumption();
|
|
242
260
|
if (toConsume.length === 0) {
|
|
@@ -246,24 +264,18 @@ class GiftService {
|
|
|
246
264
|
let updatedMemberCount = 0;
|
|
247
265
|
for (const gift of toConsume) {
|
|
248
266
|
await this.deps.giftRepository.transaction(async (transacting) => {
|
|
249
|
-
|
|
250
|
-
const locked = await this.deps.giftRepository.getByToken(gift.token, { transacting, forUpdate: true });
|
|
251
|
-
if (locked?.status !== 'redeemed') {
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
const consumed = locked.consume();
|
|
267
|
+
const consumed = await this.consume(gift.token, { transacting });
|
|
255
268
|
if (!consumed) {
|
|
256
269
|
return;
|
|
257
270
|
}
|
|
258
|
-
const member = await this.deps.memberRepository.get({ id:
|
|
271
|
+
const member = await this.deps.memberRepository.get({ id: consumed.redeemerMemberId }, { transacting, forUpdate: true });
|
|
259
272
|
if (member && member.get('status') === 'gift') {
|
|
260
273
|
await this.deps.memberRepository.update({
|
|
261
274
|
products: [],
|
|
262
275
|
status: 'free'
|
|
263
|
-
}, { id:
|
|
276
|
+
}, { id: consumed.redeemerMemberId, transacting });
|
|
264
277
|
updatedMemberCount += 1;
|
|
265
278
|
}
|
|
266
|
-
await this.deps.giftRepository.update(consumed, { transacting });
|
|
267
279
|
consumedCount += 1;
|
|
268
280
|
});
|
|
269
281
|
}
|
|
@@ -383,6 +383,31 @@ export class GiftService {
|
|
|
383
383
|
return true;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
async consume(token: string, options: {transacting?: unknown} = {}): Promise<Gift | null> {
|
|
387
|
+
const run = async (transacting: unknown) => {
|
|
388
|
+
// Fetch with a row lock to prevent race conditions under concurrency
|
|
389
|
+
const gift = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true});
|
|
390
|
+
|
|
391
|
+
if (!gift || gift.status !== 'redeemed') {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const consumed = gift.consume();
|
|
396
|
+
|
|
397
|
+
if (!consumed) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await this.deps.giftRepository.update(consumed, {transacting});
|
|
402
|
+
|
|
403
|
+
return consumed;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return options.transacting
|
|
407
|
+
? await run(options.transacting)
|
|
408
|
+
: await this.deps.giftRepository.transaction(run);
|
|
409
|
+
}
|
|
410
|
+
|
|
386
411
|
async processConsumed(): Promise<{consumedCount: number; updatedMemberCount: number}> {
|
|
387
412
|
const toConsume = await this.deps.giftRepository.findPendingConsumption();
|
|
388
413
|
|
|
@@ -395,32 +420,23 @@ export class GiftService {
|
|
|
395
420
|
|
|
396
421
|
for (const gift of toConsume) {
|
|
397
422
|
await this.deps.giftRepository.transaction(async (transacting) => {
|
|
398
|
-
|
|
399
|
-
const locked = await this.deps.giftRepository.getByToken(gift.token, {transacting, forUpdate: true});
|
|
400
|
-
|
|
401
|
-
if (locked?.status !== 'redeemed') {
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const consumed = locked.consume();
|
|
423
|
+
const consumed = await this.consume(gift.token, {transacting});
|
|
406
424
|
|
|
407
425
|
if (!consumed) {
|
|
408
426
|
return;
|
|
409
427
|
}
|
|
410
428
|
|
|
411
|
-
const member = await this.deps.memberRepository.get({id:
|
|
429
|
+
const member = await this.deps.memberRepository.get({id: consumed.redeemerMemberId}, {transacting, forUpdate: true});
|
|
412
430
|
|
|
413
431
|
if (member && member.get('status') === 'gift') {
|
|
414
432
|
await this.deps.memberRepository.update({
|
|
415
433
|
products: [],
|
|
416
434
|
status: 'free'
|
|
417
|
-
}, {id:
|
|
435
|
+
}, {id: consumed.redeemerMemberId, transacting});
|
|
418
436
|
|
|
419
437
|
updatedMemberCount += 1;
|
|
420
438
|
}
|
|
421
439
|
|
|
422
|
-
await this.deps.giftRepository.update(consumed, {transacting});
|
|
423
|
-
|
|
424
440
|
consumedCount += 1;
|
|
425
441
|
});
|
|
426
442
|
}
|
|
@@ -22,6 +22,10 @@ module.exports = class GeolocationService {
|
|
|
22
22
|
|
|
23
23
|
const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`;
|
|
24
24
|
const response = await got(geojsUrl, gotOpts).json();
|
|
25
|
-
return
|
|
25
|
+
return {
|
|
26
|
+
country: response.country,
|
|
27
|
+
country_code: response.country_code,
|
|
28
|
+
region: response.region
|
|
29
|
+
};
|
|
26
30
|
}
|
|
27
31
|
};
|
|
@@ -360,6 +360,11 @@ module.exports = class MemberBREADService {
|
|
|
360
360
|
let model;
|
|
361
361
|
|
|
362
362
|
try {
|
|
363
|
+
if (data.email && data.email_disabled === undefined) {
|
|
364
|
+
const isSuppressed = (await this.emailSuppressionList.getSuppressionData(data.email))?.suppressed;
|
|
365
|
+
data.email_disabled = !!isSuppressed;
|
|
366
|
+
}
|
|
367
|
+
|
|
363
368
|
const attribution = await this.memberAttributionService.getAttributionFromContext(options?.context);
|
|
364
369
|
if (attribution) {
|
|
365
370
|
data.attribution = attribution;
|
|
@@ -444,6 +444,18 @@ class OEmbedService {
|
|
|
444
444
|
return;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
// `rich` and `video` responses ship provider-supplied HTML that gets
|
|
448
|
+
// stored in the post's Lexical payload and rendered into the admin
|
|
449
|
+
// editor preview (via srcdoc) and into public themes (via innerHTML).
|
|
450
|
+
// Known providers (YouTube, Twitter, etc.) go through `knownProvider`
|
|
451
|
+
// with @extractus/oembed-extractor's allowlist — anything reaching
|
|
452
|
+
// here is an arbitrary site's self-declared oEmbed endpoint, which we
|
|
453
|
+
// must not trust. Drop the response and let the caller fall back to
|
|
454
|
+
// a bookmark card.
|
|
455
|
+
if (oembed.type === 'video' || oembed.type === 'rich') {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
447
459
|
// return the extracted object, don't pass through the response body
|
|
448
460
|
return oembed;
|
|
449
461
|
}
|