ghost 5.114.1 → 5.115.1
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-adapter-cache-redis-5.115.1.tgz +0 -0
- package/components/{tryghost-adapter-manager-5.114.1.tgz → tryghost-adapter-manager-5.115.1.tgz} +0 -0
- package/components/{tryghost-announcement-bar-settings-5.114.1.tgz → tryghost-announcement-bar-settings-5.115.1.tgz} +0 -0
- package/components/{tryghost-api-framework-5.114.1.tgz → tryghost-api-framework-5.115.1.tgz} +0 -0
- package/components/tryghost-constants-5.115.1.tgz +0 -0
- package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
- package/components/{tryghost-custom-theme-settings-service-5.114.1.tgz → tryghost-custom-theme-settings-service-5.115.1.tgz} +0 -0
- package/components/{tryghost-data-generator-5.114.1.tgz → tryghost-data-generator-5.115.1.tgz} +0 -0
- package/components/{tryghost-domain-events-5.114.1.tgz → tryghost-domain-events-5.115.1.tgz} +0 -0
- package/components/tryghost-donations-5.115.1.tgz +0 -0
- package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
- package/components/{tryghost-email-content-generator-5.114.1.tgz → tryghost-email-content-generator-5.115.1.tgz} +0 -0
- package/components/tryghost-email-events-5.115.1.tgz +0 -0
- package/components/tryghost-email-service-5.115.1.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
- package/components/tryghost-ghost-5.115.1.tgz +0 -0
- package/components/{tryghost-html-to-plaintext-5.114.1.tgz → tryghost-html-to-plaintext-5.115.1.tgz} +0 -0
- package/components/tryghost-i18n-5.115.1.tgz +0 -0
- package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
- package/components/{tryghost-job-manager-5.114.1.tgz → tryghost-job-manager-5.115.1.tgz} +0 -0
- package/components/{tryghost-link-redirects-5.114.1.tgz → tryghost-link-redirects-5.115.1.tgz} +0 -0
- package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
- package/components/{tryghost-magic-link-5.114.1.tgz → tryghost-magic-link-5.115.1.tgz} +0 -0
- package/components/{tryghost-mailgun-client-5.114.1.tgz → tryghost-mailgun-client-5.115.1.tgz} +0 -0
- package/components/tryghost-member-attribution-5.115.1.tgz +0 -0
- package/components/{tryghost-member-events-5.114.1.tgz → tryghost-member-events-5.115.1.tgz} +0 -0
- package/components/{tryghost-members-api-5.114.1.tgz → tryghost-members-api-5.115.1.tgz} +0 -0
- package/components/{tryghost-members-csv-5.114.1.tgz → tryghost-members-csv-5.115.1.tgz} +0 -0
- package/components/{tryghost-members-offers-5.114.1.tgz → tryghost-members-offers-5.115.1.tgz} +0 -0
- package/components/{tryghost-members-payments-5.114.1.tgz → tryghost-members-payments-5.115.1.tgz} +0 -0
- package/components/{tryghost-milestones-5.114.1.tgz → tryghost-milestones-5.115.1.tgz} +0 -0
- package/components/tryghost-minifier-5.115.1.tgz +0 -0
- package/components/{tryghost-mw-error-handler-5.114.1.tgz → tryghost-mw-error-handler-5.115.1.tgz} +0 -0
- package/components/{tryghost-mw-version-match-5.114.1.tgz → tryghost-mw-version-match-5.115.1.tgz} +0 -0
- package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
- package/components/{tryghost-post-events-5.114.1.tgz → tryghost-post-events-5.115.1.tgz} +0 -0
- package/components/{tryghost-post-revisions-5.114.1.tgz → tryghost-post-revisions-5.115.1.tgz} +0 -0
- package/components/{tryghost-posts-service-5.114.1.tgz → tryghost-posts-service-5.115.1.tgz} +0 -0
- package/components/{tryghost-prometheus-metrics-5.114.1.tgz → tryghost-prometheus-metrics-5.115.1.tgz} +0 -0
- package/components/tryghost-recommendations-5.115.1.tgz +0 -0
- package/components/{tryghost-security-5.114.1.tgz → tryghost-security-5.115.1.tgz} +0 -0
- package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
- package/components/{tryghost-tiers-5.114.1.tgz → tryghost-tiers-5.115.1.tgz} +0 -0
- package/components/{tryghost-webmentions-5.114.1.tgz → tryghost-webmentions-5.115.1.tgz} +0 -0
- package/content/themes/casper/LICENSE +1 -1
- package/content/themes/casper/README.md +1 -1
- 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 +11 -6
- package/content/themes/source/partials/feature-image.hbs +2 -2
- package/core/boot.js +3 -1
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +23497 -23041
- package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
- package/core/built/admin/assets/admin-x-demo/{index-0040480a.mjs → index-15df2af5.mjs} +4 -3
- package/core/built/admin/assets/admin-x-demo/{modals-fb35c86c.mjs → modals-8ca61d78.mjs} +67 -65
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-806ef39c.mjs → CodeEditorView-d2e6872f.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-376f847c.mjs → index-8e8821e5.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-8fa19303.mjs → index-f5cb3db3.mjs} +3104 -3094
- package/core/built/admin/assets/admin-x-settings/{modals-36775d71.mjs → modals-e8ae4d46.mjs} +3 -3
- package/core/built/admin/assets/{chunk.524.85c5b32bd46b91c147b9.js → chunk.524.2439684964c164c598ab.js} +7 -7
- package/core/built/admin/assets/{chunk.582.449a129a8005f03574bd.js → chunk.582.bf5a2bbb2c4eb69ef1e7.js} +10 -10
- package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +1 -0
- package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +1 -0
- package/core/built/admin/assets/{ghost-c563138cc2c0767bf6eefc9a2587eaa4.js → ghost-df7b9558260aa27d18b195ee895b487d.js} +182 -160
- package/core/built/admin/assets/stats/stats.js +11824 -0
- package/core/built/admin/index.html +4 -4
- package/core/frontend/helpers/ghost_head.js +3 -1
- package/core/frontend/src/cards/css/cta.css +1 -1
- package/core/server/api/endpoints/slugs.js +6 -2
- package/core/server/data/importer/import-manager.js +2 -2
- package/core/server/data/importer/importers/importer-revue.js +128 -0
- package/core/server/data/importer/importers/json-to-html.js +107 -0
- package/core/server/data/migrations/utils/tables.js +2 -4
- package/core/server/data/migrations/versions/5.115/2025-03-24-07-19-27-add-identity-read-permission-to-administrators.js +6 -0
- package/core/server/data/schema/fixtures/fixtures.json +2 -1
- package/core/server/lib/bootstrap-socket.js +87 -0
- package/core/server/lib/package-json/index.js +1 -0
- package/core/server/lib/package-json/package-json.js +160 -0
- package/core/server/lib/package-json/parse.js +57 -0
- package/core/server/models/base/plugins/actions.js +44 -31
- package/core/server/models/base/plugins/generate-slug.js +6 -0
- package/core/server/notify.js +1 -1
- package/core/server/services/activitypub/ActivityPubService.ts +1 -1
- package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +99 -0
- package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +80 -0
- package/core/server/services/api-version-compatibility/extract-api-key.js +57 -0
- package/core/server/services/api-version-compatibility/index.js +2 -2
- package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +31 -0
- package/core/server/services/audience-feedback/AudienceFeedbackController.js +85 -0
- package/core/server/services/audience-feedback/AudienceFeedbackService.js +34 -0
- package/core/server/services/audience-feedback/Feedback.js +35 -0
- package/core/server/services/audience-feedback/index.js +4 -2
- package/core/server/services/auth/session/emails/signin.js +168 -0
- package/core/server/services/auth/session/index.js +2 -2
- package/core/server/services/auth/session/session-from-token.js +69 -0
- package/core/server/services/auth/session/session-service.js +364 -0
- package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +62 -0
- package/core/server/services/email-analytics/EmailAnalyticsService.js +552 -0
- package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +3 -3
- package/core/server/services/email-analytics/EventProcessingResult.js +66 -0
- package/core/server/services/explore-ping/ExplorePingService.js +106 -0
- package/core/server/services/explore-ping/index.js +31 -0
- package/core/server/services/identity-tokens/IdentityTokenService.js +30 -0
- package/core/server/services/identity-tokens/IdentityTokenService.ts +28 -0
- package/core/server/services/identity-tokens/IdentityTokenServiceWrapper.js +1 -1
- package/core/server/services/invitations/accept.js +5 -2
- package/core/server/services/mail-events/BookshelfMailEventRepository.js +2 -2
- package/core/server/services/mail-events/InMemoryMailEventRepository.js +10 -0
- package/core/server/services/mail-events/InMemoryMailEventRepository.ts +8 -0
- package/core/server/services/mail-events/MailEvent.js +20 -0
- package/core/server/services/mail-events/MailEvent.ts +10 -0
- package/core/server/services/mail-events/MailEventRepository.js +2 -0
- package/core/server/services/mail-events/MailEventRepository.ts +5 -0
- package/core/server/services/mail-events/MailEventService.js +124 -0
- package/core/server/services/mail-events/MailEventService.ts +169 -0
- package/core/server/services/mail-events/index.js +1 -1
- package/core/server/services/mail-events/libraries.d.ts +2 -0
- package/core/server/services/members/CaptchaService.js +80 -0
- package/core/server/services/members/api.js +1 -1
- package/core/server/services/members/importer/MembersCSVImporter.js +464 -0
- package/core/server/services/members/importer/MembersCSVImporterStripeUtils.js +194 -0
- package/core/server/services/members/importer/email-template.js +182 -0
- package/core/server/services/members/importer/index.js +30 -0
- package/core/server/services/members/members-ssr.js +333 -0
- package/core/server/services/members/service.js +2 -2
- package/core/server/services/posts/stats/PostStats.js +13 -0
- package/core/server/services/route-settings/SettingsPathManager.js +47 -0
- package/core/server/services/route-settings/index.js +1 -1
- package/core/server/services/stripe/README.md +63 -0
- package/core/server/services/stripe/StripeAPI.js +931 -0
- package/core/server/services/stripe/StripeMigrations.js +613 -0
- package/core/server/services/stripe/StripeService.js +175 -0
- package/core/server/services/stripe/WebhookController.js +100 -0
- package/core/server/services/stripe/WebhookManager.js +175 -0
- package/core/server/services/stripe/events/StripeLiveDisabledEvent.js +23 -0
- package/core/server/services/stripe/events/StripeLiveEnabledEvent.js +23 -0
- package/core/server/services/stripe/events/index.js +4 -0
- package/core/server/services/stripe/service.js +1 -1
- package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +255 -0
- package/core/server/services/stripe/services/webhook/InvoiceEventService.js +70 -0
- package/core/server/services/stripe/services/webhook/SubscriptionEventService.js +54 -0
- package/core/server/services/themes/loader.js +1 -1
- package/core/server/services/themes/to-json.js +1 -1
- package/core/server/web/api/endpoints/admin/routes.js +1 -0
- package/core/server/web/shared/middleware/cache-control.js +51 -0
- package/core/server/web/shared/middleware/index.js +1 -1
- package/core/server/web/well-known.js +1 -1
- package/core/shared/labs.js +3 -1
- package/core/shared/settings-cache/CacheManager.js +64 -6
- package/package.json +103 -134
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +7 -93
- package/components/tryghost-adapter-cache-redis-5.114.1.tgz +0 -0
- package/components/tryghost-api-version-compatibility-service-5.114.1.tgz +0 -0
- package/components/tryghost-audience-feedback-5.114.1.tgz +0 -0
- package/components/tryghost-bookshelf-repository-5.114.1.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.114.1.tgz +0 -0
- package/components/tryghost-captcha-service-5.114.1.tgz +0 -0
- package/components/tryghost-constants-5.114.1.tgz +0 -0
- package/components/tryghost-custom-fonts-5.114.1.tgz +0 -0
- package/components/tryghost-donations-5.114.1.tgz +0 -0
- package/components/tryghost-email-addresses-5.114.1.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.114.1.tgz +0 -0
- package/components/tryghost-email-analytics-service-5.114.1.tgz +0 -0
- package/components/tryghost-email-events-5.114.1.tgz +0 -0
- package/components/tryghost-email-service-5.114.1.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.114.1.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.114.1.tgz +0 -0
- package/components/tryghost-extract-api-key-5.114.1.tgz +0 -0
- package/components/tryghost-ghost-5.114.1.tgz +0 -0
- package/components/tryghost-i18n-5.114.1.tgz +0 -0
- package/components/tryghost-identity-token-service-5.114.1.tgz +0 -0
- package/components/tryghost-importer-handler-content-files-5.114.1.tgz +0 -0
- package/components/tryghost-importer-revue-5.114.1.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.114.1.tgz +0 -0
- package/components/tryghost-link-replacer-5.114.1.tgz +0 -0
- package/components/tryghost-mail-events-5.114.1.tgz +0 -0
- package/components/tryghost-member-attribution-5.114.1.tgz +0 -0
- package/components/tryghost-members-importer-5.114.1.tgz +0 -0
- package/components/tryghost-members-ssr-5.114.1.tgz +0 -0
- package/components/tryghost-members-stripe-service-5.114.1.tgz +0 -0
- package/components/tryghost-minifier-5.114.1.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.114.1.tgz +0 -0
- package/components/tryghost-mw-cache-control-5.114.1.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.114.1.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.114.1.tgz +0 -0
- package/components/tryghost-mw-vhost-5.114.1.tgz +0 -0
- package/components/tryghost-package-json-5.114.1.tgz +0 -0
- package/components/tryghost-recommendations-5.114.1.tgz +0 -0
- package/components/tryghost-referrers-5.114.1.tgz +0 -0
- package/components/tryghost-session-service-5.114.1.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.114.1.tgz +0 -0
- package/components/tryghost-slack-notifications-5.114.1.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-5.114.1.tgz +0 -0
- package/core/built/admin/assets/ghost-c2a7c4a1b76550c4219adb2ed4124ce0.css +0 -1
- package/core/built/admin/assets/ghost-dark-f91e4a479c6d38d94d5d1b14727871dc.css +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const hcaptcha = require('hcaptcha');
|
|
2
|
+
const logging = require('@tryghost/logging');
|
|
3
|
+
const {InternalServerError, BadRequestError, utils: errorUtils} = require('@tryghost/errors');
|
|
4
|
+
|
|
5
|
+
class CaptchaService {
|
|
6
|
+
#enabled;
|
|
7
|
+
#scoreThreshold;
|
|
8
|
+
#secretKey;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {boolean} [options.enabled] Whether hCaptcha is enabled
|
|
13
|
+
* @param {number} [options.scoreThreshold] Score threshold for bot detection
|
|
14
|
+
* @param {string} [options.secretKey] hCaptcha secret key
|
|
15
|
+
*/
|
|
16
|
+
constructor({
|
|
17
|
+
enabled,
|
|
18
|
+
scoreThreshold,
|
|
19
|
+
secretKey
|
|
20
|
+
}) {
|
|
21
|
+
this.#enabled = enabled;
|
|
22
|
+
this.#secretKey = secretKey;
|
|
23
|
+
this.#scoreThreshold = scoreThreshold;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getMiddleware() {
|
|
27
|
+
const scoreThreshold = this.#scoreThreshold;
|
|
28
|
+
const secretKey = this.#secretKey;
|
|
29
|
+
|
|
30
|
+
if (!this.#enabled) {
|
|
31
|
+
return function captchaNoOpMiddleware(req, res, next) {
|
|
32
|
+
next();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return async function captchaMiddleware(req, res, next) {
|
|
37
|
+
let captchaResponse;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (!req.body || !req.body.token) {
|
|
41
|
+
throw new BadRequestError({
|
|
42
|
+
message: 'hCaptcha token missing'
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
captchaResponse = await hcaptcha.verify(secretKey, req.body.token, req.ip);
|
|
47
|
+
|
|
48
|
+
if ('score' in captchaResponse && captchaResponse.score < scoreThreshold) {
|
|
49
|
+
// Using hCaptcha enterprise, so score is present
|
|
50
|
+
next();
|
|
51
|
+
} else if (!('score' in captchaResponse) && captchaResponse.success) {
|
|
52
|
+
// Using regular hCaptcha, so challenge-based
|
|
53
|
+
next();
|
|
54
|
+
} else {
|
|
55
|
+
logging.error(`Blocking request due to high score (${captchaResponse.score})`);
|
|
56
|
+
|
|
57
|
+
// Intentionally left sparse to avoid leaking information
|
|
58
|
+
throw new InternalServerError();
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (errorUtils.isGhostError(err)) {
|
|
62
|
+
return next(err);
|
|
63
|
+
} else {
|
|
64
|
+
const message = 'Failed to verify hCaptcha token';
|
|
65
|
+
|
|
66
|
+
logging.error(new InternalServerError({
|
|
67
|
+
message,
|
|
68
|
+
err
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
return next(new InternalServerError({
|
|
72
|
+
message
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = CaptchaService;
|
|
@@ -18,7 +18,7 @@ const tiersService = require('../tiers');
|
|
|
18
18
|
const newslettersService = require('../newsletters');
|
|
19
19
|
const memberAttributionService = require('../member-attribution');
|
|
20
20
|
const emailSuppressionList = require('../email-suppression-list');
|
|
21
|
-
const CaptchaService = require('
|
|
21
|
+
const CaptchaService = require('./CaptchaService');
|
|
22
22
|
const {t} = require('../i18n');
|
|
23
23
|
const sentry = require('../../../shared/sentry');
|
|
24
24
|
const sharedConfig = require('../../../shared/config');
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
const moment = require('moment-timezone');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const metrics = require('@tryghost/metrics');
|
|
5
|
+
const membersCSV = require('@tryghost/members-csv');
|
|
6
|
+
const errors = require('@tryghost/errors');
|
|
7
|
+
const tpl = require('@tryghost/tpl');
|
|
8
|
+
const emailTemplate = require('./email-template');
|
|
9
|
+
const logging = require('@tryghost/logging');
|
|
10
|
+
|
|
11
|
+
const messages = {
|
|
12
|
+
filenameCollision: 'Filename already exists, please try again.',
|
|
13
|
+
freeMemberNotAllowedImportTier: 'You cannot import a free member with a specified tier.',
|
|
14
|
+
invalidImportTier: '"{tier}" is not a valid tier.'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// The key should correspond to a member model field (unless it's a special purpose field like 'complimentary_plan')
|
|
18
|
+
// the value should represent an allowed field name coming from user input
|
|
19
|
+
const DEFAULT_CSV_HEADER_MAPPING = {
|
|
20
|
+
email: 'email',
|
|
21
|
+
name: 'name',
|
|
22
|
+
note: 'note',
|
|
23
|
+
subscribed_to_emails: 'subscribed',
|
|
24
|
+
created_at: 'created_at',
|
|
25
|
+
complimentary_plan: 'complimentary_plan',
|
|
26
|
+
stripe_customer_id: 'stripe_customer_id',
|
|
27
|
+
labels: 'labels',
|
|
28
|
+
import_tier: 'import_tier'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} MembersCSVImporterOptions
|
|
33
|
+
* @property {string} storagePath - The path to store CSV's in before importing
|
|
34
|
+
* @property {Function} getTimezone - function returning currently configured timezone
|
|
35
|
+
* @property {() => Object} getMembersRepository - member model access instance for data access and manipulation
|
|
36
|
+
* @property {() => Promise<import('@tryghost/tiers/lib/Tier')>} getDefaultTier - async function returning default Member Tier
|
|
37
|
+
* @property {(string) => Promise<import('@tryghost/tiers/lib/Tier')>} getTierByName - async function returning Member Tier by name
|
|
38
|
+
* @property {Function} sendEmail - function sending an email
|
|
39
|
+
* @property {(string) => boolean} isSet - Method checking if specific feature is enabled
|
|
40
|
+
* @property {({job, offloaded, name}) => void} addJob - Method registering an async job
|
|
41
|
+
* @property {Object} knex - An instance of the Ghost Database connection
|
|
42
|
+
* @property {Function} urlFor - function generating urls
|
|
43
|
+
* @property {Object} context
|
|
44
|
+
* @property {Object} stripeUtils - An instance of MembersCSVImporterStripeUtils
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
module.exports = class MembersCSVImporter {
|
|
48
|
+
/**
|
|
49
|
+
* @param {MembersCSVImporterOptions} options
|
|
50
|
+
*/
|
|
51
|
+
constructor({storagePath, getTimezone, getMembersRepository, getDefaultTier, getTierByName, sendEmail, isSet, addJob, knex, urlFor, context, stripeUtils}) {
|
|
52
|
+
this._storagePath = storagePath;
|
|
53
|
+
this._getTimezone = getTimezone;
|
|
54
|
+
this._getMembersRepository = getMembersRepository;
|
|
55
|
+
this._getDefaultTier = getDefaultTier;
|
|
56
|
+
this._getTierByName = getTierByName;
|
|
57
|
+
this._sendEmail = sendEmail;
|
|
58
|
+
this._isSet = isSet;
|
|
59
|
+
this._addJob = addJob;
|
|
60
|
+
this._knex = knex;
|
|
61
|
+
this._urlFor = urlFor;
|
|
62
|
+
this._context = context;
|
|
63
|
+
this._stripeUtils = stripeUtils;
|
|
64
|
+
this._tierIdCache = new Map();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prepares a CSV file for import
|
|
69
|
+
* - Maps headers based on headerMapping, this allows for a non standard CSV
|
|
70
|
+
* to be imported, so long as a mapping exists between it and a standard CSV
|
|
71
|
+
* - Stores the CSV to be imported in the storagePath
|
|
72
|
+
* - Creates a MemberImport Job and associated MemberImportBatch's
|
|
73
|
+
*
|
|
74
|
+
* @param {string} inputFilePath - The path to the CSV to prepare
|
|
75
|
+
* @param {Object.<string, string>} [headerMapping] - An object whose keys are headers in the input CSV and values are the header to replace it with
|
|
76
|
+
* @param {Array<string>} [defaultLabels] - A list of labels to apply to every member
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<{filePath: string, batches: number, metadata: Object.<string, any>}>} - A promise resolving to the data including filePath of "prepared" CSV
|
|
79
|
+
*/
|
|
80
|
+
async prepare(inputFilePath, headerMapping, defaultLabels) {
|
|
81
|
+
headerMapping = headerMapping || DEFAULT_CSV_HEADER_MAPPING;
|
|
82
|
+
// @NOTE: investigate why is it "1" and do we even need this concept anymore?
|
|
83
|
+
const batchSize = 1;
|
|
84
|
+
|
|
85
|
+
const siteTimezone = this._getTimezone();
|
|
86
|
+
const currentTime = moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm:ss.SSS');
|
|
87
|
+
const outputFileName = `Members Import ${currentTime}.csv`;
|
|
88
|
+
const outputFilePath = path.join(this._storagePath, '/', outputFileName);
|
|
89
|
+
|
|
90
|
+
const pathExists = await fs.pathExists(outputFilePath);
|
|
91
|
+
|
|
92
|
+
if (pathExists) {
|
|
93
|
+
throw new errors.DataImportError({message: tpl(messages.filenameCollision)});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// completely rely on explicit user input for header mappings
|
|
97
|
+
const rows = await membersCSV.parse(inputFilePath, headerMapping, defaultLabels);
|
|
98
|
+
const columns = Object.keys(rows[0]);
|
|
99
|
+
const numberOfBatches = Math.ceil(rows.length / batchSize);
|
|
100
|
+
const mappedCSV = membersCSV.unparse(rows, columns);
|
|
101
|
+
|
|
102
|
+
const hasStripeData = !!(rows.find(function rowHasStripeData(row) {
|
|
103
|
+
return !!row.stripe_customer_id;
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
await fs.writeFile(outputFilePath, mappedCSV);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
filePath: outputFilePath,
|
|
110
|
+
batches: numberOfBatches,
|
|
111
|
+
metadata: {
|
|
112
|
+
hasStripeData
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Performs an import of a CSV file
|
|
119
|
+
*
|
|
120
|
+
* @param {string} filePath - the path to a "prepared" CSV file
|
|
121
|
+
*/
|
|
122
|
+
async perform(filePath) {
|
|
123
|
+
const performStart = Date.now();
|
|
124
|
+
const rows = await membersCSV.parse(filePath, DEFAULT_CSV_HEADER_MAPPING);
|
|
125
|
+
|
|
126
|
+
const defaultTier = await this._getDefaultTier();
|
|
127
|
+
const membersRepository = await this._getMembersRepository();
|
|
128
|
+
|
|
129
|
+
// Clear tier ID cache before each import in-case tiers have been updated since last import
|
|
130
|
+
this._tierIdCache.clear();
|
|
131
|
+
|
|
132
|
+
// Keep track of any Stripe prices created as a result of an import tier being specified so that they
|
|
133
|
+
// can be archived after the import has completed - This ensures the created Stripe prices cannot be re-used
|
|
134
|
+
// for future subscriptions
|
|
135
|
+
const archivableStripePriceIds = [];
|
|
136
|
+
|
|
137
|
+
const result = await rows.reduce(async (resultPromise, row) => {
|
|
138
|
+
const resultAccumulator = await resultPromise;
|
|
139
|
+
|
|
140
|
+
// Use doNotReject config to reject `executionPromise` on rollback
|
|
141
|
+
// https://github.com/knex/knex/blob/master/UPGRADING.md
|
|
142
|
+
const trx = await this._knex.transaction(undefined, {doNotRejectOnRollback: false});
|
|
143
|
+
const options = {
|
|
144
|
+
transacting: trx,
|
|
145
|
+
context: this._context
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// If the member is created in the future, set created_at to now
|
|
150
|
+
// Members created in the future will not appear in admin members list
|
|
151
|
+
// Refs https://github.com/TryGhost/Team/issues/2793
|
|
152
|
+
const createdAt = moment(row.created_at).isAfter(moment()) ? moment().toDate() : row.created_at;
|
|
153
|
+
const memberValues = {
|
|
154
|
+
email: row.email,
|
|
155
|
+
name: row.name,
|
|
156
|
+
note: row.note,
|
|
157
|
+
subscribed: row.subscribed,
|
|
158
|
+
created_at: createdAt,
|
|
159
|
+
labels: row.labels
|
|
160
|
+
};
|
|
161
|
+
const existingMember = await membersRepository.get({email: memberValues.email}, {
|
|
162
|
+
...options,
|
|
163
|
+
withRelated: ['labels', 'newsletters']
|
|
164
|
+
});
|
|
165
|
+
let member;
|
|
166
|
+
if (existingMember) {
|
|
167
|
+
const existingLabels = existingMember.related('labels') ? existingMember.related('labels').toJSON() : [];
|
|
168
|
+
const existingNewsletters = existingMember.related('newsletters');
|
|
169
|
+
|
|
170
|
+
// Preserve member's existing newsletter subscription preferences
|
|
171
|
+
if (existingNewsletters.length > 0 && memberValues.subscribed) {
|
|
172
|
+
memberValues.newsletters = existingNewsletters.toJSON();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If member does not have any subscriptions, assume they have previously unsubscribed
|
|
176
|
+
// and do not re-subscribe them
|
|
177
|
+
if (!existingNewsletters.length && memberValues.subscribed) {
|
|
178
|
+
memberValues.subscribed = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Don't overwrite name or note if they are blank in the file
|
|
182
|
+
if (!row.name) {
|
|
183
|
+
memberValues.name = existingMember.name;
|
|
184
|
+
}
|
|
185
|
+
if (!row.note) {
|
|
186
|
+
memberValues.note = existingMember.note;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
member = await membersRepository.update({
|
|
190
|
+
...memberValues,
|
|
191
|
+
labels: existingLabels.concat(memberValues.labels)
|
|
192
|
+
}, {
|
|
193
|
+
...options,
|
|
194
|
+
id: existingMember.id
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
member = await membersRepository.create(memberValues, Object.assign({}, options, {
|
|
198
|
+
context: {
|
|
199
|
+
import: true
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let importTierId;
|
|
205
|
+
if (row.import_tier) {
|
|
206
|
+
importTierId = await this.#getTierIdByName(row.import_tier);
|
|
207
|
+
|
|
208
|
+
if (!importTierId) {
|
|
209
|
+
throw new errors.DataImportError({
|
|
210
|
+
message: tpl(messages.invalidImportTier, {tier: row.import_tier})
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (row.stripe_customer_id && typeof row.stripe_customer_id === 'string') {
|
|
216
|
+
let stripeCustomerId;
|
|
217
|
+
|
|
218
|
+
// If 'auto' is passed, try to find the Stripe customer by email
|
|
219
|
+
if (row.stripe_customer_id.toLowerCase() === 'auto') {
|
|
220
|
+
stripeCustomerId = await membersRepository.getCustomerIdByEmail(row.email);
|
|
221
|
+
} else {
|
|
222
|
+
stripeCustomerId = row.stripe_customer_id;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (stripeCustomerId) {
|
|
226
|
+
if (row.import_tier) {
|
|
227
|
+
const {isNewStripePrice, stripePriceId} = await this._stripeUtils.forceStripeSubscriptionToProduct({
|
|
228
|
+
customer_id: stripeCustomerId,
|
|
229
|
+
product_id: importTierId
|
|
230
|
+
}, options);
|
|
231
|
+
|
|
232
|
+
if (isNewStripePrice) {
|
|
233
|
+
archivableStripePriceIds.push(stripePriceId);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await membersRepository.linkStripeCustomer({
|
|
238
|
+
customer_id: stripeCustomerId,
|
|
239
|
+
member_id: member.id
|
|
240
|
+
}, options);
|
|
241
|
+
}
|
|
242
|
+
} else if (row.complimentary_plan) {
|
|
243
|
+
const products = [];
|
|
244
|
+
|
|
245
|
+
if (row.import_tier) {
|
|
246
|
+
products.push({id: importTierId});
|
|
247
|
+
} else {
|
|
248
|
+
products.push({id: defaultTier.id.toString()});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await membersRepository.update({products}, {
|
|
252
|
+
...options,
|
|
253
|
+
id: member.id
|
|
254
|
+
});
|
|
255
|
+
} else if (row.import_tier) {
|
|
256
|
+
throw new errors.DataImportError({message: tpl(messages.freeMemberNotAllowedImportTier)});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await trx.commit();
|
|
260
|
+
return {
|
|
261
|
+
...resultAccumulator,
|
|
262
|
+
imported: resultAccumulator.imported + 1
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// The model layer can sometimes throw arrays of errors
|
|
266
|
+
const errorList = [].concat(error);
|
|
267
|
+
const errorMessage = errorList.map(({message}) => message).join(', ');
|
|
268
|
+
await trx.rollback();
|
|
269
|
+
return {
|
|
270
|
+
...resultAccumulator,
|
|
271
|
+
errors: [...resultAccumulator.errors, {
|
|
272
|
+
...row,
|
|
273
|
+
error: errorMessage
|
|
274
|
+
}]
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}, Promise.resolve({
|
|
278
|
+
imported: 0,
|
|
279
|
+
errors: []
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
await Promise.all(
|
|
283
|
+
archivableStripePriceIds.map(stripePriceId => this._stripeUtils.archivePrice(stripePriceId))
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
metrics.metric({
|
|
287
|
+
imported: result.imported,
|
|
288
|
+
errors: result.errors.length,
|
|
289
|
+
value: Date.now() - performStart
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
total: result.imported + result.errors.length,
|
|
294
|
+
...result
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
generateCompletionEmail(result, data) {
|
|
299
|
+
const siteUrl = new URL(this._urlFor('home', null, true));
|
|
300
|
+
const membersUrl = new URL('members', this._urlFor('admin', null, true));
|
|
301
|
+
if (data.importLabel) {
|
|
302
|
+
membersUrl.searchParams.set('label', data.importLabel.slug);
|
|
303
|
+
}
|
|
304
|
+
return emailTemplate({result, siteUrl, membersUrl, ...data});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
generateErrorCSV(result) {
|
|
308
|
+
const errorsWithFormattedMessages = result.errors.map((row) => {
|
|
309
|
+
const formattedError = row.error
|
|
310
|
+
.replace(
|
|
311
|
+
'Value in [members.email] cannot be blank.',
|
|
312
|
+
'Missing email address'
|
|
313
|
+
)
|
|
314
|
+
.replace(
|
|
315
|
+
'Value in [members.note] exceeds maximum length of 2000 characters.',
|
|
316
|
+
'"Note" exceeds maximum length of 2000 characters'
|
|
317
|
+
)
|
|
318
|
+
.replace(
|
|
319
|
+
'Value in [members.subscribed] must be one of true, false, 0 or 1.',
|
|
320
|
+
'Value in "Subscribed to emails" must be "true" or "false"'
|
|
321
|
+
)
|
|
322
|
+
.replace(
|
|
323
|
+
'Validation (isEmail) failed for email',
|
|
324
|
+
'Invalid email address'
|
|
325
|
+
)
|
|
326
|
+
.replace(
|
|
327
|
+
/No such customer:[^,]*/,
|
|
328
|
+
'Could not find Stripe customer'
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
...row,
|
|
333
|
+
error: formattedError
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
return membersCSV.unparse(errorsWithFormattedMessages);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Send email with attached CSV containing error rows info
|
|
341
|
+
*
|
|
342
|
+
* @param {Object} config
|
|
343
|
+
* @param {String} config.emailRecipient - email recipient for error file
|
|
344
|
+
* @param {String} config.emailSubject - email subject
|
|
345
|
+
* @param {String} config.emailContent - html content of email
|
|
346
|
+
* @param {String} config.errorCSV - error CSV content
|
|
347
|
+
* @param {Object} config.emailSubject - email subject
|
|
348
|
+
* @param {Object} config.importLabel -
|
|
349
|
+
* @param {String} config.importLabel.name - label name
|
|
350
|
+
*/
|
|
351
|
+
async sendErrorEmail({emailRecipient, emailSubject, emailContent, errorCSV, importLabel}) {
|
|
352
|
+
await this._sendEmail({
|
|
353
|
+
to: emailRecipient,
|
|
354
|
+
subject: emailSubject,
|
|
355
|
+
html: emailContent,
|
|
356
|
+
forceTextContent: true,
|
|
357
|
+
attachments: [{
|
|
358
|
+
filename: `${importLabel.name} - Errors.csv`,
|
|
359
|
+
content: errorCSV,
|
|
360
|
+
contentType: 'text/csv',
|
|
361
|
+
contentDisposition: 'attachment'
|
|
362
|
+
}]
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Processes CSV file and imports member&label records depending on the size of the imported set
|
|
369
|
+
*
|
|
370
|
+
* @param {Object} config
|
|
371
|
+
* @param {String} config.pathToCSV - path where imported csv with members records is stored
|
|
372
|
+
* @param {Object} config.headerMapping - mapping of CSV headers to member record fields
|
|
373
|
+
* @param {Object} [config.globalLabels] - labels to be applied to whole imported members set
|
|
374
|
+
* @param {Object} config.importLabel -
|
|
375
|
+
* @param {String} config.importLabel.name - label name
|
|
376
|
+
* @param {Object} config.user
|
|
377
|
+
* @param {String} config.user.email - calling user email
|
|
378
|
+
* @param {Object} config.LabelModel - instance of Ghosts Label model
|
|
379
|
+
* @param {Boolean} config.forceInline - allows to force performing imports not in a job (used in test environment)
|
|
380
|
+
* @param {{testImportThreshold: () => Promise<void>}} config.verificationTrigger
|
|
381
|
+
*/
|
|
382
|
+
async process({pathToCSV, headerMapping, globalLabels, importLabel, user, LabelModel, forceInline, verificationTrigger}) {
|
|
383
|
+
const meta = {};
|
|
384
|
+
const job = await this.prepare(pathToCSV, headerMapping, globalLabels);
|
|
385
|
+
|
|
386
|
+
meta.originalImportSize = job.batches;
|
|
387
|
+
|
|
388
|
+
if ((job.batches <= 500 && !job.metadata.hasStripeData) || forceInline) {
|
|
389
|
+
const result = await this.perform(job.filePath);
|
|
390
|
+
const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
|
|
391
|
+
await verificationTrigger.testImportThreshold();
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
meta: Object.assign(meta, {
|
|
395
|
+
stats: {
|
|
396
|
+
imported: result.imported,
|
|
397
|
+
invalid: result.errors
|
|
398
|
+
},
|
|
399
|
+
import_label: importLabelModel
|
|
400
|
+
})
|
|
401
|
+
};
|
|
402
|
+
} else {
|
|
403
|
+
const emailRecipient = user.email;
|
|
404
|
+
this._addJob({
|
|
405
|
+
job: async () => {
|
|
406
|
+
try {
|
|
407
|
+
const result = await this.perform(job.filePath);
|
|
408
|
+
const importLabelModel = result.imported ? await LabelModel.findOne(importLabel) : null;
|
|
409
|
+
const emailContent = this.generateCompletionEmail(result, {
|
|
410
|
+
emailRecipient,
|
|
411
|
+
importLabel: importLabelModel ? importLabelModel.toJSON() : null
|
|
412
|
+
});
|
|
413
|
+
const errorCSV = this.generateErrorCSV(result);
|
|
414
|
+
const emailSubject = result.imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful';
|
|
415
|
+
await this.sendErrorEmail({
|
|
416
|
+
emailRecipient,
|
|
417
|
+
emailSubject,
|
|
418
|
+
emailContent,
|
|
419
|
+
errorCSV,
|
|
420
|
+
importLabel
|
|
421
|
+
});
|
|
422
|
+
} catch (e) {
|
|
423
|
+
logging.error('Error in members import job');
|
|
424
|
+
logging.error(e);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Still check verification triggers in case of errors (e.g., email sending failed)
|
|
428
|
+
try {
|
|
429
|
+
await verificationTrigger.testImportThreshold();
|
|
430
|
+
} catch (e) {
|
|
431
|
+
logging.error('Error in members import job when testing import threshold');
|
|
432
|
+
logging.error(e);
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
offloaded: false,
|
|
436
|
+
name: 'members-import'
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
meta
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Retrieve the ID of a tier, querying by its name, and cache the result
|
|
447
|
+
*
|
|
448
|
+
* @param {string} name
|
|
449
|
+
* @returns {Promise<string|null>}
|
|
450
|
+
*/
|
|
451
|
+
async #getTierIdByName(name) {
|
|
452
|
+
if (!this._tierIdCache.has(name)) {
|
|
453
|
+
const tier = await this._getTierByName(name);
|
|
454
|
+
|
|
455
|
+
if (!tier) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
this._tierIdCache.set(name, tier.id.toString());
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return this._tierIdCache.get(name);
|
|
463
|
+
}
|
|
464
|
+
};
|