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.
Files changed (201) hide show
  1. package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
  2. package/components/{tryghost-adapter-manager-5.114.1.tgz → tryghost-adapter-manager-5.115.1.tgz} +0 -0
  3. package/components/{tryghost-announcement-bar-settings-5.114.1.tgz → tryghost-announcement-bar-settings-5.115.1.tgz} +0 -0
  4. package/components/{tryghost-api-framework-5.114.1.tgz → tryghost-api-framework-5.115.1.tgz} +0 -0
  5. package/components/tryghost-constants-5.115.1.tgz +0 -0
  6. package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.114.1.tgz → tryghost-custom-theme-settings-service-5.115.1.tgz} +0 -0
  8. package/components/{tryghost-data-generator-5.114.1.tgz → tryghost-data-generator-5.115.1.tgz} +0 -0
  9. package/components/{tryghost-domain-events-5.114.1.tgz → tryghost-domain-events-5.115.1.tgz} +0 -0
  10. package/components/tryghost-donations-5.115.1.tgz +0 -0
  11. package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
  12. package/components/{tryghost-email-content-generator-5.114.1.tgz → tryghost-email-content-generator-5.115.1.tgz} +0 -0
  13. package/components/tryghost-email-events-5.115.1.tgz +0 -0
  14. package/components/tryghost-email-service-5.115.1.tgz +0 -0
  15. package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
  16. package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
  17. package/components/tryghost-ghost-5.115.1.tgz +0 -0
  18. package/components/{tryghost-html-to-plaintext-5.114.1.tgz → tryghost-html-to-plaintext-5.115.1.tgz} +0 -0
  19. package/components/tryghost-i18n-5.115.1.tgz +0 -0
  20. package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
  21. package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
  22. package/components/{tryghost-job-manager-5.114.1.tgz → tryghost-job-manager-5.115.1.tgz} +0 -0
  23. package/components/{tryghost-link-redirects-5.114.1.tgz → tryghost-link-redirects-5.115.1.tgz} +0 -0
  24. package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
  25. package/components/{tryghost-magic-link-5.114.1.tgz → tryghost-magic-link-5.115.1.tgz} +0 -0
  26. package/components/{tryghost-mailgun-client-5.114.1.tgz → tryghost-mailgun-client-5.115.1.tgz} +0 -0
  27. package/components/tryghost-member-attribution-5.115.1.tgz +0 -0
  28. package/components/{tryghost-member-events-5.114.1.tgz → tryghost-member-events-5.115.1.tgz} +0 -0
  29. package/components/{tryghost-members-api-5.114.1.tgz → tryghost-members-api-5.115.1.tgz} +0 -0
  30. package/components/{tryghost-members-csv-5.114.1.tgz → tryghost-members-csv-5.115.1.tgz} +0 -0
  31. package/components/{tryghost-members-offers-5.114.1.tgz → tryghost-members-offers-5.115.1.tgz} +0 -0
  32. package/components/{tryghost-members-payments-5.114.1.tgz → tryghost-members-payments-5.115.1.tgz} +0 -0
  33. package/components/{tryghost-milestones-5.114.1.tgz → tryghost-milestones-5.115.1.tgz} +0 -0
  34. package/components/tryghost-minifier-5.115.1.tgz +0 -0
  35. package/components/{tryghost-mw-error-handler-5.114.1.tgz → tryghost-mw-error-handler-5.115.1.tgz} +0 -0
  36. package/components/{tryghost-mw-version-match-5.114.1.tgz → tryghost-mw-version-match-5.115.1.tgz} +0 -0
  37. package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
  38. package/components/{tryghost-post-events-5.114.1.tgz → tryghost-post-events-5.115.1.tgz} +0 -0
  39. package/components/{tryghost-post-revisions-5.114.1.tgz → tryghost-post-revisions-5.115.1.tgz} +0 -0
  40. package/components/{tryghost-posts-service-5.114.1.tgz → tryghost-posts-service-5.115.1.tgz} +0 -0
  41. package/components/{tryghost-prometheus-metrics-5.114.1.tgz → tryghost-prometheus-metrics-5.115.1.tgz} +0 -0
  42. package/components/tryghost-recommendations-5.115.1.tgz +0 -0
  43. package/components/{tryghost-security-5.114.1.tgz → tryghost-security-5.115.1.tgz} +0 -0
  44. package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
  45. package/components/{tryghost-tiers-5.114.1.tgz → tryghost-tiers-5.115.1.tgz} +0 -0
  46. package/components/{tryghost-webmentions-5.114.1.tgz → tryghost-webmentions-5.115.1.tgz} +0 -0
  47. package/content/themes/casper/LICENSE +1 -1
  48. package/content/themes/casper/README.md +1 -1
  49. package/content/themes/source/LICENSE +1 -1
  50. package/content/themes/source/README.md +1 -1
  51. package/content/themes/source/assets/built/screen.css +1 -1
  52. package/content/themes/source/assets/built/screen.css.map +1 -1
  53. package/content/themes/source/assets/css/screen.css +11 -6
  54. package/content/themes/source/partials/feature-image.hbs +2 -2
  55. package/core/boot.js +3 -1
  56. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +23497 -23041
  57. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
  58. package/core/built/admin/assets/admin-x-demo/{index-0040480a.mjs → index-15df2af5.mjs} +4 -3
  59. package/core/built/admin/assets/admin-x-demo/{modals-fb35c86c.mjs → modals-8ca61d78.mjs} +67 -65
  60. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-806ef39c.mjs → CodeEditorView-d2e6872f.mjs} +2 -2
  61. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  62. package/core/built/admin/assets/admin-x-settings/{index-376f847c.mjs → index-8e8821e5.mjs} +2 -2
  63. package/core/built/admin/assets/admin-x-settings/{index-8fa19303.mjs → index-f5cb3db3.mjs} +3104 -3094
  64. package/core/built/admin/assets/admin-x-settings/{modals-36775d71.mjs → modals-e8ae4d46.mjs} +3 -3
  65. package/core/built/admin/assets/{chunk.524.85c5b32bd46b91c147b9.js → chunk.524.2439684964c164c598ab.js} +7 -7
  66. package/core/built/admin/assets/{chunk.582.449a129a8005f03574bd.js → chunk.582.bf5a2bbb2c4eb69ef1e7.js} +10 -10
  67. package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +1 -0
  68. package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +1 -0
  69. package/core/built/admin/assets/{ghost-c563138cc2c0767bf6eefc9a2587eaa4.js → ghost-df7b9558260aa27d18b195ee895b487d.js} +182 -160
  70. package/core/built/admin/assets/stats/stats.js +11824 -0
  71. package/core/built/admin/index.html +4 -4
  72. package/core/frontend/helpers/ghost_head.js +3 -1
  73. package/core/frontend/src/cards/css/cta.css +1 -1
  74. package/core/server/api/endpoints/slugs.js +6 -2
  75. package/core/server/data/importer/import-manager.js +2 -2
  76. package/core/server/data/importer/importers/importer-revue.js +128 -0
  77. package/core/server/data/importer/importers/json-to-html.js +107 -0
  78. package/core/server/data/migrations/utils/tables.js +2 -4
  79. package/core/server/data/migrations/versions/5.115/2025-03-24-07-19-27-add-identity-read-permission-to-administrators.js +6 -0
  80. package/core/server/data/schema/fixtures/fixtures.json +2 -1
  81. package/core/server/lib/bootstrap-socket.js +87 -0
  82. package/core/server/lib/package-json/index.js +1 -0
  83. package/core/server/lib/package-json/package-json.js +160 -0
  84. package/core/server/lib/package-json/parse.js +57 -0
  85. package/core/server/models/base/plugins/actions.js +44 -31
  86. package/core/server/models/base/plugins/generate-slug.js +6 -0
  87. package/core/server/notify.js +1 -1
  88. package/core/server/services/activitypub/ActivityPubService.ts +1 -1
  89. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +99 -0
  90. package/core/server/services/api-version-compatibility/VersionNotificationsDataService.js +80 -0
  91. package/core/server/services/api-version-compatibility/extract-api-key.js +57 -0
  92. package/core/server/services/api-version-compatibility/index.js +2 -2
  93. package/core/server/services/api-version-compatibility/mw-api-version-mismatch.js +31 -0
  94. package/core/server/services/audience-feedback/AudienceFeedbackController.js +85 -0
  95. package/core/server/services/audience-feedback/AudienceFeedbackService.js +34 -0
  96. package/core/server/services/audience-feedback/Feedback.js +35 -0
  97. package/core/server/services/audience-feedback/index.js +4 -2
  98. package/core/server/services/auth/session/emails/signin.js +168 -0
  99. package/core/server/services/auth/session/index.js +2 -2
  100. package/core/server/services/auth/session/session-from-token.js +69 -0
  101. package/core/server/services/auth/session/session-service.js +364 -0
  102. package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +62 -0
  103. package/core/server/services/email-analytics/EmailAnalyticsService.js +552 -0
  104. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +3 -3
  105. package/core/server/services/email-analytics/EventProcessingResult.js +66 -0
  106. package/core/server/services/explore-ping/ExplorePingService.js +106 -0
  107. package/core/server/services/explore-ping/index.js +31 -0
  108. package/core/server/services/identity-tokens/IdentityTokenService.js +30 -0
  109. package/core/server/services/identity-tokens/IdentityTokenService.ts +28 -0
  110. package/core/server/services/identity-tokens/IdentityTokenServiceWrapper.js +1 -1
  111. package/core/server/services/invitations/accept.js +5 -2
  112. package/core/server/services/mail-events/BookshelfMailEventRepository.js +2 -2
  113. package/core/server/services/mail-events/InMemoryMailEventRepository.js +10 -0
  114. package/core/server/services/mail-events/InMemoryMailEventRepository.ts +8 -0
  115. package/core/server/services/mail-events/MailEvent.js +20 -0
  116. package/core/server/services/mail-events/MailEvent.ts +10 -0
  117. package/core/server/services/mail-events/MailEventRepository.js +2 -0
  118. package/core/server/services/mail-events/MailEventRepository.ts +5 -0
  119. package/core/server/services/mail-events/MailEventService.js +124 -0
  120. package/core/server/services/mail-events/MailEventService.ts +169 -0
  121. package/core/server/services/mail-events/index.js +1 -1
  122. package/core/server/services/mail-events/libraries.d.ts +2 -0
  123. package/core/server/services/members/CaptchaService.js +80 -0
  124. package/core/server/services/members/api.js +1 -1
  125. package/core/server/services/members/importer/MembersCSVImporter.js +464 -0
  126. package/core/server/services/members/importer/MembersCSVImporterStripeUtils.js +194 -0
  127. package/core/server/services/members/importer/email-template.js +182 -0
  128. package/core/server/services/members/importer/index.js +30 -0
  129. package/core/server/services/members/members-ssr.js +333 -0
  130. package/core/server/services/members/service.js +2 -2
  131. package/core/server/services/posts/stats/PostStats.js +13 -0
  132. package/core/server/services/route-settings/SettingsPathManager.js +47 -0
  133. package/core/server/services/route-settings/index.js +1 -1
  134. package/core/server/services/stripe/README.md +63 -0
  135. package/core/server/services/stripe/StripeAPI.js +931 -0
  136. package/core/server/services/stripe/StripeMigrations.js +613 -0
  137. package/core/server/services/stripe/StripeService.js +175 -0
  138. package/core/server/services/stripe/WebhookController.js +100 -0
  139. package/core/server/services/stripe/WebhookManager.js +175 -0
  140. package/core/server/services/stripe/events/StripeLiveDisabledEvent.js +23 -0
  141. package/core/server/services/stripe/events/StripeLiveEnabledEvent.js +23 -0
  142. package/core/server/services/stripe/events/index.js +4 -0
  143. package/core/server/services/stripe/service.js +1 -1
  144. package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +255 -0
  145. package/core/server/services/stripe/services/webhook/InvoiceEventService.js +70 -0
  146. package/core/server/services/stripe/services/webhook/SubscriptionEventService.js +54 -0
  147. package/core/server/services/themes/loader.js +1 -1
  148. package/core/server/services/themes/to-json.js +1 -1
  149. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  150. package/core/server/web/shared/middleware/cache-control.js +51 -0
  151. package/core/server/web/shared/middleware/index.js +1 -1
  152. package/core/server/web/well-known.js +1 -1
  153. package/core/shared/labs.js +3 -1
  154. package/core/shared/settings-cache/CacheManager.js +64 -6
  155. package/package.json +103 -134
  156. package/tsconfig.tsbuildinfo +1 -1
  157. package/yarn.lock +7 -93
  158. package/components/tryghost-adapter-cache-redis-5.114.1.tgz +0 -0
  159. package/components/tryghost-api-version-compatibility-service-5.114.1.tgz +0 -0
  160. package/components/tryghost-audience-feedback-5.114.1.tgz +0 -0
  161. package/components/tryghost-bookshelf-repository-5.114.1.tgz +0 -0
  162. package/components/tryghost-bootstrap-socket-5.114.1.tgz +0 -0
  163. package/components/tryghost-captcha-service-5.114.1.tgz +0 -0
  164. package/components/tryghost-constants-5.114.1.tgz +0 -0
  165. package/components/tryghost-custom-fonts-5.114.1.tgz +0 -0
  166. package/components/tryghost-donations-5.114.1.tgz +0 -0
  167. package/components/tryghost-email-addresses-5.114.1.tgz +0 -0
  168. package/components/tryghost-email-analytics-provider-mailgun-5.114.1.tgz +0 -0
  169. package/components/tryghost-email-analytics-service-5.114.1.tgz +0 -0
  170. package/components/tryghost-email-events-5.114.1.tgz +0 -0
  171. package/components/tryghost-email-service-5.114.1.tgz +0 -0
  172. package/components/tryghost-email-suppression-list-5.114.1.tgz +0 -0
  173. package/components/tryghost-express-dynamic-redirects-5.114.1.tgz +0 -0
  174. package/components/tryghost-extract-api-key-5.114.1.tgz +0 -0
  175. package/components/tryghost-ghost-5.114.1.tgz +0 -0
  176. package/components/tryghost-i18n-5.114.1.tgz +0 -0
  177. package/components/tryghost-identity-token-service-5.114.1.tgz +0 -0
  178. package/components/tryghost-importer-handler-content-files-5.114.1.tgz +0 -0
  179. package/components/tryghost-importer-revue-5.114.1.tgz +0 -0
  180. package/components/tryghost-in-memory-repository-5.114.1.tgz +0 -0
  181. package/components/tryghost-link-replacer-5.114.1.tgz +0 -0
  182. package/components/tryghost-mail-events-5.114.1.tgz +0 -0
  183. package/components/tryghost-member-attribution-5.114.1.tgz +0 -0
  184. package/components/tryghost-members-importer-5.114.1.tgz +0 -0
  185. package/components/tryghost-members-ssr-5.114.1.tgz +0 -0
  186. package/components/tryghost-members-stripe-service-5.114.1.tgz +0 -0
  187. package/components/tryghost-minifier-5.114.1.tgz +0 -0
  188. package/components/tryghost-mw-api-version-mismatch-5.114.1.tgz +0 -0
  189. package/components/tryghost-mw-cache-control-5.114.1.tgz +0 -0
  190. package/components/tryghost-mw-session-from-token-5.114.1.tgz +0 -0
  191. package/components/tryghost-mw-update-user-last-seen-5.114.1.tgz +0 -0
  192. package/components/tryghost-mw-vhost-5.114.1.tgz +0 -0
  193. package/components/tryghost-package-json-5.114.1.tgz +0 -0
  194. package/components/tryghost-recommendations-5.114.1.tgz +0 -0
  195. package/components/tryghost-referrers-5.114.1.tgz +0 -0
  196. package/components/tryghost-session-service-5.114.1.tgz +0 -0
  197. package/components/tryghost-settings-path-manager-5.114.1.tgz +0 -0
  198. package/components/tryghost-slack-notifications-5.114.1.tgz +0 -0
  199. package/components/tryghost-version-notifications-data-service-5.114.1.tgz +0 -0
  200. package/core/built/admin/assets/ghost-c2a7c4a1b76550c4219adb2ed4124ce0.css +0 -1
  201. 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('@tryghost/captcha-service');
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
+ };