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.
Files changed (31) hide show
  1. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  2. package/core/built/admin/assets/admin-x-settings/{code-editor-view-ClWZH2b9.mjs → code-editor-view-B9Y9-ria.mjs} +2 -2
  3. package/core/built/admin/assets/admin-x-settings/{index-DFPdw8A9.mjs → index-D5IkCdkn.mjs} +2 -2
  4. package/core/built/admin/assets/admin-x-settings/{index-hTRteIub.mjs → index-WNL04L8u.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{index-BvKUS_Ix.mjs → index-bwPWeQa9.mjs} +5 -5
  6. package/core/built/admin/assets/admin-x-settings/{modals-H4owEPK6.mjs → modals-D-vFoCmN.mjs} +5917 -5925
  7. package/core/built/admin/assets/{chunk.526.96b1ceeb47f193bbd5cf.js → chunk.289.799159a53c663e5c4afd.js} +3 -3
  8. package/core/built/admin/assets/{chunk.524.3015bfad0b069d85881d.js → chunk.524.fd32ec309de4906c5734.js} +4 -4
  9. package/core/built/admin/assets/{chunk.582.21a5a8ae91c8e92e76cd.js → chunk.582.696652c545ccf71958b8.js} +6 -6
  10. package/core/built/admin/assets/{ghost-f48d0d808daca3e7828effda55ce351d.js → ghost-2b736ae44ae91e9b1034ed7f4041ed5c.js} +2 -2
  11. package/core/built/admin/index.html +4 -4
  12. package/core/frontend/public/admin-auth/admin-auth.min.js +1 -1
  13. package/core/frontend/src/admin-auth/message-handler.js +49 -91
  14. package/core/server/adapters/lib/redis/AdapterCacheRedis.js +61 -4
  15. package/core/server/api/endpoints/authentication.js +5 -4
  16. package/core/server/api/endpoints/users.js +28 -3
  17. package/core/server/data/seeders/importers/members-importer.js +1 -13
  18. package/core/server/lib/get-inbox-links.js +6 -2
  19. package/core/server/lib/get-inbox-links.ts +7 -6
  20. package/core/server/models/session.js +5 -4
  21. package/core/server/models/user.js +5 -4
  22. package/core/server/services/auth/session/session-service.js +29 -0
  23. package/core/server/services/email-suppression-list/mailgun-email-suppression-list.js +18 -14
  24. package/core/server/services/gifts/gift-service-wrapper.js +17 -0
  25. package/core/server/services/gifts/gift-service.js +21 -9
  26. package/core/server/services/gifts/gift-service.ts +28 -12
  27. package/core/server/services/members/members-api/services/geolocation-service.js +5 -1
  28. package/core/server/services/members/members-api/services/member-bread-service.js +5 -0
  29. package/core/server/services/oembed/oembed-service.js +12 -0
  30. package/package.json +1 -1
  31. 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
- getDesktopLink: ({ recipient, sender }) => (`https://mail.google.com/mail/u/${encodeRecipientForGmailUrl(recipient)}/#search/from%3A(${encodeURIComponent(sender)})+in%3Aanywhere+newer_than%3A1h`),
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/${encodeRecipientForGmailUrl(
74
+ `https://mail.google.com/mail/u/0/?authuser=${encodeURIComponent(
74
75
  recipient
75
- )}/#search/from%3A(${encodeURIComponent(
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((obj) => {
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
- if (session.get('session_id') !== skipSessionID) {
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
- try {
121
- if (reason === 'bounce') {
122
- if (!Number.isInteger(event.error?.code)) {
123
- return;
124
- }
125
- if (event.error.code !== 607 && event.error.code !== 605) {
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
- // Re-fetch with a row lock to prevent races with concurrent refunds
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: locked.redeemerMemberId }, { transacting, forUpdate: true });
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: locked.redeemerMemberId, transacting });
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
- // Re-fetch with a row lock to prevent races with concurrent refunds
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: locked.redeemerMemberId}, {transacting, forUpdate: true});
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: locked.redeemerMemberId, transacting});
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 response;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost",
3
- "version": "6.33.0",
3
+ "version": "6.34.0",
4
4
  "description": "The professional publishing platform",
5
5
  "author": "Ghost Foundation",
6
6
  "homepage": "https://ghost.org",