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.
- package/components/{tryghost-api-framework-5.117.0.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
- package/components/tryghost-constants-5.118.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
- package/components/tryghost-domain-events-5.118.0.tgz +0 -0
- package/components/tryghost-donations-5.118.0.tgz +0 -0
- package/components/{tryghost-email-addresses-5.117.0.tgz → tryghost-email-addresses-5.118.0.tgz} +0 -0
- package/components/{tryghost-email-service-5.117.0.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
- package/components/{tryghost-email-suppression-list-5.117.0.tgz → tryghost-email-suppression-list-5.118.0.tgz} +0 -0
- package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
- package/components/tryghost-i18n-5.118.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.117.0.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
- package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.117.0.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
- package/components/{tryghost-member-attribution-5.117.0.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
- package/components/tryghost-member-events-5.118.0.tgz +0 -0
- package/components/{tryghost-members-csv-5.117.0.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.117.0.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
- package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
- package/components/tryghost-post-events-5.118.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
- package/components/{tryghost-posts-service-5.117.0.tgz → tryghost-posts-service-5.118.0.tgz} +0 -0
- package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
- package/components/tryghost-security-5.118.0.tgz +0 -0
- package/components/tryghost-tiers-5.118.0.tgz +0 -0
- package/components/tryghost-webmentions-5.118.0.tgz +0 -0
- package/content/themes/casper/LICENSE +1 -1
- package/content/themes/casper/README.md +1 -1
- package/content/themes/casper/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +1 -1
- package/content/themes/casper/author.hbs +23 -2
- package/content/themes/casper/package.json +2 -2
- package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
- package/content/themes/casper/partials/icons/instagram.hbs +5 -0
- package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
- package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
- package/content/themes/casper/partials/icons/threads.hbs +3 -0
- package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
- package/content/themes/casper/partials/icons/twitter.hbs +3 -1
- package/content/themes/casper/partials/icons/youtube.hbs +3 -0
- package/content/themes/source/LICENSE +1 -1
- package/content/themes/source/README.md +1 -1
- package/content/themes/source/assets/built/screen.css +1 -1
- package/content/themes/source/assets/built/screen.css.map +1 -1
- package/content/themes/source/assets/css/screen.css +7 -12
- package/content/themes/source/author.hbs +24 -3
- package/content/themes/source/package.json +2 -2
- package/content/themes/source/partials/feature-image.hbs +2 -2
- package/content/themes/source/partials/icons/bluesky.hbs +3 -0
- package/content/themes/source/partials/icons/instagram.hbs +5 -0
- package/content/themes/source/partials/icons/linkedin.hbs +3 -0
- package/content/themes/source/partials/icons/mastodon.hbs +3 -0
- package/content/themes/source/partials/icons/threads.hbs +3 -0
- package/content/themes/source/partials/icons/tiktok.hbs +3 -0
- package/content/themes/source/partials/icons/youtube.hbs +3 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31773 -26586
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-3bc05d1b.mjs → CodeEditorView-1143c509.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-c5623145.mjs → index-19ebc8ad.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-b2cdc747.mjs → index-ac104f42.mjs} +2559 -2551
- package/core/built/admin/assets/admin-x-settings/{modals-fd7bc70c.mjs → modals-994901ee.mjs} +6680 -6165
- package/core/built/admin/assets/{chunk.524.f4d7526780f546c5fc0b.js → chunk.524.5710919eb507b9a81166.js} +6 -6
- package/core/built/admin/assets/{chunk.582.869c66dfbfa68412de07.js → chunk.582.c8cb99b85cfa13fc7df1.js} +8 -8
- package/core/built/admin/assets/{ghost-45186e4f079c9fdd8f42dfbfb93d3344.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +803 -800
- package/core/built/admin/assets/posts/posts.js +11542 -11341
- package/core/built/admin/assets/stats/stats.js +37309 -31520
- package/core/built/admin/index.html +3 -3
- package/core/frontend/helpers/social_url.js +31 -0
- package/core/server/api/endpoints/users.js +7 -0
- package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
- package/core/server/services/auth/session/index.js +5 -2
- package/core/server/services/auth/session/session-service.js +5 -4
- package/core/server/services/members/api.js +2 -2
- package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
- package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
- package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
- package/core/server/services/members/members-api/members-api.js +404 -0
- package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
- package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
- package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
- package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
- package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
- package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
- package/core/server/services/members/members-api/services/TokenService.js +54 -0
- package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
- package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
- package/core/server/services/milestones/Milestone.js +231 -0
- package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
- package/core/server/services/milestones/MilestonesService.js +327 -0
- package/core/server/services/milestones/service.js +2 -2
- package/core/server/services/newsletters/index.js +1 -1
- package/core/server/services/public-config/config.js +2 -1
- package/core/server/services/settings/settings-service.js +1 -1
- package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
- package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
- package/core/server/services/staff/StaffService.js +1 -1
- package/core/shared/config/defaults.json +3 -0
- package/core/shared/config/env/config.testing-mysql.json +3 -0
- package/core/shared/config/env/config.testing.json +3 -0
- package/core/shared/labs.js +3 -4
- package/package.json +63 -63
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +86 -44
- package/components/tryghost-constants-5.117.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.117.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.117.0.tgz +0 -0
- package/components/tryghost-domain-events-5.117.0.tgz +0 -0
- package/components/tryghost-donations-5.117.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.117.0.tgz +0 -0
- package/components/tryghost-i18n-5.117.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.117.0.tgz +0 -0
- package/components/tryghost-member-events-5.117.0.tgz +0 -0
- package/components/tryghost-members-api-5.117.0.tgz +0 -0
- package/components/tryghost-milestones-5.117.0.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.117.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.117.0.tgz +0 -0
- package/components/tryghost-post-events-5.117.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.117.0.tgz +0 -0
- package/components/tryghost-prometheus-metrics-5.117.0.tgz +0 -0
- package/components/tryghost-security-5.117.0.tgz +0 -0
- package/components/tryghost-tiers-5.117.0.tgz +0 -0
- 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
|
+
}
|