ghost 4.43.0 → 4.45.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 (69) hide show
  1. package/core/boot.js +2 -0
  2. package/core/built/assets/ghost-dark-887882218a8f9a4a367de52212d27917.css +1 -0
  3. package/core/built/assets/ghost.min-0b3ecc9dd9e8b3b380d93f1839213af5.css +1 -0
  4. package/core/built/assets/{ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js → ghost.min-aafce1ab3f2ab6b4a385e8b888548e15.js} +391 -290
  5. package/core/built/assets/img/abstract-2-2937e2902b64360d0cbe4cec8bd8479b.jpg +0 -0
  6. package/core/built/assets/img/abstract-c52b2f4208e7fd2e7b8abd8b1eec4f7b.jpg +0 -0
  7. package/core/built/assets/img/community-be8c1dcecfb157f2bfba5cababc8e686.jpg +0 -0
  8. package/core/built/assets/{vendor.min-21f79c68a284acb1b70039f3f63e5507.js → vendor.min-eaf9e7b39e2ba76722eabc7a814e0ff1.js} +103 -94
  9. package/core/frontend/apps/amp/lib/helpers/amp_content.js +2 -3
  10. package/core/frontend/web/middleware/cors.js +56 -0
  11. package/core/frontend/web/middleware/index.js +1 -0
  12. package/core/frontend/web/middleware/static-theme.js +8 -8
  13. package/core/frontend/web/site.js +1 -48
  14. package/core/server/api/canary/authentication.js +2 -2
  15. package/core/server/api/canary/newsletters.js +32 -0
  16. package/core/server/api/canary/posts.js +1 -0
  17. package/core/server/api/canary/stats.js +2 -2
  18. package/core/server/api/canary/utils/serializers/output/members.js +3 -0
  19. package/core/server/api/shared/http.js +1 -1
  20. package/core/server/data/importer/importers/data/settings.js +0 -3
  21. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +4 -4
  22. package/core/server/data/migrations/versions/4.44/2022-04-06-15-22-populate-type-column-for-paid-subscription-events.js +21 -0
  23. package/core/server/data/migrations/versions/4.44/2022-04-08-11-54-add-cancelled-events.js +51 -0
  24. package/core/server/data/migrations/versions/4.44/2022-04-11-08-24-add-newsletter-permissions.js +33 -0
  25. package/core/server/data/migrations/versions/4.44/2022-04-11-10-54-add-mrr-to-subscriptions.js +8 -0
  26. package/core/server/data/migrations/versions/4.44/2022-04-12-07-33-fill-mrr.js +29 -0
  27. package/core/server/data/migrations/versions/4.44/2022-04-13-12-00-remove-newsletter-sender-name-not-null-constraint.js +33 -0
  28. package/core/server/data/migrations/versions/4.44/2022-04-15-07-53-add-offer-id-to-subscriptions.js +9 -0
  29. package/core/server/data/migrations/versions/4.45/2022-04-19-12-23-backfill-subscriptions-offers.js +60 -0
  30. package/core/server/data/migrations/versions/4.45/2022-04-20-11-25-add-newsletter-read-permission.js +9 -0
  31. package/core/server/data/migrations/versions/4.45/2022-04-21-02-55-add-notifications-key-entry-to-settings-table.js +8 -0
  32. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  33. package/core/server/data/schema/fixtures/fixtures.json +31 -1
  34. package/core/server/data/schema/schema.js +7 -5
  35. package/core/server/models/label.js +1 -1
  36. package/core/server/models/member.js +3 -0
  37. package/core/server/models/newsletter.js +9 -2
  38. package/core/server/models/post.js +9 -2
  39. package/core/server/models/role.js +1 -1
  40. package/core/server/models/stripe-customer-subscription.js +4 -0
  41. package/core/server/models/tag.js +1 -1
  42. package/core/server/models/user.js +1 -1
  43. package/core/server/services/api-version-compatibility/index.js +26 -0
  44. package/core/server/services/auth/setup.js +17 -7
  45. package/core/server/services/mega/mega.js +3 -1
  46. package/core/server/services/members/middleware.js +10 -2
  47. package/core/server/services/members/service.js +3 -11
  48. package/core/server/services/posts/posts-service.js +20 -1
  49. package/core/server/services/stats/service.js +2 -6
  50. package/core/server/web/admin/views/default-prod.html +4 -4
  51. package/core/server/web/admin/views/default.html +4 -4
  52. package/core/server/web/api/app.js +3 -0
  53. package/core/server/web/api/canary/admin/app.js +3 -0
  54. package/core/server/web/api/canary/admin/routes.js +1 -0
  55. package/core/server/web/api/canary/content/app.js +3 -0
  56. package/core/server/web/api/middleware/cors.js +1 -1
  57. package/core/server/web/api/v2/admin/app.js +3 -0
  58. package/core/server/web/api/v2/content/app.js +3 -0
  59. package/core/server/web/api/v3/admin/app.js +3 -0
  60. package/core/server/web/api/v3/content/app.js +3 -0
  61. package/core/shared/config/defaults.json +2 -2
  62. package/core/shared/labs.js +3 -1
  63. package/core/shared/settings-cache/public.js +1 -1
  64. package/package.json +39 -35
  65. package/yarn.lock +367 -311
  66. package/core/built/assets/ghost-dark-1933079797e24ccb8839657020830be5.css +0 -1
  67. package/core/built/assets/ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css +0 -1
  68. package/core/server/services/stats/lib/members-stats-service.js +0 -161
  69. package/core/server/services/stats/lib/mrr-stats-service.js +0 -154
@@ -187,9 +187,8 @@ function ampContent() {
187
187
  // Use cheerio to traverse through HTML and make little clean-ups
188
188
  $ = cheerio.load(ampHTML);
189
189
 
190
- // We have to remove source children in video, as source
191
- // is whitelisted for audio, but causes validation
192
- // errors in video, because video will be stripped out.
190
+ // We have to remove source children in video, as source is allowed for audio,
191
+ // but causes validation errors in video, because video will be stripped out.
193
192
  // @TODO: remove this, when Amperize support video transform
194
193
  $('video').children('source').remove();
195
194
  $('video').children('track').remove();
@@ -0,0 +1,56 @@
1
+ const {URL} = require('url');
2
+ const cors = require('cors');
3
+ const errors = require('@tryghost/errors');
4
+ const config = require('../../../shared/config');
5
+
6
+ /**
7
+ * Dynamically configures the expressjs/cors middleware
8
+ *
9
+ * @param {import('express').Request} req
10
+ * @param {Function} callback
11
+ */
12
+ function corsOptionsDelegate(req, callback) {
13
+ const origin = req.header('Origin');
14
+ const corsOptions = {
15
+ origin: false, // disallow cross-origin requests by default
16
+ credentials: true // required to allow admin-client to login to private sites
17
+ };
18
+
19
+ if (!origin || origin === 'null') {
20
+ return callback(null, corsOptions);
21
+ }
22
+
23
+ let originUrl;
24
+ try {
25
+ originUrl = new URL(origin);
26
+ } catch (err) {
27
+ return callback(new errors.BadRequestError({err}));
28
+ }
29
+
30
+ // originUrl will definitely exist here because according to WHATWG URL spec
31
+ // The class constructor will either throw a TypeError or return a URL object
32
+ // https://url.spec.whatwg.org/#url-class
33
+
34
+ // allow all localhost and 127.0.0.1 requests no matter the port
35
+ if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1') {
36
+ corsOptions.origin = true;
37
+ }
38
+
39
+ // allow the configured host through on any protocol
40
+ const siteUrl = new URL(config.get('url'));
41
+ if (originUrl.host === siteUrl.host) {
42
+ corsOptions.origin = true;
43
+ }
44
+
45
+ // allow the configured admin:url host through on any protocol
46
+ if (config.get('admin:url')) {
47
+ const adminUrl = new URL(config.get('admin:url'));
48
+ if (originUrl.host === adminUrl.host) {
49
+ corsOptions.origin = true;
50
+ }
51
+ }
52
+
53
+ callback(null, corsOptions);
54
+ }
55
+
56
+ module.exports = cors(corsOptionsDelegate);
@@ -1,4 +1,5 @@
1
1
  module.exports = {
2
+ cors: require('./cors'),
2
3
  errorHandler: require('./error-handler'),
3
4
  handleImageSizes: require('./handle-image-sizes'),
4
5
  redirectGhostToAdmin: require('./redirect-ghost-to-admin'),
@@ -4,18 +4,18 @@ const constants = require('@tryghost/constants');
4
4
  const themeEngine = require('../../services/theme-engine');
5
5
  const express = require('../../../shared/express');
6
6
 
7
- function isBlackListedFileType(file) {
8
- const blackListedFileTypes = ['.hbs', '.md', '.json'];
7
+ function isDeniedFile(file) {
8
+ const deniedFileTypes = ['.hbs', '.md', '.json'];
9
9
  const ext = path.extname(file);
10
10
 
11
- return blackListedFileTypes.includes(ext);
11
+ return deniedFileTypes.includes(ext);
12
12
  }
13
13
 
14
- function isWhiteListedFile(file) {
15
- const whiteListedFiles = ['manifest.json'];
14
+ function isAllowedFile(file) {
15
+ const allowedFiles = ['manifest.json'];
16
16
  const base = path.basename(file);
17
17
 
18
- return whiteListedFiles.includes(base);
18
+ return allowedFiles.includes(base);
19
19
  }
20
20
 
21
21
  function forwardToExpressStatic(req, res, next) {
@@ -31,8 +31,8 @@ function forwardToExpressStatic(req, res, next) {
31
31
  }
32
32
 
33
33
  function staticTheme() {
34
- return function blackListStatic(req, res, next) {
35
- if (!isWhiteListedFile(req.path) && isBlackListedFileType(req.path)) {
34
+ return function denyStatic(req, res, next) {
35
+ if (!isAllowedFile(req.path) && isDeniedFile(req.path)) {
36
36
  return next();
37
37
  }
38
38
 
@@ -1,9 +1,6 @@
1
1
  const debug = require('@tryghost/debug')('frontend');
2
2
  const path = require('path');
3
3
  const express = require('../../shared/express');
4
- const cors = require('cors');
5
- const {URL} = require('url');
6
- const errors = require('@tryghost/errors');
7
4
  const DomainEvents = require('@tryghost/domain-events');
8
5
  const {MemberPageViewEvent} = require('@tryghost/member-events');
9
6
 
@@ -31,50 +28,6 @@ const STATIC_FILES_URL_PREFIX = `/${constants.STATIC_FILES_URL_PREFIX}`;
31
28
 
32
29
  let router;
33
30
 
34
- const corsOptionsDelegate = function corsOptionsDelegate(req, callback) {
35
- const origin = req.header('Origin');
36
- const corsOptions = {
37
- origin: false, // disallow cross-origin requests by default
38
- credentials: true // required to allow admin-client to login to private sites
39
- };
40
-
41
- if (!origin || origin === 'null') {
42
- return callback(null, corsOptions);
43
- }
44
-
45
- let originUrl;
46
- try {
47
- originUrl = new URL(origin);
48
- } catch (err) {
49
- return callback(new errors.BadRequestError({err}));
50
- }
51
-
52
- // originUrl will definitely exist here because according to WHATWG URL spec
53
- // The class constructor will either throw a TypeError or return a URL object
54
- // https://url.spec.whatwg.org/#url-class
55
-
56
- // allow all localhost and 127.0.0.1 requests no matter the port
57
- if (originUrl.hostname === 'localhost' || originUrl.hostname === '127.0.0.1') {
58
- corsOptions.origin = true;
59
- }
60
-
61
- // allow the configured host through on any protocol
62
- const siteUrl = new URL(config.get('url'));
63
- if (originUrl.host === siteUrl.host) {
64
- corsOptions.origin = true;
65
- }
66
-
67
- // allow the configured admin:url host through on any protocol
68
- if (config.get('admin:url')) {
69
- const adminUrl = new URL(config.get('admin:url'));
70
- if (originUrl.host === adminUrl.host) {
71
- corsOptions.origin = true;
72
- }
73
- }
74
-
75
- callback(null, corsOptions);
76
- };
77
-
78
31
  function SiteRouter(req, res, next) {
79
32
  router(req, res, next);
80
33
  }
@@ -89,7 +42,7 @@ module.exports = function setupSiteApp(options = {}) {
89
42
  siteApp.set('view engine', 'hbs');
90
43
 
91
44
  // enable CORS headers (allows admin client to hit front-end when configured on separate URLs)
92
- siteApp.use(cors(corsOptionsDelegate));
45
+ siteApp.use(mw.cors);
93
46
 
94
47
  siteApp.use(offersService.middleware);
95
48
 
@@ -51,14 +51,14 @@ module.exports = {
51
51
  })
52
52
  .then((data) => {
53
53
  try {
54
- return auth.setup.doFixtures(data, api.products);
54
+ return auth.setup.doFixtures(data);
55
55
  } catch (e) {
56
56
  return data;
57
57
  }
58
58
  })
59
59
  .then((data) => {
60
60
  try {
61
- return auth.setup.doProduct(data, api.products);
61
+ return auth.setup.doProductAndNewsletter(data, api);
62
62
  } catch (e) {
63
63
  return data;
64
64
  }
@@ -1,4 +1,10 @@
1
1
  const models = require('../../models');
2
+ const tpl = require('@tryghost/tpl');
3
+ const errors = require('@tryghost/errors');
4
+
5
+ const messages = {
6
+ newsletterNotFound: 'Newsletter not found.'
7
+ };
2
8
 
3
9
  module.exports = {
4
10
  docName: 'newsletters',
@@ -17,6 +23,32 @@ module.exports = {
17
23
  }
18
24
  },
19
25
 
26
+ read: {
27
+ options: [
28
+ 'fields',
29
+ 'debug',
30
+ // NOTE: only for internal context
31
+ 'forUpdate',
32
+ 'transacting'
33
+ ],
34
+ data: [
35
+ 'id',
36
+ 'slug',
37
+ 'uuid'
38
+ ],
39
+ permissions: true,
40
+ async query(frame) {
41
+ const newsletter = models.Newsletter.findOne(frame.data, frame.options);
42
+
43
+ if (!newsletter) {
44
+ throw new errors.NotFoundError({
45
+ message: tpl(messages.newsletterNotFound)
46
+ });
47
+ }
48
+ return newsletter;
49
+ }
50
+ },
51
+
20
52
  add: {
21
53
  statusCode: 201,
22
54
  permissions: true,
@@ -129,6 +129,7 @@ module.exports = {
129
129
  'formats',
130
130
  'source',
131
131
  'email_recipient_filter',
132
+ 'newsletter_id',
132
133
  'send_email_when_published',
133
134
  'force_rerender',
134
135
  // NOTE: only for internal context
@@ -8,7 +8,7 @@ module.exports = {
8
8
  method: 'browse'
9
9
  },
10
10
  async query() {
11
- return await statsService.members.getCountHistory();
11
+ return await statsService.getMemberCountHistory();
12
12
  }
13
13
  },
14
14
  mrr: {
@@ -17,7 +17,7 @@ module.exports = {
17
17
  method: 'browse'
18
18
  },
19
19
  async query() {
20
- return await statsService.mrr.getHistory();
20
+ return await statsService.getMRRHistory();
21
21
  }
22
22
  }
23
23
  };
@@ -133,6 +133,9 @@ function serializeMember(member, options) {
133
133
  }
134
134
 
135
135
  if (json.newsletters && labsService.isSet('multipleNewsletters')) {
136
+ json.newsletters.sort((a, b) => {
137
+ return a.sort_order - b.sort_order;
138
+ });
136
139
  serialized.newsletters = json.newsletters;
137
140
  }
138
141
 
@@ -83,7 +83,7 @@ const http = (apiImpl) => {
83
83
 
84
84
  // CASE: generate headers based on the api ctrl configuration
85
85
  if (req && req.headers && req.headers['accept-version'] && res.locals) {
86
- headers['content-version'] = `v${res.locals.safeVersion}`;
86
+ headers['Content-Version'] = `v${res.locals.safeVersion}`;
87
87
  }
88
88
  res.set(headers);
89
89
 
@@ -80,9 +80,6 @@ class SettingsImporter extends BaseImporter {
80
80
  };
81
81
  }
82
82
 
83
- /**
84
- * - 'core' and 'theme' are blacklisted
85
- */
86
83
  beforeImport() {
87
84
  debug('beforeImport');
88
85
 
@@ -7,7 +7,7 @@ module.exports = recreateTable('newsletters', {
7
7
  slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
8
8
  sender_name: {type: 'string', maxlength: 191, nullable: false},
9
9
  sender_email: {type: 'string', maxlength: 191, nullable: true},
10
- sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter', validations: {isIn: ['newsletter', 'support']}},
10
+ sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter', validations: {isIn: [['newsletter', 'support']]}},
11
11
  status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active'},
12
12
  visibility: {
13
13
  type: 'string',
@@ -20,10 +20,10 @@ module.exports = recreateTable('newsletters', {
20
20
  header_image: {type: 'string', maxlength: 2000, nullable: true},
21
21
  show_header_icon: {type: 'bool', nullable: false, defaultTo: true},
22
22
  show_header_title: {type: 'bool', nullable: false, defaultTo: true},
23
- title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: ['serif', 'sans_serif']}},
24
- title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: ['center', 'left']}},
23
+ title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}},
24
+ title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}},
25
25
  show_feature_image: {type: 'bool', nullable: false, defaultTo: true},
26
- body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: ['serif', 'sans_serif']}},
26
+ body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}},
27
27
  footer_content: {type: 'text', maxlength: 1000000000, nullable: true},
28
28
  show_badge: {type: 'bool', nullable: false, defaultTo: true}
29
29
  });
@@ -0,0 +1,21 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ logging.info('Setting "type" to "updated" for events with different to_plan & from_plan');
8
+ await knex('members_paid_subscription_events').update('type', 'updated').whereNotNull('from_plan').whereNotNull('to_plan').whereRaw('to_plan != from_plan');
9
+
10
+ logging.info('Setting "type" to "expired" for events with null to_plan or the same to_plan & from_plan');
11
+ await knex('members_paid_subscription_events').update('type', 'expired').whereNull('to_plan').whereNotNull('from_plan');
12
+ await knex('members_paid_subscription_events').update('type', 'expired').whereRaw('from_plan = to_plan');
13
+
14
+ logging.info('Setting "type" to "created" for events with null from_plan');
15
+ await knex('members_paid_subscription_events').update('type', 'created').whereNull('from_plan').whereNotNull('to_plan');
16
+ },
17
+ async function down(knex) {
18
+ logging.info('Setting "type" to null for all rows in "members_paid_subscriptions events"');
19
+ await knex('members_paid_subscription_events').update('type', null);
20
+ }
21
+ );
@@ -0,0 +1,51 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+ const logging = require('@tryghost/logging');
3
+
4
+ const {createTransactionalMigration} = require('../../utils');
5
+
6
+ module.exports = createTransactionalMigration(
7
+ async function up(knex) {
8
+ const cancelledSubscriptions = await knex
9
+ .select(
10
+ 'members.id as member_id',
11
+ 'members_stripe_customers_subscriptions.id',
12
+ 'members_stripe_customers_subscriptions.stripe_price_id',
13
+ 'members_stripe_customers_subscriptions.plan_currency',
14
+ 'members_stripe_customers_subscriptions.updated_at'
15
+ )
16
+ .from('members_stripe_customers_subscriptions')
17
+ .join('members_stripe_customers', 'members_stripe_customers.customer_id', '=', 'members_stripe_customers_subscriptions.customer_id')
18
+ .join('members', 'members_stripe_customers.member_id', '=', 'members.id')
19
+ .where('members_stripe_customers_subscriptions.cancel_at_period_end', true)
20
+ .whereNot('members_stripe_customers_subscriptions.status', 'canceled');
21
+
22
+ if (cancelledSubscriptions.length === 0) {
23
+ logging.info('No missing cancelled events - skipping migration');
24
+ return;
25
+ }
26
+
27
+ const eventsToInsert = cancelledSubscriptions.map((subscription) => {
28
+ const event = {
29
+ id: (new ObjectID()).toHexString(),
30
+ type: 'canceled',
31
+ member_id: subscription.member_id,
32
+ subscription_id: subscription.id,
33
+ from_plan: subscription.stripe_price_id,
34
+ to_plan: subscription.stripe_price_id,
35
+ currency: subscription.plan_currency,
36
+ source: 'migration',
37
+ mrr_delta: 0,
38
+ created_at: subscription.updated_at
39
+ };
40
+
41
+ return event;
42
+ });
43
+
44
+ logging.info(`Found ${eventsToInsert.length} missing cancellation events`);
45
+ await knex('members_paid_subscription_events').insert(eventsToInsert);
46
+ },
47
+ async function down(knex) {
48
+ logging.info('Deleting all members_paid_subscription_events with a "type" of "cancelled"');
49
+ await knex('members_paid_subscription_events').where({type: 'canceled', source: 'migration'}).del();
50
+ }
51
+ );
@@ -0,0 +1,33 @@
1
+ const {
2
+ addPermissionWithRoles,
3
+ combineTransactionalMigrations
4
+ } = require('../../utils');
5
+
6
+ /**
7
+ * This is similar to core/server/data/migrations/versions/4.42/2022-03-30-15-44-add-newsletter-permissions.js
8
+ * as the permissions were not added in the fixture file at the time of the migration.
9
+ * This means the new Ghost installs do not have the newsletter permission and we need this migration.
10
+ */
11
+ module.exports = combineTransactionalMigrations(
12
+ addPermissionWithRoles({
13
+ name: 'Browse newsletters',
14
+ action: 'browse',
15
+ object: 'newsletter'
16
+ }, [
17
+ 'Administrator'
18
+ ]),
19
+ addPermissionWithRoles({
20
+ name: 'Add newsletters',
21
+ action: 'add',
22
+ object: 'newsletter'
23
+ }, [
24
+ 'Administrator'
25
+ ]),
26
+ addPermissionWithRoles({
27
+ name: 'Edit newsletters',
28
+ action: 'edit',
29
+ object: 'newsletter'
30
+ }, [
31
+ 'Administrator'
32
+ ])
33
+ );
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'mrr', {
4
+ type: 'integer',
5
+ unsigned: true,
6
+ nullable: false,
7
+ defaultTo: 0
8
+ });
@@ -0,0 +1,29 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ logging.info('Setting "mrr" for active subscriptions in "members_stripe_customers_subscriptions"');
8
+
9
+ // Note that we also set the MRR for 'canceled' subscriptions (cancel_at_period_end === true)
10
+ // A different migration will make that change in 5.0
11
+ await knex('members_stripe_customers_subscriptions')
12
+ .update('mrr', knex.raw(`
13
+ CASE WHEN plan_interval = 'year' THEN
14
+ FLOOR(plan_amount / 12)
15
+ WHEN plan_interval = 'week' THEN
16
+ plan_amount * 4
17
+ WHEN plan_interval = 'day' THEN
18
+ plan_amount * 30
19
+ ELSE
20
+ plan_amount
21
+ END
22
+ `))
23
+ .whereNotIn('status', ['trialing', 'incomplete', 'incomplete_expired', 'canceled']);
24
+ },
25
+ async function down(knex) {
26
+ logging.info('Setting "mrr" to 0 for all rows in "members_stripe_customers_subscriptions"');
27
+ await knex('members_stripe_customers_subscriptions').update('mrr', 0);
28
+ }
29
+ );
@@ -0,0 +1,33 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createNonTransactionalMigration} = require('../../utils');
3
+
4
+ /**
5
+ * Note: This doesn't use knex.alterTable as it doesn't work for down migration.
6
+ * It tries to insert a `null` into non `nullable` column while altering column
7
+ */
8
+ module.exports = createNonTransactionalMigration(
9
+ async function up(knex) {
10
+ logging.info('Dropping NOT NULL constraint for: sender_name in table: newsletters');
11
+
12
+ await knex.schema.table('newsletters', function (table) {
13
+ table.dropColumn('sender_name');
14
+ });
15
+
16
+ await knex.schema.table('newsletters', function (table) {
17
+ table.string('sender_name', 191).nullable();
18
+ });
19
+ },
20
+ async function down(knex) {
21
+ logging.info('Adding NOT NULL constraint for: sender_name in table: newsletters');
22
+
23
+ await knex.schema.table('newsletters', function (table) {
24
+ table.dropColumn('sender_name');
25
+ });
26
+
27
+ await knex.schema.table('newsletters', function (table) {
28
+ // SQLite doesn't allow adding a non nullable column without any default
29
+ table.string('sender_name', 191).notNullable().defaultTo('Ghost');
30
+ });
31
+ }
32
+ );
33
+
@@ -0,0 +1,9 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'offer_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true,
7
+ unique: false,
8
+ references: 'offers.id'
9
+ });
@@ -0,0 +1,60 @@
1
+ const logging = require('@tryghost/logging');
2
+ const DatabaseInfo = require('@tryghost/database-info');
3
+
4
+ const {createTransactionalMigration} = require('../../utils');
5
+
6
+ module.exports = createTransactionalMigration(
7
+ async function up(knex) {
8
+ logging.info('Backfilling "offer_id" column in "members_stripe_customers_subscriptions" by matching tier and cadence');
9
+
10
+ const subquery = `
11
+ SELECT
12
+ members_stripe_customers_subscriptions.id as subscription_id,
13
+ offer_redemptions.offer_id as offer_id
14
+ FROM
15
+ members_stripe_customers_subscriptions
16
+ JOIN offer_redemptions ON offer_redemptions.subscription_id = members_stripe_customers_subscriptions.id
17
+ JOIN offers ON offers.id = offer_redemptions.offer_id
18
+ JOIN stripe_prices ON members_stripe_customers_subscriptions.stripe_price_id = stripe_prices.stripe_price_id
19
+ JOIN stripe_products ON stripe_prices.stripe_product_id = stripe_products.stripe_product_id
20
+ WHERE
21
+ offers.product_id = stripe_products.product_id
22
+ AND offers.interval = stripe_prices.interval
23
+ AND members_stripe_customers_subscriptions.offer_id is null
24
+ `;
25
+
26
+ if (DatabaseInfo.isSQLite(knex)) {
27
+ // Less optimized for SQLite
28
+ const result = await knex.raw(subquery);
29
+ const updatedRows = result.length;
30
+ const subscriptionsToUpdate = result;
31
+
32
+ logging.info(`Setting the offer_id for ${updatedRows} members_stripe_customers_subscriptions`);
33
+
34
+ // eslint-disable-next-line no-restricted-syntax
35
+ for (const u of subscriptionsToUpdate) {
36
+ // eslint-disable-next-line no-restricted-syntax
37
+ await knex('members_stripe_customers_subscriptions')
38
+ .update('offer_id', u.offer_id)
39
+ .where('id', u.subscription_id);
40
+ }
41
+ } else {
42
+ // Single update query
43
+ const query = `
44
+ UPDATE
45
+ members_stripe_customers_subscriptions,
46
+ (${subquery}) as c
47
+ SET members_stripe_customers_subscriptions.offer_id = c.offer_id
48
+ WHERE c.subscription_id = members_stripe_customers_subscriptions.id
49
+ `;
50
+
51
+ const result = await knex.raw(query);
52
+ const updatedRows = result[0].affectedRows;
53
+
54
+ logging.info(`Updated ${updatedRows} members_stripe_customers_subscriptions with an offer_id`);
55
+ }
56
+ },
57
+ async function down() {
58
+ // We risk losing data if we would reset offer_id here
59
+ }
60
+ );
@@ -0,0 +1,9 @@
1
+ const {addPermissionWithRoles} = require('../../utils');
2
+
3
+ module.exports = addPermissionWithRoles({
4
+ name: 'Read newsletters',
5
+ action: 'read',
6
+ object: 'newsletter'
7
+ }, [
8
+ 'Administrator'
9
+ ]);
@@ -0,0 +1,8 @@
1
+ const {addSetting} = require('../../utils.js');
2
+
3
+ module.exports = addSetting({
4
+ key: 'version_notifications',
5
+ value: '[]',
6
+ type: 'array',
7
+ group: 'core'
8
+ });
@@ -16,6 +16,10 @@
16
16
  "defaultValue": "[]",
17
17
  "type": "array"
18
18
  },
19
+ "version_notifications": {
20
+ "defaultValue": "[]",
21
+ "type": "array"
22
+ },
19
23
  "session_secret": {
20
24
  "defaultValue": null,
21
25
  "type": "string"