ghost 5.26.4 → 5.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/components/tryghost-adapter-manager-5.28.0.tgz +0 -0
  2. package/components/{tryghost-api-framework-5.26.4.tgz → tryghost-api-framework-5.28.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.26.4.tgz → tryghost-api-version-compatibility-service-5.28.0.tgz} +0 -0
  4. package/components/tryghost-audience-feedback-5.28.0.tgz +0 -0
  5. package/components/tryghost-bootstrap-socket-5.28.0.tgz +0 -0
  6. package/components/tryghost-constants-5.28.0.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.26.4.tgz → tryghost-custom-theme-settings-service-5.28.0.tgz} +0 -0
  8. package/components/tryghost-data-generator-5.28.0.tgz +0 -0
  9. package/components/tryghost-domain-events-5.28.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.28.0.tgz +0 -0
  11. package/components/tryghost-email-analytics-service-5.28.0.tgz +0 -0
  12. package/components/tryghost-email-content-generator-5.28.0.tgz +0 -0
  13. package/components/tryghost-email-events-5.28.0.tgz +0 -0
  14. package/components/tryghost-email-service-5.28.0.tgz +0 -0
  15. package/components/tryghost-email-suppression-list-5.28.0.tgz +0 -0
  16. package/components/tryghost-express-dynamic-redirects-5.28.0.tgz +0 -0
  17. package/components/tryghost-extract-api-key-5.28.0.tgz +0 -0
  18. package/components/tryghost-html-to-plaintext-5.28.0.tgz +0 -0
  19. package/components/tryghost-importer-revue-5.28.0.tgz +0 -0
  20. package/components/tryghost-job-manager-5.28.0.tgz +0 -0
  21. package/components/tryghost-link-redirects-5.28.0.tgz +0 -0
  22. package/components/tryghost-link-replacer-5.28.0.tgz +0 -0
  23. package/components/tryghost-link-tracking-5.28.0.tgz +0 -0
  24. package/components/tryghost-magic-link-5.28.0.tgz +0 -0
  25. package/components/tryghost-mailgun-client-5.28.0.tgz +0 -0
  26. package/components/{tryghost-member-attribution-5.26.4.tgz → tryghost-member-attribution-5.28.0.tgz} +0 -0
  27. package/components/{tryghost-member-events-5.26.4.tgz → tryghost-member-events-5.28.0.tgz} +0 -0
  28. package/components/{tryghost-members-api-5.26.4.tgz → tryghost-members-api-5.28.0.tgz} +0 -0
  29. package/components/tryghost-members-csv-5.28.0.tgz +0 -0
  30. package/components/tryghost-members-events-service-5.28.0.tgz +0 -0
  31. package/components/tryghost-members-importer-5.28.0.tgz +0 -0
  32. package/components/tryghost-members-offers-5.28.0.tgz +0 -0
  33. package/components/tryghost-members-payments-5.28.0.tgz +0 -0
  34. package/components/tryghost-members-ssr-5.28.0.tgz +0 -0
  35. package/components/tryghost-members-stripe-service-5.28.0.tgz +0 -0
  36. package/components/tryghost-minifier-5.28.0.tgz +0 -0
  37. package/components/tryghost-mw-api-version-mismatch-5.28.0.tgz +0 -0
  38. package/components/tryghost-mw-cache-control-5.28.0.tgz +0 -0
  39. package/components/tryghost-mw-error-handler-5.28.0.tgz +0 -0
  40. package/components/tryghost-mw-session-from-token-5.28.0.tgz +0 -0
  41. package/components/tryghost-mw-update-user-last-seen-5.28.0.tgz +0 -0
  42. package/components/tryghost-mw-vhost-5.28.0.tgz +0 -0
  43. package/components/tryghost-oembed-service-5.28.0.tgz +0 -0
  44. package/components/tryghost-package-json-5.28.0.tgz +0 -0
  45. package/components/tryghost-referrers-5.28.0.tgz +0 -0
  46. package/components/tryghost-security-5.28.0.tgz +0 -0
  47. package/components/tryghost-session-service-5.28.0.tgz +0 -0
  48. package/components/tryghost-settings-path-manager-5.28.0.tgz +0 -0
  49. package/components/{tryghost-staff-service-5.26.4.tgz → tryghost-staff-service-5.28.0.tgz} +0 -0
  50. package/components/tryghost-stats-service-5.28.0.tgz +0 -0
  51. package/components/tryghost-tiers-5.28.0.tgz +0 -0
  52. package/components/tryghost-update-check-service-5.28.0.tgz +0 -0
  53. package/components/tryghost-verification-trigger-5.28.0.tgz +0 -0
  54. package/components/tryghost-version-notifications-data-service-5.28.0.tgz +0 -0
  55. package/core/built/admin/assets/{chunk.143.f9454a947ce3ca9468a9.js → chunk.143.16c07be45a0c39262995.js} +6 -6
  56. package/core/built/admin/assets/{chunk.178.5730ec1241c554356843.js → chunk.178.93714202b2fc31b2b86a.js} +4 -4
  57. package/core/built/admin/assets/{chunk.613.25718fa355a25fc2a7c1.js → chunk.380.de3a9bf5161ab5300d23.js} +4790 -4306
  58. package/core/built/admin/assets/{chunk.613.25718fa355a25fc2a7c1.js.LICENSE.txt → chunk.380.de3a9bf5161ab5300d23.js.LICENSE.txt} +0 -0
  59. package/core/built/admin/assets/ghost-1532517fd24b662b42a85ea0881865a5.css +1 -0
  60. package/core/built/admin/assets/{ghost-5429d972966c85a0a24d766f93db999d.js → ghost-d8834e309d8e4800057d6d17fed9415b.js} +4311 -4289
  61. package/core/built/admin/assets/ghost-dark-3b8aff3cf9db67f6ad59abec25ae98be.css +1 -0
  62. package/core/built/admin/assets/{vendor-5aae14724f891e36c43786254980485c.js → vendor-dbf84caa0968ef5da0486055da6636da.js} +113 -63
  63. package/core/built/admin/index.html +6 -6
  64. package/core/frontend/helpers/get.js +2 -2
  65. package/core/frontend/web/middleware/error-handler.js +0 -2
  66. package/core/server/api/endpoints/themes.js +13 -9
  67. package/core/server/api/endpoints/utils/serializers/output/mappers/index.js +2 -0
  68. package/core/server/data/migrations/versions/5.27/2022-12-13-16-15-add-usage-colums-to-tokens.js +20 -0
  69. package/core/server/data/migrations/versions/5.27/2023-01-04-04-12-drop-suppressions-table.js +14 -0
  70. package/core/server/data/migrations/versions/5.27/2023-01-04-04-13-add-suppressions-table.js +9 -0
  71. package/core/server/data/migrations/versions/5.28/2023-01-05-15-13-add-active-theme-permissions.js +12 -0
  72. package/core/server/data/schema/fixtures/fixtures.json +7 -2
  73. package/core/server/data/schema/schema.js +4 -1
  74. package/core/server/models/member.js +2 -1
  75. package/core/server/models/product.js +1 -1
  76. package/core/server/models/single-use-token.js +2 -25
  77. package/core/server/services/bulk-email/bulk-email-processor.js +23 -4
  78. package/core/server/services/email-service/wrapper.js +2 -1
  79. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +13 -5
  80. package/core/server/services/mega/mega.js +9 -1
  81. package/core/server/services/members/SingleUseTokenProvider.js +51 -7
  82. package/core/server/services/members/api.js +8 -1
  83. package/core/server/services/members/jobs/clean-tokens.js +50 -0
  84. package/core/server/services/members/jobs/index.js +29 -4
  85. package/core/server/services/members/service.js +12 -19
  86. package/core/server/services/newsletters/index.js +8 -1
  87. package/core/server/services/settings/settings-service.js +8 -1
  88. package/core/server/services/themes/activate.js +3 -3
  89. package/core/server/services/themes/index.js +4 -2
  90. package/core/server/services/themes/storage.js +3 -1
  91. package/core/server/services/themes/to-json.js +6 -6
  92. package/core/server/services/themes/validate.js +80 -8
  93. package/core/server/web/api/endpoints/admin/routes.js +5 -0
  94. package/core/shared/config/defaults.json +3 -2
  95. package/core/shared/labs.js +2 -1
  96. package/package.json +133 -133
  97. package/yarn.lock +1840 -2000
  98. package/components/tryghost-adapter-manager-5.26.4.tgz +0 -0
  99. package/components/tryghost-audience-feedback-5.26.4.tgz +0 -0
  100. package/components/tryghost-bootstrap-socket-5.26.4.tgz +0 -0
  101. package/components/tryghost-constants-5.26.4.tgz +0 -0
  102. package/components/tryghost-data-generator-5.26.4.tgz +0 -0
  103. package/components/tryghost-domain-events-5.26.4.tgz +0 -0
  104. package/components/tryghost-email-analytics-provider-mailgun-5.26.4.tgz +0 -0
  105. package/components/tryghost-email-analytics-service-5.26.4.tgz +0 -0
  106. package/components/tryghost-email-content-generator-5.26.4.tgz +0 -0
  107. package/components/tryghost-email-events-5.26.4.tgz +0 -0
  108. package/components/tryghost-email-service-5.26.4.tgz +0 -0
  109. package/components/tryghost-email-suppression-list-5.26.4.tgz +0 -0
  110. package/components/tryghost-express-dynamic-redirects-5.26.4.tgz +0 -0
  111. package/components/tryghost-extract-api-key-5.26.4.tgz +0 -0
  112. package/components/tryghost-html-to-plaintext-5.26.4.tgz +0 -0
  113. package/components/tryghost-importer-revue-5.26.4.tgz +0 -0
  114. package/components/tryghost-job-manager-5.26.4.tgz +0 -0
  115. package/components/tryghost-link-redirects-5.26.4.tgz +0 -0
  116. package/components/tryghost-link-replacer-5.26.4.tgz +0 -0
  117. package/components/tryghost-link-tracking-5.26.4.tgz +0 -0
  118. package/components/tryghost-magic-link-5.26.4.tgz +0 -0
  119. package/components/tryghost-mailgun-client-5.26.4.tgz +0 -0
  120. package/components/tryghost-members-csv-5.26.4.tgz +0 -0
  121. package/components/tryghost-members-events-service-5.26.4.tgz +0 -0
  122. package/components/tryghost-members-importer-5.26.4.tgz +0 -0
  123. package/components/tryghost-members-offers-5.26.4.tgz +0 -0
  124. package/components/tryghost-members-payments-5.26.4.tgz +0 -0
  125. package/components/tryghost-members-ssr-5.26.4.tgz +0 -0
  126. package/components/tryghost-members-stripe-service-5.26.4.tgz +0 -0
  127. package/components/tryghost-minifier-5.26.4.tgz +0 -0
  128. package/components/tryghost-mw-api-version-mismatch-5.26.4.tgz +0 -0
  129. package/components/tryghost-mw-cache-control-5.26.4.tgz +0 -0
  130. package/components/tryghost-mw-error-handler-5.26.4.tgz +0 -0
  131. package/components/tryghost-mw-session-from-token-5.26.4.tgz +0 -0
  132. package/components/tryghost-mw-update-user-last-seen-5.26.4.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.26.4.tgz +0 -0
  134. package/components/tryghost-oembed-service-5.26.4.tgz +0 -0
  135. package/components/tryghost-package-json-5.26.4.tgz +0 -0
  136. package/components/tryghost-referrers-5.26.4.tgz +0 -0
  137. package/components/tryghost-security-5.26.4.tgz +0 -0
  138. package/components/tryghost-session-service-5.26.4.tgz +0 -0
  139. package/components/tryghost-settings-path-manager-5.26.4.tgz +0 -0
  140. package/components/tryghost-stats-service-5.26.4.tgz +0 -0
  141. package/components/tryghost-tiers-5.26.4.tgz +0 -0
  142. package/components/tryghost-update-check-service-5.26.4.tgz +0 -0
  143. package/components/tryghost-verification-trigger-5.26.4.tgz +0 -0
  144. package/components/tryghost-version-notifications-data-service-5.26.4.tgz +0 -0
  145. package/core/built/admin/assets/ghost-830670d910cea64d98ab083aafd1dffd.css +0 -1
  146. package/core/built/admin/assets/ghost-dark-e9195020925a360d83163e1e87b514a6.css +0 -1
@@ -8,7 +8,7 @@
8
8
  <title>Ghost Admin</title>
9
9
 
10
10
 
11
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%225.26%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
11
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%225.28%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
12
12
 
13
13
  <meta name="HandheldFriendly" content="True" />
14
14
  <meta name="MobileOptimized" content="320" />
@@ -37,7 +37,7 @@
37
37
  </style>
38
38
 
39
39
  <link integrity="" rel="stylesheet" href="assets/vendor-3e6947aa681f0fb82b193090e520dc73.css">
40
- <link integrity="" rel="stylesheet" href="assets/ghost-830670d910cea64d98ab083aafd1dffd.css" title="light">
40
+ <link integrity="" rel="stylesheet" href="assets/ghost-1532517fd24b662b42a85ea0881865a5.css" title="light">
41
41
 
42
42
 
43
43
  </head>
@@ -56,9 +56,9 @@
56
56
 
57
57
  <div id="ember-basic-dropdown-wormhole"></div>
58
58
 
59
- <script src="assets/vendor-5aae14724f891e36c43786254980485c.js"></script>
60
- <script src="assets/chunk.613.25718fa355a25fc2a7c1.js"></script>
61
- <script src="assets/chunk.143.f9454a947ce3ca9468a9.js"></script>
62
- <script src="assets/ghost-5429d972966c85a0a24d766f93db999d.js"></script>
59
+ <script src="assets/vendor-dbf84caa0968ef5da0486055da6636da.js"></script>
60
+ <script src="assets/chunk.380.de3a9bf5161ab5300d23.js"></script>
61
+ <script src="assets/chunk.143.16c07be45a0c39262995.js"></script>
62
+ <script src="assets/ghost-d8834e309d8e4800057d6d17fed9415b.js"></script>
63
63
  </body>
64
64
  </html>
@@ -13,7 +13,7 @@ const jsonpath = require('jsonpath');
13
13
 
14
14
  const messages = {
15
15
  mustBeCalledAsBlock: 'The {\\{{helperName}}} helper must be called as a block. E.g. {{#{helperName}}}...{{/{helperName}}}',
16
- invalidResource: 'Invalid resource given to get helper'
16
+ invalidResource: 'Invalid "{resource}" resource given to get helper'
17
17
  };
18
18
 
19
19
  const createFrame = hbs.handlebars.createFrame;
@@ -193,7 +193,7 @@ module.exports = async function get(resource, options) {
193
193
  }
194
194
 
195
195
  if (!RESOURCES[resource]) {
196
- data.error = tpl(messages.invalidResource);
196
+ data.error = tpl(messages.invalidResource, {resource});
197
197
  logging.warn(data.error);
198
198
  return options.inverse(self, {data: data});
199
199
  }
@@ -47,8 +47,6 @@ const themeErrorRenderer = (err, req, res, next) => {
47
47
  // Format Data
48
48
  const data = {
49
49
  message: err.message,
50
- // @deprecated Remove in Ghost 5.0
51
- code: err.statusCode,
52
50
  statusCode: err.statusCode,
53
51
  errorDetails: err.errorDetails || []
54
52
  };
@@ -4,6 +4,7 @@ const models = require('../../models');
4
4
 
5
5
  // Used to emit theme.uploaded which is used in core/server/analytics-events
6
6
  const events = require('../../lib/common/events');
7
+ const {settingsCache} = require('../../services/settings-helpers');
7
8
 
8
9
  module.exports = {
9
10
  docName: 'themes',
@@ -15,6 +16,15 @@ module.exports = {
15
16
  }
16
17
  },
17
18
 
19
+ readActive: {
20
+ permissions: true,
21
+ async query() {
22
+ let themeName = settingsCache.get('active_theme');
23
+ const themeErrors = await themeService.api.getThemeErrors(themeName);
24
+ return themeService.api.getJSON(themeName, themeErrors);
25
+ }
26
+ },
27
+
18
28
  activate: {
19
29
  headers: {
20
30
  cacheInvalidate: true
@@ -42,15 +52,9 @@ module.exports = {
42
52
  value: themeName
43
53
  }];
44
54
 
45
- return themeService.api.activate(themeName)
46
- .then((checkedTheme) => {
47
- // @NOTE: we use the model, not the API here, as we don't want to trigger permissions
48
- return models.Settings.edit(newSettings, frame.options)
49
- .then(() => checkedTheme);
50
- })
51
- .then((checkedTheme) => {
52
- return themeService.api.getJSON(themeName, checkedTheme);
53
- });
55
+ const themeErrors = await themeService.api.activate(themeName);
56
+ await models.Settings.edit(newSettings, frame.options);
57
+ return themeService.api.getJSON(themeName, themeErrors);
54
58
  }
55
59
  },
56
60
 
@@ -4,6 +4,8 @@ module.exports = {
4
4
  authors: require('./authors'),
5
5
  comments: require('./comments'),
6
6
  emails: require('./emails'),
7
+ emailBatches: require('./email-batches'),
8
+ emailFailures: require('./email-failures'),
7
9
  images: require('./images'),
8
10
  integrations: require('./integrations'),
9
11
  pages: require('./pages'),
@@ -0,0 +1,20 @@
1
+ const {createAddColumnMigration, combineNonTransactionalMigrations} = require('../../utils');
2
+
3
+ module.exports = combineNonTransactionalMigrations(
4
+ createAddColumnMigration('tokens', 'updated_at', {
5
+ type: 'dateTime',
6
+ nullable: true
7
+ }),
8
+
9
+ createAddColumnMigration('tokens', 'first_used_at', {
10
+ type: 'dateTime',
11
+ nullable: true
12
+ }),
13
+
14
+ createAddColumnMigration('tokens', 'used_count', {
15
+ type: 'integer',
16
+ nullable: false,
17
+ unsigned: true,
18
+ defaultTo: 0
19
+ })
20
+ );
@@ -0,0 +1,14 @@
1
+ const {addTable} = require('../../utils');
2
+
3
+ const migration = addTable('suppressions', {
4
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
5
+ email_address: {type: 'string', maxlength: 191, nullable: false, unique: true},
6
+ email_id: {type: 'string', maxlength: 24, nullable: true, references: 'emails.id'},
7
+ reason: {type: 'string', maxlength: 50, nullable: false},
8
+ created_at: {type: 'dateTime', nullable: false}
9
+ });
10
+
11
+ module.exports = {
12
+ up: migration.down,
13
+ down: migration.up
14
+ };
@@ -0,0 +1,9 @@
1
+ const {addTable} = require('../../utils');
2
+
3
+ module.exports = addTable('suppressions', {
4
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
5
+ email: {type: 'string', maxlength: 191, nullable: false, unique: true},
6
+ email_id: {type: 'string', maxlength: 24, nullable: true, references: 'emails.id'},
7
+ reason: {type: 'string', maxlength: 50, nullable: false},
8
+ created_at: {type: 'dateTime', nullable: false}
9
+ });
@@ -0,0 +1,12 @@
1
+ const {addPermissionWithRoles} = require('../../utils');
2
+
3
+ module.exports = addPermissionWithRoles({
4
+ name: 'View active theme details',
5
+ action: 'readActive',
6
+ object: 'theme'
7
+ }, [
8
+ 'Author',
9
+ 'Editor',
10
+ 'Administrator',
11
+ 'Admin Integration'
12
+ ]);
@@ -214,6 +214,11 @@
214
214
  "action_type": "activate",
215
215
  "object_type": "theme"
216
216
  },
217
+ {
218
+ "name": "View active theme details",
219
+ "action_type": "readActive",
220
+ "object_type": "theme"
221
+ },
217
222
  {
218
223
  "name": "Upload themes",
219
224
  "action_type": "add",
@@ -804,7 +809,7 @@
804
809
  "user": "all",
805
810
  "role": "all",
806
811
  "invite": "all",
807
- "theme": ["browse"],
812
+ "theme": ["browse", "readActive"],
808
813
  "email_preview": "all",
809
814
  "email": "all",
810
815
  "snippet": "all",
@@ -819,7 +824,7 @@
819
824
  "tag": ["browse", "read", "add"],
820
825
  "user": ["browse", "read"],
821
826
  "role": ["browse"],
822
- "theme": ["browse"],
827
+ "theme": ["browse", "readActive"],
823
828
  "email_preview": "read",
824
829
  "email": "read",
825
830
  "snippet": ["browse", "read"],
@@ -858,6 +858,9 @@ module.exports = {
858
858
  token: {type: 'string', maxlength: 32, nullable: false, index: true},
859
859
  data: {type: 'string', maxlength: 2000, nullable: true},
860
860
  created_at: {type: 'dateTime', nullable: false},
861
+ updated_at: {type: 'dateTime', nullable: true},
862
+ first_used_at: {type: 'dateTime', nullable: true},
863
+ used_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0},
861
864
  created_by: {type: 'string', maxlength: 24, nullable: false}
862
865
  },
863
866
  snippets: {
@@ -952,7 +955,7 @@ module.exports = {
952
955
  },
953
956
  suppressions: {
954
957
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
955
- email_address: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
958
+ email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
956
959
  email_id: {type: 'string', maxlength: 24, nullable: true, references: 'emails.id'},
957
960
  reason: {
958
961
  type: 'string',
@@ -50,7 +50,7 @@ const Member = ghostBookshelf.Model.extend({
50
50
  replacement: 'emails.post_id',
51
51
  // Currently we cannot expand on values such as null or a string in mongo-knex
52
52
  // But the line below is essentially the same as: `email_recipients.opened_at:-null`
53
- expansion: 'email_recipients.opened_at:>=0'
53
+ expansion: 'email_recipients.opened_at:>=0'
54
54
  }];
55
55
  },
56
56
 
@@ -165,6 +165,7 @@ const Member = ghostBookshelf.Model.extend({
165
165
 
166
166
  newsletters() {
167
167
  return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id')
168
+ .query('orderBy', 'newsletters.sort_order', 'ASC')
168
169
  .query((qb) => {
169
170
  // avoids bookshelf adding a `DISTINCT` to the query
170
171
  // we know the result set will already be unique and DISTINCT hurts query performance
@@ -143,7 +143,7 @@ const Product = ghostBookshelf.Model.extend({
143
143
  }
144
144
  }, {
145
145
  orderDefaultRaw() {
146
- return 'stripe_prices.amount asc';
146
+ return 'monthly_price ASC';
147
147
  }
148
148
  });
149
149
 
@@ -1,12 +1,12 @@
1
1
  const ghostBookshelf = require('./base');
2
2
  const crypto = require('crypto');
3
- const logging = require('@tryghost/logging');
4
3
 
5
4
  const SingleUseToken = ghostBookshelf.Model.extend({
6
5
  tableName: 'tokens',
7
6
 
8
7
  defaults() {
9
8
  return {
9
+ used_count: 0,
10
10
  token: crypto
11
11
  .randomBytes(192 / 8)
12
12
  .toString('base64')
@@ -15,30 +15,7 @@ const SingleUseToken = ghostBookshelf.Model.extend({
15
15
  .replace(/\//g, '_')
16
16
  };
17
17
  }
18
- }, {
19
- async findOne(data, unfilteredOptions = {}) {
20
- const model = await ghostBookshelf.Model.findOne.call(this, data, unfilteredOptions);
21
-
22
- if (model) {
23
- setTimeout(async () => {
24
- try {
25
- await this.destroy(Object.assign({
26
- destroyBy: {
27
- id: model.id
28
- }
29
- }, {
30
- ...unfilteredOptions,
31
- transacting: null
32
- }));
33
- } catch (err) {
34
- logging.error(err);
35
- }
36
- }, 10 * 60 * 1000);
37
- }
38
-
39
- return model;
40
- }
41
- });
18
+ }, {});
42
19
 
43
20
  const SingleUseTokens = ghostBookshelf.Collection.extend({
44
21
  model: SingleUseToken
@@ -12,6 +12,12 @@ const postEmailSerializer = require('../mega/post-email-serializer');
12
12
  const configService = require('../../../shared/config');
13
13
  const settingsCache = require('../../../shared/settings-cache');
14
14
 
15
+ async function sleep(ms) {
16
+ return new Promise((resolve) => {
17
+ setTimeout(resolve, ms);
18
+ });
19
+ }
20
+
15
21
  const messages = {
16
22
  error: 'The email service received an error from mailgun and was unable to send.'
17
23
  };
@@ -145,14 +151,27 @@ module.exports = {
145
151
  });
146
152
  }
147
153
 
148
- // get recipient rows via knex to avoid costly bookshelf model instantiation
149
- const recipientRows = await models.EmailRecipient
150
- .getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`});
151
-
152
154
  // Patch to prevent saving the related email model
153
155
  await emailBatchModel.save({status: 'submitting'}, {...knexOptions, patch: true});
154
156
 
155
157
  try {
158
+ // get recipient rows via knex to avoid costly bookshelf model instantiation
159
+ let recipientRows = await models.EmailRecipient.getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`}, knexOptions);
160
+
161
+ // For an unknown reason, the returned recipient rows is sometimes an empty array
162
+ // refs https://github.com/TryGhost/Team/issues/2246
163
+ let counter = 0;
164
+ while (recipientRows.length === 0 && counter < 5) {
165
+ logging.info('[sendEmailJob] Found zero recipients [retries:' + counter + '] for email batch ' + emailBatchId);
166
+
167
+ counter += 1;
168
+ await sleep(200);
169
+ recipientRows = await models.EmailRecipient.getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`}, knexOptions);
170
+ }
171
+ if (counter > 0) {
172
+ logging.info('[sendEmailJob] Recovered recipients [retries:' + counter + '] for email batch ' + emailBatchId + ' - ' + recipientRows.length + ' recipients found');
173
+ }
174
+
156
175
  // Load newsletter data on email
157
176
  await emailBatchModel.relations.email.getLazyRelation('newsletter', {require: false, ...knexOptions});
158
177
 
@@ -101,7 +101,8 @@ class EmailServiceWrapper {
101
101
  emailRenderer,
102
102
  emailSegmenter,
103
103
  limitService,
104
- membersRepository
104
+ membersRepository,
105
+ verificationTrigger: membersService.verificationTrigger
105
106
  });
106
107
 
107
108
  this.controller = new EmailController(this.service, {
@@ -35,7 +35,7 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
35
35
  try {
36
36
  await this.Suppression.destroy({
37
37
  destroyBy: {
38
- email_address: email
38
+ email: email
39
39
  }
40
40
  });
41
41
  } catch (err) {
@@ -49,7 +49,7 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
49
49
  async getSuppressionData(email) {
50
50
  try {
51
51
  const model = await this.Suppression.findOne({
52
- email_address: email
52
+ email: email
53
53
  });
54
54
 
55
55
  if (!model) {
@@ -73,11 +73,11 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
73
73
 
74
74
  try {
75
75
  const collection = await this.Suppression.findAll({
76
- filter: `email_address:[${emails.join(',')}]`
76
+ filter: `email:[${emails.join(',')}]`
77
77
  });
78
78
 
79
79
  return emails.map((email) => {
80
- const model = collection.models.find(m => m.get('email_address') === email);
80
+ const model = collection.models.find(m => m.get('email') === email);
81
81
 
82
82
  if (!model) {
83
83
  return new EmailSuppressionData(false);
@@ -97,8 +97,16 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
97
97
  async init() {
98
98
  const handleEvent = reason => async (event) => {
99
99
  try {
100
+ if (reason === 'bounce') {
101
+ if (!Number.isInteger(event.error?.code)) {
102
+ return;
103
+ }
104
+ if (event.error.code < 500 || event.error.code > 599 && event.error.code !== 605) {
105
+ return;
106
+ }
107
+ }
100
108
  await this.Suppression.add({
101
- email_address: event.email,
109
+ email: event.email,
102
110
  email_id: event.emailId,
103
111
  reason: reason,
104
112
  created_at: event.timestamp
@@ -185,7 +185,7 @@ const addEmail = async (postModel, options) => {
185
185
  await limitService.errorIfWouldGoOverLimit('emails');
186
186
  }
187
187
 
188
- if (settingsCache.get('email_verification_required') === true) {
188
+ if (await membersService.verificationTrigger.checkVerificationRequired()) {
189
189
  throw new errors.HostLimitError({
190
190
  message: tpl(messages.emailSendingDisabled)
191
191
  });
@@ -312,6 +312,14 @@ async function sendEmailJob({emailId, options}) {
312
312
  await limitService.errorIfWouldGoOverLimit('emails');
313
313
  }
314
314
 
315
+ // Check email verification required
316
+ // We need to check this inside the job again
317
+ if (await membersService.verificationTrigger.checkVerificationRequired()) {
318
+ throw new errors.HostLimitError({
319
+ message: tpl(messages.emailSendingDisabled)
320
+ });
321
+ }
322
+
315
323
  // Check if the email is still pending. And set the status to submitting in one transaction.
316
324
  let hasSingleAccess = false;
317
325
  let emailModel;
@@ -3,12 +3,17 @@ const {ValidationError} = require('@tryghost/errors');
3
3
 
4
4
  class SingleUseTokenProvider {
5
5
  /**
6
- * @param {import('../../models/base')} SingleUseTokenModel - A model for creating and retrieving tokens.
7
- * @param {number} validity - How long a token is valid for from it's creation in milliseconds.
6
+ * @param {Object} dependencies
7
+ * @param {import('../../models/base')} dependencies.SingleUseTokenModel - A model for creating and retrieving tokens.
8
+ * @param {number} dependencies.validityPeriod - How long a token is valid for from it's creation in milliseconds.
9
+ * @param {number} dependencies.validityPeriodAfterUsage - How long a token is valid after first usage, in milliseconds.
10
+ * @param {number} dependencies.maxUsageCount - How many times a token can be used.
8
11
  */
9
- constructor(SingleUseTokenModel, validity) {
12
+ constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount}) {
10
13
  this.model = SingleUseTokenModel;
11
- this.validity = validity;
14
+ this.validityPeriod = validityPeriod;
15
+ this.validityPeriodAfterUsage = validityPeriodAfterUsage;
16
+ this.maxUsageCount = maxUsageCount;
12
17
  }
13
18
 
14
19
  /**
@@ -37,8 +42,17 @@ class SingleUseTokenProvider {
37
42
  *
38
43
  * @returns {Promise<Object<string, any>>}
39
44
  */
40
- async validate(token) {
41
- const model = await this.model.findOne({token});
45
+ async validate(token, options = {}) {
46
+ if (!options.transacting) {
47
+ return await this.model.transaction((transacting) => {
48
+ return this.validate(token, {
49
+ ...options,
50
+ transacting
51
+ });
52
+ });
53
+ }
54
+
55
+ const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
42
56
 
43
57
  if (!model) {
44
58
  throw new ValidationError({
@@ -46,16 +60,46 @@ class SingleUseTokenProvider {
46
60
  });
47
61
  }
48
62
 
63
+ if (model.get('used_count') >= this.maxUsageCount) {
64
+ throw new ValidationError({
65
+ message: 'Token expired'
66
+ });
67
+ }
68
+
49
69
  const createdAtEpoch = model.get('created_at').getTime();
70
+ const firstUsedAtEpoch = model.get('first_used_at')?.getTime() ?? createdAtEpoch;
71
+
72
+ // Is this token already used?
73
+ if (model.get('used_count') > 0) {
74
+ const timeSinceFirstUsage = Date.now() - firstUsedAtEpoch;
50
75
 
76
+ if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
77
+ throw new ValidationError({
78
+ message: 'Token expired'
79
+ });
80
+ }
81
+ }
51
82
  const tokenLifetimeMilliseconds = Date.now() - createdAtEpoch;
52
83
 
53
- if (tokenLifetimeMilliseconds > this.validity) {
84
+ if (tokenLifetimeMilliseconds > this.validityPeriod) {
54
85
  throw new ValidationError({
55
86
  message: 'Token expired'
56
87
  });
57
88
  }
58
89
 
90
+ if (!model.get('first_used_at')) {
91
+ await model.save({
92
+ first_used_at: new Date(),
93
+ updated_at: new Date(),
94
+ used_count: model.get('used_count') + 1
95
+ }, {autoRefresh: false, patch: true, transacting: options.transacting});
96
+ } else {
97
+ await model.save({
98
+ used_count: model.get('used_count') + 1,
99
+ updated_at: new Date()
100
+ }, {autoRefresh: false, patch: true, transacting: options.transacting});
101
+ }
102
+
59
103
  try {
60
104
  return JSON.parse(model.get('data'));
61
105
  } catch (err) {
@@ -19,6 +19,8 @@ const memberAttributionService = require('../member-attribution');
19
19
  const emailSuppressionList = require('../email-suppression-list');
20
20
 
21
21
  const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
22
+ const MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE = 10 * 60 * 1000;
23
+ const MAGIC_LINK_TOKEN_MAX_USAGE_COUNT = 3;
22
24
 
23
25
  const ghostMailer = new mail.GhostMailer();
24
26
 
@@ -30,7 +32,12 @@ function createApiInstance(config) {
30
32
  auth: {
31
33
  getSigninURL: config.getSigninURL.bind(config),
32
34
  allowSelfSignup: config.getAllowSelfSignup.bind(config),
33
- tokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY)
35
+ tokenProvider: new SingleUseTokenProvider({
36
+ SingleUseTokenModel: models.SingleUseToken,
37
+ validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
38
+ validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
39
+ maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT
40
+ })
34
41
  },
35
42
  mail: {
36
43
  transporter: {
@@ -0,0 +1,50 @@
1
+ const {parentPort} = require('worker_threads');
2
+ const debug = require('@tryghost/debug')('jobs:clean-tokens');
3
+ const moment = require('moment');
4
+
5
+ // Exit early when cancelled to prevent stalling shutdown. No cleanup needed when cancelling as everything is idempotent and will pick up
6
+ // where it left off on next run
7
+ function cancel() {
8
+ if (parentPort) {
9
+ parentPort.postMessage('Expired complimentary subscriptions cleanup cancelled before completion');
10
+ parentPort.postMessage('cancelled');
11
+ } else {
12
+ setTimeout(() => {
13
+ process.exit(0);
14
+ }, 1000);
15
+ }
16
+ }
17
+
18
+ if (parentPort) {
19
+ parentPort.once('message', (message) => {
20
+ if (message === 'cancel') {
21
+ return cancel();
22
+ }
23
+ });
24
+ }
25
+
26
+ (async () => {
27
+ const cleanupStartDate = new Date();
28
+ const db = require('../../../data/db');
29
+ debug(`Starting cleanup of tokens`);
30
+
31
+ // We delete all tokens that are older than 24 hours.
32
+ const d = moment.utc().subtract(24, 'hours');
33
+ const deletedTokens = await db.knex('tokens')
34
+ .where('created_at', '<', d.format('YYYY-MM-DD HH:mm:ss')) // we need to be careful about the type here. .format() is the only thing that works across SQLite and MySQL
35
+ .delete();
36
+
37
+ const cleanupEndDate = new Date();
38
+
39
+ debug(`Removed ${deletedTokens} tokens created before ${d.toISOString()} in ${cleanupEndDate.valueOf() - cleanupStartDate.valueOf()}ms`);
40
+
41
+ if (parentPort) {
42
+ parentPort.postMessage(`Removed ${deletedTokens} tokens created before ${d.toISOString()} in ${cleanupEndDate.valueOf() - cleanupStartDate.valueOf()}ms`);
43
+ parentPort.postMessage('done');
44
+ } else {
45
+ // give the logging pipes time finish writing before exit
46
+ setTimeout(() => {
47
+ process.exit(0);
48
+ }, 1000);
49
+ }
50
+ })();
@@ -1,12 +1,15 @@
1
1
  const path = require('path');
2
2
  const jobsService = require('../../jobs');
3
3
 
4
- let hasScheduled = false;
4
+ let hasScheduled = {
5
+ expiredComped: false,
6
+ tokens: false
7
+ };
5
8
 
6
9
  module.exports = {
7
10
  async scheduleExpiredCompCleanupJob() {
8
11
  if (
9
- !hasScheduled &&
12
+ !hasScheduled.expiredComped &&
10
13
  !process.env.NODE_ENV.startsWith('test')
11
14
  ) {
12
15
  // use a random seconds value to avoid spikes to external APIs on the minute
@@ -19,9 +22,31 @@ module.exports = {
19
22
  name: 'clean-expired-comped'
20
23
  });
21
24
 
22
- hasScheduled = true;
25
+ hasScheduled.expiredComped = true;
26
+ }
27
+
28
+ return hasScheduled.expiredComped;
29
+ },
30
+
31
+ async scheduleTokenCleanupJob() {
32
+ if (
33
+ !hasScheduled.tokens &&
34
+ !process.env.NODE_ENV.startsWith('test')
35
+ ) {
36
+ // use a random seconds/minutes/hours value to avoid delete spikes to the database
37
+ const s = Math.floor(Math.random() * 60); // 0-59
38
+ const m = Math.floor(Math.random() * 60); // 0-59
39
+ const h = Math.floor(Math.random() * 24); // 0-23
40
+
41
+ jobsService.addJob({
42
+ at: `${s} ${m} ${h} * * *`, // Every day
43
+ job: require('path').resolve(__dirname, 'clean-tokens.js'),
44
+ name: 'clean-tokens'
45
+ });
46
+
47
+ hasScheduled.tokens = true;
23
48
  }
24
49
 
25
- return hasScheduled;
50
+ return hasScheduled.tokens;
26
51
  }
27
52
  };