ghost 5.117.0 → 5.118.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 (124) hide show
  1. package/components/{tryghost-api-framework-5.117.0.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
  2. package/components/tryghost-constants-5.118.0.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
  4. package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
  5. package/components/tryghost-domain-events-5.118.0.tgz +0 -0
  6. package/components/tryghost-donations-5.118.0.tgz +0 -0
  7. package/components/{tryghost-email-addresses-5.117.0.tgz → tryghost-email-addresses-5.118.0.tgz} +0 -0
  8. package/components/{tryghost-email-service-5.117.0.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
  9. package/components/{tryghost-email-suppression-list-5.117.0.tgz → tryghost-email-suppression-list-5.118.0.tgz} +0 -0
  10. package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
  11. package/components/tryghost-i18n-5.118.0.tgz +0 -0
  12. package/components/{tryghost-job-manager-5.117.0.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
  13. package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
  14. package/components/{tryghost-magic-link-5.117.0.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
  15. package/components/{tryghost-member-attribution-5.117.0.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
  16. package/components/tryghost-member-events-5.118.0.tgz +0 -0
  17. package/components/{tryghost-members-csv-5.117.0.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
  18. package/components/{tryghost-members-offers-5.117.0.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
  19. package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
  20. package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
  21. package/components/tryghost-post-events-5.118.0.tgz +0 -0
  22. package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
  23. package/components/{tryghost-posts-service-5.117.0.tgz → tryghost-posts-service-5.118.0.tgz} +0 -0
  24. package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
  25. package/components/tryghost-security-5.118.0.tgz +0 -0
  26. package/components/tryghost-tiers-5.118.0.tgz +0 -0
  27. package/components/tryghost-webmentions-5.118.0.tgz +0 -0
  28. package/content/themes/casper/LICENSE +1 -1
  29. package/content/themes/casper/README.md +1 -1
  30. package/content/themes/casper/assets/built/screen.css +1 -1
  31. package/content/themes/casper/assets/built/screen.css.map +1 -1
  32. package/content/themes/casper/assets/css/screen.css +1 -1
  33. package/content/themes/casper/author.hbs +23 -2
  34. package/content/themes/casper/package.json +2 -2
  35. package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
  36. package/content/themes/casper/partials/icons/instagram.hbs +5 -0
  37. package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
  38. package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
  39. package/content/themes/casper/partials/icons/threads.hbs +3 -0
  40. package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
  41. package/content/themes/casper/partials/icons/twitter.hbs +3 -1
  42. package/content/themes/casper/partials/icons/youtube.hbs +3 -0
  43. package/content/themes/source/LICENSE +1 -1
  44. package/content/themes/source/README.md +1 -1
  45. package/content/themes/source/assets/built/screen.css +1 -1
  46. package/content/themes/source/assets/built/screen.css.map +1 -1
  47. package/content/themes/source/assets/css/screen.css +7 -12
  48. package/content/themes/source/author.hbs +24 -3
  49. package/content/themes/source/package.json +2 -2
  50. package/content/themes/source/partials/feature-image.hbs +2 -2
  51. package/content/themes/source/partials/icons/bluesky.hbs +3 -0
  52. package/content/themes/source/partials/icons/instagram.hbs +5 -0
  53. package/content/themes/source/partials/icons/linkedin.hbs +3 -0
  54. package/content/themes/source/partials/icons/mastodon.hbs +3 -0
  55. package/content/themes/source/partials/icons/threads.hbs +3 -0
  56. package/content/themes/source/partials/icons/tiktok.hbs +3 -0
  57. package/content/themes/source/partials/icons/youtube.hbs +3 -0
  58. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31773 -26586
  59. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-3bc05d1b.mjs → CodeEditorView-1143c509.mjs} +2 -2
  60. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  61. package/core/built/admin/assets/admin-x-settings/{index-c5623145.mjs → index-19ebc8ad.mjs} +2 -2
  62. package/core/built/admin/assets/admin-x-settings/{index-b2cdc747.mjs → index-ac104f42.mjs} +2559 -2551
  63. package/core/built/admin/assets/admin-x-settings/{modals-fd7bc70c.mjs → modals-994901ee.mjs} +6680 -6165
  64. package/core/built/admin/assets/{chunk.524.f4d7526780f546c5fc0b.js → chunk.524.5710919eb507b9a81166.js} +6 -6
  65. package/core/built/admin/assets/{chunk.582.869c66dfbfa68412de07.js → chunk.582.c8cb99b85cfa13fc7df1.js} +8 -8
  66. package/core/built/admin/assets/{ghost-45186e4f079c9fdd8f42dfbfb93d3344.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +803 -800
  67. package/core/built/admin/assets/posts/posts.js +11542 -11341
  68. package/core/built/admin/assets/stats/stats.js +37309 -31520
  69. package/core/built/admin/index.html +3 -3
  70. package/core/frontend/helpers/social_url.js +31 -0
  71. package/core/server/api/endpoints/users.js +7 -0
  72. package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
  73. package/core/server/services/auth/session/index.js +5 -2
  74. package/core/server/services/auth/session/session-service.js +5 -4
  75. package/core/server/services/members/api.js +2 -2
  76. package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
  77. package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
  78. package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
  79. package/core/server/services/members/members-api/members-api.js +404 -0
  80. package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
  81. package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
  82. package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
  83. package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
  84. package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
  85. package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
  86. package/core/server/services/members/members-api/services/TokenService.js +54 -0
  87. package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
  88. package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
  89. package/core/server/services/milestones/Milestone.js +231 -0
  90. package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
  91. package/core/server/services/milestones/MilestonesService.js +327 -0
  92. package/core/server/services/milestones/service.js +2 -2
  93. package/core/server/services/newsletters/index.js +1 -1
  94. package/core/server/services/public-config/config.js +2 -1
  95. package/core/server/services/settings/settings-service.js +1 -1
  96. package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
  97. package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
  98. package/core/server/services/staff/StaffService.js +1 -1
  99. package/core/shared/config/defaults.json +3 -0
  100. package/core/shared/config/env/config.testing-mysql.json +3 -0
  101. package/core/shared/config/env/config.testing.json +3 -0
  102. package/core/shared/labs.js +3 -4
  103. package/package.json +63 -63
  104. package/tsconfig.tsbuildinfo +1 -1
  105. package/yarn.lock +86 -44
  106. package/components/tryghost-constants-5.117.0.tgz +0 -0
  107. package/components/tryghost-custom-fonts-5.117.0.tgz +0 -0
  108. package/components/tryghost-custom-theme-settings-service-5.117.0.tgz +0 -0
  109. package/components/tryghost-domain-events-5.117.0.tgz +0 -0
  110. package/components/tryghost-donations-5.117.0.tgz +0 -0
  111. package/components/tryghost-html-to-plaintext-5.117.0.tgz +0 -0
  112. package/components/tryghost-i18n-5.117.0.tgz +0 -0
  113. package/components/tryghost-link-replacer-5.117.0.tgz +0 -0
  114. package/components/tryghost-member-events-5.117.0.tgz +0 -0
  115. package/components/tryghost-members-api-5.117.0.tgz +0 -0
  116. package/components/tryghost-milestones-5.117.0.tgz +0 -0
  117. package/components/tryghost-mw-error-handler-5.117.0.tgz +0 -0
  118. package/components/tryghost-mw-vhost-5.117.0.tgz +0 -0
  119. package/components/tryghost-post-events-5.117.0.tgz +0 -0
  120. package/components/tryghost-post-revisions-5.117.0.tgz +0 -0
  121. package/components/tryghost-prometheus-metrics-5.117.0.tgz +0 -0
  122. package/components/tryghost-security-5.117.0.tgz +0 -0
  123. package/components/tryghost-tiers-5.117.0.tgz +0 -0
  124. package/components/tryghost-webmentions-5.117.0.tgz +0 -0
@@ -0,0 +1,667 @@
1
+ const tpl = require('@tryghost/tpl');
2
+ const logging = require('@tryghost/logging');
3
+ const sanitizeHtml = require('sanitize-html');
4
+ const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureError} = require('@tryghost/errors');
5
+ const errors = require('@tryghost/errors');
6
+ const {isEmail} = require('@tryghost/validator');
7
+
8
+ const messages = {
9
+ emailRequired: 'Email is required.',
10
+ invalidEmail: 'Email is not valid',
11
+ blockedEmailDomain: 'Signups from this email domain are currently restricted.',
12
+ badRequest: 'Bad Request.',
13
+ notFound: 'Not Found.',
14
+ offerNotFound: 'This offer does not exist.',
15
+ offerArchived: 'This offer is archived.',
16
+ tierNotFound: 'This tier does not exist.',
17
+ tierArchived: 'This tier is archived.',
18
+ existingSubscription: 'A subscription exists for this Member.',
19
+ unableToCheckout: 'Unable to initiate checkout session',
20
+ inviteOnly: 'This site is invite-only, contact the owner for access.',
21
+ paidOnly: 'This site only accepts paid members.',
22
+ memberNotFound: 'No member exists with this e-mail address.',
23
+ memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.',
24
+ invalidType: 'Invalid checkout type.',
25
+ notConfigured: 'This site is not accepting payments at the moment.',
26
+ invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
27
+ archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}'
28
+ };
29
+
30
+ module.exports = class RouterController {
31
+ /**
32
+ * RouterController
33
+ *
34
+ * @param {object} deps
35
+ * @param {any} deps.offersAPI
36
+ * @param {any} deps.paymentsService
37
+ * @param {any} deps.memberRepository
38
+ * @param {any} deps.StripePrice
39
+ * @param {() => boolean} deps.allowSelfSignup
40
+ * @param {any} deps.magicLinkService
41
+ * @param {import('@tryghost/members-stripe-service')} deps.stripeAPIService
42
+ * @param {import('@tryghost/member-attribution')} deps.memberAttributionService
43
+ * @param {any} deps.tokenService
44
+ * @param {any} deps.sendEmailWithMagicLink
45
+ * @param {{isSet(name: string): boolean}} deps.labsService
46
+ * @param {any} deps.newslettersService
47
+ * @param {any} deps.sentry
48
+ * @param {any} deps.settingsCache
49
+ */
50
+ constructor({
51
+ offersAPI,
52
+ paymentsService,
53
+ tiersService,
54
+ memberRepository,
55
+ StripePrice,
56
+ allowSelfSignup,
57
+ magicLinkService,
58
+ stripeAPIService,
59
+ tokenService,
60
+ memberAttributionService,
61
+ sendEmailWithMagicLink,
62
+ labsService,
63
+ newslettersService,
64
+ sentry,
65
+ settingsCache
66
+ }) {
67
+ this._offersAPI = offersAPI;
68
+ this._paymentsService = paymentsService;
69
+ this._tiersService = tiersService;
70
+ this._memberRepository = memberRepository;
71
+ this._StripePrice = StripePrice;
72
+ this._allowSelfSignup = allowSelfSignup;
73
+ this._magicLinkService = magicLinkService;
74
+ this._stripeAPIService = stripeAPIService;
75
+ this._tokenService = tokenService;
76
+ this._sendEmailWithMagicLink = sendEmailWithMagicLink;
77
+ this._memberAttributionService = memberAttributionService;
78
+ this.labsService = labsService;
79
+ this._newslettersService = newslettersService;
80
+ this._sentry = sentry || undefined;
81
+ this._settingsCache = settingsCache;
82
+ }
83
+
84
+ async ensureStripe(_req, res, next) {
85
+ if (!this._stripeAPIService.configured) {
86
+ res.writeHead(400);
87
+ return res.end('Stripe not configured');
88
+ }
89
+ try {
90
+ await this._stripeAPIService.ready();
91
+ next();
92
+ } catch (err) {
93
+ logging.error(err);
94
+ res.writeHead(500);
95
+ return res.end('There was an error configuring stripe');
96
+ }
97
+ }
98
+
99
+ async createCheckoutSetupSession(req, res) {
100
+ const identity = req.body.identity;
101
+
102
+ if (!identity) {
103
+ res.writeHead(400);
104
+ return res.end();
105
+ }
106
+
107
+ let email;
108
+ try {
109
+ if (!identity) {
110
+ email = null;
111
+ } else {
112
+ const claims = await this._tokenService.decodeToken(identity);
113
+ email = claims && claims.sub;
114
+ }
115
+ } catch (err) {
116
+ logging.error(err);
117
+ res.writeHead(401);
118
+ return res.end('Unauthorized');
119
+ }
120
+
121
+ const member = email ? await this._memberRepository.get({email}) : null;
122
+
123
+ if (!member) {
124
+ res.writeHead(403);
125
+ return res.end('Bad Request.');
126
+ }
127
+
128
+ const subscriptions = await member.related('stripeSubscriptions').fetch();
129
+
130
+ const activeSubscription = subscriptions.models.find((sub) => {
131
+ return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.get('status'));
132
+ });
133
+
134
+ let currency = activeSubscription?.get('plan_currency') || undefined;
135
+
136
+ let customer;
137
+ if (!req.body.subscription_id) {
138
+ customer = await this._stripeAPIService.getCustomerForMemberCheckoutSession(member);
139
+ } else {
140
+ const subscription = subscriptions.models.find((sub) => {
141
+ return sub.get('subscription_id') === req.body.subscription_id;
142
+ });
143
+
144
+ if (!subscription) {
145
+ res.writeHead(404, {
146
+ 'Content-Type': 'text/plain;charset=UTF-8'
147
+ });
148
+ return res.end(`Could not find subscription ${req.body.subscription_id}`);
149
+ }
150
+ currency = subscription.get('plan_currency') || undefined;
151
+ customer = await this._stripeAPIService.getCustomer(subscription.get('customer_id'));
152
+ }
153
+
154
+ const session = await this._stripeAPIService.createCheckoutSetupSession(customer, {
155
+ successUrl: req.body.successUrl,
156
+ cancelUrl: req.body.cancelUrl,
157
+ subscription_id: req.body.subscription_id,
158
+ currency
159
+ });
160
+ const publicKey = this._stripeAPIService.getPublicKey();
161
+ const sessionInfo = {
162
+ sessionId: session.id,
163
+ publicKey
164
+ };
165
+ res.writeHead(200, {
166
+ 'Content-Type': 'application/json'
167
+ });
168
+
169
+ res.end(JSON.stringify(sessionInfo));
170
+ }
171
+
172
+ async _setAttributionMetadata(metadata) {
173
+ // Don't allow to set the source manually
174
+ delete metadata.attribution_id;
175
+ delete metadata.attribution_url;
176
+ delete metadata.attribution_type;
177
+ delete metadata.referrer_source;
178
+ delete metadata.referrer_medium;
179
+ delete metadata.referrer_url;
180
+
181
+ if (metadata.urlHistory) {
182
+ // The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values)
183
+ // So we need to add top-level attributes with string values
184
+ const urlHistory = metadata.urlHistory;
185
+ delete metadata.urlHistory;
186
+
187
+ const attribution = await this._memberAttributionService.getAttribution(urlHistory);
188
+
189
+ // Don't set null properties
190
+ if (attribution.id) {
191
+ metadata.attribution_id = attribution.id;
192
+ }
193
+
194
+ if (attribution.url) {
195
+ metadata.attribution_url = attribution.url;
196
+ }
197
+
198
+ if (attribution.type) {
199
+ metadata.attribution_type = attribution.type;
200
+ }
201
+
202
+ if (attribution.referrerSource) {
203
+ metadata.referrer_source = attribution.referrerSource;
204
+ }
205
+
206
+ if (attribution.referrerMedium) {
207
+ metadata.referrer_medium = attribution.referrerMedium;
208
+ }
209
+
210
+ if (attribution.referrerUrl) {
211
+ metadata.referrer_url = attribution.referrerUrl;
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Read the passed tier, offer and cadence from the request body and return the corresponding objects, or throws if validation fails
218
+ * @returns
219
+ */
220
+ async _getSubscriptionCheckoutData(body) {
221
+ const tierId = body.tierId;
222
+ const offerId = body.offerId;
223
+
224
+ let cadence = body.cadence;
225
+ let tier;
226
+ let offer;
227
+
228
+ // Validate basic input
229
+ if (!offerId && !tierId) {
230
+ logging.error('[RouterController._getSubscriptionCheckoutData] Expected offerId or tierId, received none');
231
+ throw new BadRequestError({
232
+ message: tpl(messages.badRequest),
233
+ context: 'Expected offerId or tierId, received none'
234
+ });
235
+ }
236
+
237
+ if (offerId && tierId) {
238
+ logging.error('[RouterController._getSubscriptionCheckoutData] Expected offerId or tierId, received both');
239
+ throw new BadRequestError({
240
+ message: tpl(messages.badRequest),
241
+ context: 'Expected offerId or tierId, received both'
242
+ });
243
+ }
244
+
245
+ if (tierId && !cadence) {
246
+ logging.error('[RouterController._getSubscriptionCheckoutData] Expected cadence to be "month" or "year", received ', cadence);
247
+ throw new BadRequestError({
248
+ message: tpl(messages.badRequest),
249
+ context: 'Expected cadence to be "month" or "year", received ' + cadence
250
+ });
251
+ }
252
+
253
+ if (tierId && cadence && cadence !== 'month' && cadence !== 'year') {
254
+ logging.error('[RouterController._getSubscriptionCheckoutData] Expected cadence to be "month" or "year", received ', cadence);
255
+ throw new BadRequestError({
256
+ message: tpl(messages.badRequest),
257
+ context: 'Expected cadence to be "month" or "year", received "' + cadence + '"'
258
+ });
259
+ }
260
+
261
+ // Fetch tier and offer
262
+ if (offerId) {
263
+ offer = await this._offersAPI.getOffer({id: offerId});
264
+
265
+ if (!offer) {
266
+ throw new BadRequestError({
267
+ message: tpl(messages.offerNotFound),
268
+ context: 'Offer with id "' + offerId + '" not found'
269
+ });
270
+ }
271
+
272
+ tier = await this._tiersService.api.read(offer.tier.id);
273
+ cadence = offer.cadence;
274
+ } else if (tierId) {
275
+ offer = null;
276
+
277
+ try {
278
+ // If the tierId is not a valid ID, the following line will throw
279
+ tier = await this._tiersService.api.read(tierId);
280
+
281
+ if (!tier) {
282
+ throw undefined;
283
+ }
284
+ } catch (err) {
285
+ logging.error(err);
286
+ this._sentry?.captureException?.(err);
287
+ throw new BadRequestError({
288
+ message: tpl(messages.tierNotFound),
289
+ context: 'Tier with id "' + tierId + '" not found'
290
+ });
291
+ }
292
+ }
293
+
294
+ if (tier.status === 'archived') {
295
+ throw new NoPermissionError({
296
+ message: tpl(messages.tierArchived)
297
+ });
298
+ }
299
+
300
+ return {
301
+ tier,
302
+ offer,
303
+ cadence
304
+ };
305
+ }
306
+
307
+ /**
308
+ *
309
+ * @param {object} options
310
+ * @param {object} options.tier
311
+ * @param {object} [options.offer]
312
+ * @param {string} options.cadence
313
+ * @param {string} options.successUrl URL to redirect to after successful checkout
314
+ * @param {string} options.cancelUrl URL to redirect to after cancelled checkout
315
+ * @param {string} [options.email] Email address of the customer
316
+ * @param {object} [options.member] Currently authenticated member OR member associated with the email address
317
+ * @param {boolean} options.isAuthenticated
318
+ * @param {object} options.metadata Metadata to be passed to Stripe
319
+ * @returns
320
+ */
321
+ async _createSubscriptionCheckoutSession(options) {
322
+ if (options.offer) {
323
+ // Attach offer information to stripe metadata for free trial offers
324
+ // free trial offers don't have associated stripe coupons
325
+ options.metadata.offer = options.offer.id;
326
+ }
327
+
328
+ if (!options.member && options.email) {
329
+ // Create a signup link if there is no member with this email address
330
+ options.successUrl = await this._magicLinkService.getMagicLink({
331
+ tokenData: {
332
+ email: options.email,
333
+ attribution: {
334
+ id: options.metadata.attribution_id ?? null,
335
+ type: options.metadata.attribution_type ?? null,
336
+ url: options.metadata.attribution_url ?? null
337
+ }
338
+ },
339
+ type: 'signup',
340
+ // Redirect to the original success url after sign up
341
+ referrer: options.successUrl
342
+ });
343
+ }
344
+
345
+ const restrictCheckout = options.member?.get('status') === 'paid';
346
+
347
+ if (restrictCheckout) {
348
+ // This member is already subscribed to a paid tier
349
+ // We don't want to create a duplicate subscription
350
+ if (!options.isAuthenticated && options.email) {
351
+ try {
352
+ await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'});
353
+ } catch (err) {
354
+ logging.warn(err);
355
+ }
356
+ }
357
+ throw new NoPermissionError({
358
+ message: messages.existingSubscription,
359
+ code: 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION'
360
+ });
361
+ }
362
+
363
+ try {
364
+ const paymentLink = await this._paymentsService.getPaymentLink(options);
365
+
366
+ return {url: paymentLink};
367
+ } catch (err) {
368
+ logging.error(err);
369
+ this._sentry?.captureException?.(err);
370
+ throw new BadRequestError({
371
+ err,
372
+ message: tpl(messages.unableToCheckout)
373
+ });
374
+ }
375
+ }
376
+
377
+ /**
378
+ *
379
+ * @param {object} options
380
+ * @param {string} options.successUrl URL to redirect to after successful checkout
381
+ * @param {string} options.cancelUrl URL to redirect to after cancelled checkout
382
+ * @param {string} [options.email] Email address of the customer
383
+ * @param {object} [options.member] Currently authenticated member OR member associated with the email address
384
+ * @param {boolean} options.isAuthenticated
385
+ * @param {object} options.metadata Metadata to be passed to Stripe
386
+ * @returns
387
+ */
388
+ async _createDonationCheckoutSession(options) {
389
+ if (!this._paymentsService.stripeAPIService.configured) {
390
+ throw new DisabledFeatureError({
391
+ message: tpl(messages.notConfigured)
392
+ });
393
+ }
394
+
395
+ try {
396
+ const paymentLink = await this._paymentsService.getDonationPaymentLink(options);
397
+
398
+ return {url: paymentLink};
399
+ } catch (err) {
400
+ logging.error(err);
401
+ this._sentry?.captureException?.(err);
402
+ throw new BadRequestError({
403
+ err,
404
+ message: tpl(messages.unableToCheckout)
405
+ });
406
+ }
407
+ }
408
+
409
+ async createCheckoutSession(req, res) {
410
+ const type = req.body.type ?? 'subscription';
411
+ const metadata = req.body.metadata ?? {};
412
+ const identity = req.body.identity;
413
+ const membersEnabled = true;
414
+
415
+ // Check this checkout type is supported
416
+ if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) {
417
+ throw new BadRequestError({
418
+ message: tpl(messages.invalidType)
419
+ });
420
+ }
421
+
422
+ // Optional authentication
423
+ let member;
424
+ let isAuthenticated = false;
425
+ if (membersEnabled) {
426
+ if (identity) {
427
+ try {
428
+ const claims = await this._tokenService.decodeToken(identity);
429
+ const email = claims && claims.sub;
430
+ if (email) {
431
+ member = await this._memberRepository.get({
432
+ email
433
+ }, {
434
+ withRelated: ['stripeCustomers', 'products']
435
+ });
436
+ isAuthenticated = true;
437
+ }
438
+ } catch (err) {
439
+ logging.error(err);
440
+ this._sentry?.captureException?.(err);
441
+ throw new UnauthorizedError({err});
442
+ }
443
+ } else if (req.body.customerEmail) {
444
+ member = await this._memberRepository.get({
445
+ email: req.body.customerEmail
446
+ }, {
447
+ withRelated: ['stripeCustomers', 'products']
448
+ });
449
+ }
450
+ }
451
+
452
+ // Store attribution data in the metadata
453
+ await this._setAttributionMetadata(metadata);
454
+
455
+ // Build options
456
+ const options = {
457
+ successUrl: req.body.successUrl,
458
+ cancelUrl: req.body.cancelUrl,
459
+ email: req.body.customerEmail,
460
+ member,
461
+ metadata,
462
+ isAuthenticated
463
+ };
464
+
465
+ let response;
466
+ if (type === 'subscription') {
467
+ if (!membersEnabled) {
468
+ throw new BadRequestError({
469
+ message: tpl(messages.badRequest)
470
+ });
471
+ }
472
+
473
+ // Get selected tier, offer and cadence
474
+ const data = await this._getSubscriptionCheckoutData(req.body);
475
+
476
+ // Check the checkout session
477
+ response = await this._createSubscriptionCheckoutSession({
478
+ ...options,
479
+ ...data
480
+ });
481
+ } else if (type === 'donation') {
482
+ options.personalNote = parsePersonalNote(req.body.personalNote);
483
+ response = await this._createDonationCheckoutSession(options);
484
+ }
485
+
486
+ res.writeHead(200, {
487
+ 'Content-Type': 'application/json'
488
+ });
489
+
490
+ return res.end(JSON.stringify(response));
491
+ }
492
+
493
+ async sendMagicLink(req, res) {
494
+ const {email, honeypot, autoRedirect} = req.body;
495
+ let {emailType, redirect} = req.body;
496
+
497
+ let referrer = req.get('referer');
498
+ if (autoRedirect === false){
499
+ referrer = null;
500
+ }
501
+ if (redirect) {
502
+ try {
503
+ // Validate URL
504
+ referrer = new URL(redirect).href;
505
+ } catch (e) {
506
+ logging.warn(e);
507
+ }
508
+ }
509
+
510
+ if (!email) {
511
+ throw new errors.BadRequestError({
512
+ message: tpl(messages.emailRequired)
513
+ });
514
+ }
515
+
516
+ if (!isEmail(email)) {
517
+ throw new errors.BadRequestError({
518
+ message: tpl(messages.invalidEmail)
519
+ });
520
+ }
521
+
522
+ if (honeypot) {
523
+ logging.warn('Honeypot field filled, this is likely a bot');
524
+
525
+ // Honeypot field is filled, this is a bot.
526
+ // Pretend that the email was sent successfully.
527
+ res.writeHead(201);
528
+ return res.end('Created.');
529
+ }
530
+
531
+ if (!emailType) {
532
+ // Default to subscribe form that also allows to login (safe fallback for older clients)
533
+ emailType = 'subscribe';
534
+ }
535
+
536
+ if (!['signin', 'signup', 'subscribe'].includes(emailType)) {
537
+ res.writeHead(400);
538
+ return res.end('Bad Request.');
539
+ }
540
+
541
+ try {
542
+ if (emailType === 'signup' || emailType === 'subscribe') {
543
+ await this._handleSignup(req, referrer);
544
+ } else {
545
+ await this._handleSignin(req, referrer);
546
+ }
547
+
548
+ res.writeHead(201);
549
+ return res.end('Created.');
550
+ } catch (err) {
551
+ if (err.code === 'EENVELOPE') {
552
+ logging.error(err);
553
+ res.writeHead(400);
554
+ return res.end('Bad Request.');
555
+ }
556
+ logging.error(err);
557
+
558
+ // Let the normal error middleware handle this error
559
+ throw err;
560
+ }
561
+ }
562
+
563
+ async _handleSignup(req, referrer = null) {
564
+ if (!this._allowSelfSignup()) {
565
+ if (this._settingsCache.get('members_signup_access') === 'paid') {
566
+ throw new errors.BadRequestError({
567
+ message: tpl(messages.paidOnly)
568
+ });
569
+ } else {
570
+ throw new errors.BadRequestError({
571
+ message: tpl(messages.inviteOnly)
572
+ });
573
+ }
574
+ }
575
+
576
+ const blockedEmailDomains = this._settingsCache.get('all_blocked_email_domains');
577
+ const emailDomain = req.body.email.split('@')[1]?.toLowerCase();
578
+ if (emailDomain && blockedEmailDomains.includes(emailDomain)) {
579
+ throw new errors.BadRequestError({
580
+ message: tpl(messages.blockedEmailDomain)
581
+ });
582
+ }
583
+
584
+ const {email, emailType} = req.body;
585
+
586
+ const tokenData = {
587
+ labels: req.body.labels,
588
+ name: req.body.name,
589
+ reqIp: req.ip ?? undefined,
590
+ newsletters: await this._validateNewsletters(req),
591
+ attribution: await this._memberAttributionService.getAttribution(req.body.urlHistory)
592
+ };
593
+
594
+ return await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer});
595
+ }
596
+
597
+ async _handleSignin(req, referrer = null) {
598
+ const {email, emailType} = req.body;
599
+
600
+ const member = await this._memberRepository.get({email});
601
+
602
+ if (!member) {
603
+ throw new errors.BadRequestError({
604
+ message: this._allowSelfSignup() ? tpl(messages.memberNotFoundSignUp) : tpl(messages.memberNotFound)
605
+ });
606
+ }
607
+
608
+ const tokenData = {};
609
+ return await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer});
610
+ }
611
+
612
+ async _validateNewsletters(req) {
613
+ const {newsletters: requestedNewsletters} = req.body;
614
+
615
+ if (requestedNewsletters && requestedNewsletters.length > 0 && requestedNewsletters.every(newsletter => newsletter.name !== undefined)) {
616
+ const newsletterNames = requestedNewsletters.map(newsletter => newsletter.name);
617
+ const newsletterNamesFilter = newsletterNames.map(newsletter => `'${newsletter.replace(/("|')/g, '\\$1')}'`);
618
+ const newsletters = (await this._newslettersService.getAll({
619
+ filter: `name:[${newsletterNamesFilter}]`,
620
+ columns: ['id','name','status']
621
+ }));
622
+
623
+ // Check for invalid newsletters
624
+ if (newsletters.length !== newsletterNames.length) {
625
+ const validNewsletters = newsletters.map(newsletter => newsletter.name);
626
+ const invalidNewsletters = newsletterNames.filter(newsletter => !validNewsletters.includes(newsletter));
627
+
628
+ throw new errors.BadRequestError({
629
+ message: tpl(messages.invalidNewsletters, {newsletters: invalidNewsletters})
630
+ });
631
+ }
632
+
633
+ // Check for archived newsletters
634
+ const archivedNewsletters = newsletters
635
+ .filter(newsletter => newsletter.status === 'archived')
636
+ .map(newsletter => newsletter.name);
637
+
638
+ if (archivedNewsletters && archivedNewsletters.length > 0) {
639
+ throw new errors.BadRequestError({
640
+ message: tpl(messages.archivedNewsletters, {newsletters: archivedNewsletters})
641
+ });
642
+ }
643
+
644
+ return newsletters
645
+ .filter(newsletter => newsletter.status === 'active')
646
+ .map(newsletter => ({id: newsletter.id}));
647
+ }
648
+ }
649
+ };
650
+
651
+ function parsePersonalNote(rawText) {
652
+ if (rawText && typeof rawText !== 'string') {
653
+ logging.warn('Donation personal note is not a string, ignoring');
654
+ return '';
655
+ }
656
+ if (rawText && rawText.length > 255) {
657
+ logging.warn('Donation personal note is too long, ignoring:', rawText);
658
+ return '';
659
+ }
660
+
661
+ const safeInput = sanitizeHtml(rawText, {
662
+ allowedTags: [],
663
+ allowedAttributes: {}
664
+ });
665
+
666
+ return safeInput;
667
+ }