ghost 4.38.1 → 4.40.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 (104) hide show
  1. package/.c8rc.json +1 -1
  2. package/Gruntfile.js +1 -1
  3. package/README.md +26 -18
  4. package/core/built/assets/{chunk.3.4906cf0b01d6d8e33374.js → chunk.3.6e2ed2d00856e12bd81a.js} +19 -19
  5. package/core/built/assets/ghost-dark-498ff8339a89bb68c3f78f59bee4146e.css +1 -0
  6. package/core/built/assets/ghost.min-77b93478f83b0def6ddc5a4f23ce963e.css +1 -0
  7. package/core/built/assets/{ghost.min-6386b02480494a69c3bfe66206754836.js → ghost.min-88c665c3ba304b4f220d08b8bcf9d246.js} +525 -538
  8. package/core/built/assets/icons/{event-changed-subscription.svg → event-subscriptions.svg} +0 -1
  9. package/core/built/assets/icons/member.svg +3 -0
  10. package/core/built/assets/{vendor.min-c814d3c4b3f543c4cd5ef3aacd0fc645.js → vendor.min-ed945ad80ea22f1d3ffeec6d5ae63aee.js} +2355 -1419
  11. package/core/frontend/apps/private-blogging/lib/middleware.js +1 -1
  12. package/core/frontend/apps/private-blogging/lib/views/private.hbs +9 -10
  13. package/core/frontend/public/ghost.css +205 -143
  14. package/core/frontend/public/ghost.min.css +1 -1
  15. package/core/frontend/services/theme-engine/middleware/update-local-template-options.js +3 -1
  16. package/core/frontend/views/unsubscribe.hbs +28 -33
  17. package/core/frontend/web/middleware/error-handler.js +2 -2
  18. package/core/server/api/canary/authentication.js +7 -0
  19. package/core/server/api/canary/identities.js +0 -1
  20. package/core/server/api/canary/members.js +7 -1
  21. package/core/server/api/canary/tiers.js +3 -1
  22. package/core/server/api/canary/utils/serializers/input/pages.js +1 -1
  23. package/core/server/api/canary/utils/serializers/input/posts.js +1 -1
  24. package/core/server/api/canary/utils/serializers/input/tiers.js +17 -0
  25. package/core/server/api/canary/utils/serializers/output/actions.js +2 -2
  26. package/core/server/api/canary/utils/serializers/output/authentication.js +3 -3
  27. package/core/server/api/canary/utils/serializers/output/authors.js +3 -3
  28. package/core/server/api/canary/utils/serializers/output/email-posts.js +2 -2
  29. package/core/server/api/canary/utils/serializers/output/emails.js +3 -3
  30. package/core/server/api/canary/utils/serializers/output/images.js +2 -2
  31. package/core/server/api/canary/utils/serializers/output/integrations.js +5 -6
  32. package/core/server/api/canary/utils/serializers/output/labels.js +3 -3
  33. package/core/server/api/canary/utils/serializers/output/mappers/actions.js +7 -0
  34. package/core/server/api/canary/utils/serializers/output/mappers/emails.js +17 -0
  35. package/core/server/api/canary/utils/serializers/output/mappers/images.js +5 -0
  36. package/core/server/api/canary/utils/serializers/output/mappers/index.js +12 -0
  37. package/core/server/api/canary/utils/serializers/output/mappers/integrations.js +13 -0
  38. package/core/server/api/canary/utils/serializers/output/mappers/labels.js +4 -0
  39. package/core/server/api/canary/utils/serializers/output/mappers/pages.js +11 -0
  40. package/core/server/api/canary/utils/serializers/output/mappers/posts.js +101 -0
  41. package/core/server/api/canary/utils/serializers/output/mappers/settings.js +37 -0
  42. package/core/server/api/canary/utils/serializers/output/mappers/tags.js +11 -0
  43. package/core/server/api/canary/utils/serializers/output/mappers/users.js +12 -0
  44. package/core/server/api/canary/utils/serializers/output/members.js +2 -7
  45. package/core/server/api/canary/utils/serializers/output/pages.js +3 -3
  46. package/core/server/api/canary/utils/serializers/output/posts.js +3 -3
  47. package/core/server/api/canary/utils/serializers/output/preview.js +2 -2
  48. package/core/server/api/canary/utils/serializers/output/settings.js +2 -2
  49. package/core/server/api/canary/utils/serializers/output/tags.js +3 -3
  50. package/core/server/api/canary/utils/serializers/output/users.js +3 -3
  51. package/core/server/api/shared/serializers/handle.js +2 -2
  52. package/core/server/api/v2/utils/serializers/input/pages.js +1 -1
  53. package/core/server/api/v2/utils/serializers/input/posts.js +1 -1
  54. package/core/server/api/v3/utils/serializers/input/pages.js +1 -1
  55. package/core/server/api/v3/utils/serializers/input/posts.js +1 -1
  56. package/core/server/data/exporter/table-lists.js +1 -0
  57. package/core/server/data/importer/import-manager.js +152 -113
  58. package/core/server/data/migrations/versions/4.33/2022-01-14-11-51-add-default-free-tier.js +3 -0
  59. package/core/server/data/migrations/versions/4.39/2022-03-07-10-57-update-free-products-visibility-column.js +66 -0
  60. package/core/server/data/migrations/versions/4.39/2022-03-07-10-57-update-products-visibility-column.js +36 -0
  61. package/core/server/data/migrations/versions/4.40/2022-03-07-14-37-add-members-cancel-events-table.js +8 -0
  62. package/core/server/data/migrations/versions/4.40/2022-03-15-06-40-add-offers-admin-integration-permission-roles.js +23 -0
  63. package/core/server/data/migrations/versions/4.40/2022-03-15-06-40-add-tiers-admin-integration-permission-roles.js +20 -0
  64. package/core/server/data/schema/default-settings/default-settings.json +2 -2
  65. package/core/server/data/schema/fixtures/fixtures.json +17 -160
  66. package/core/server/data/schema/schema.js +6 -0
  67. package/core/server/lib/image/image-size.js +12 -4
  68. package/core/server/models/base/plugins/generate-slug.js +13 -1
  69. package/core/server/models/base/plugins/raw-knex.js +1 -1
  70. package/core/server/models/member-cancel-event.js +28 -0
  71. package/core/server/models/post.js +16 -6
  72. package/core/server/models/user.js +1 -1
  73. package/core/server/services/auth/setup.js +29 -13
  74. package/core/server/services/mega/mega.js +4 -4
  75. package/core/server/services/mega/template.js +2 -1
  76. package/core/server/services/members/api.js +1 -0
  77. package/core/server/services/members/content-gating.js +1 -1
  78. package/core/server/services/members/middleware.js +1 -0
  79. package/core/server/services/posts/posts-service.js +1 -1
  80. package/core/server/services/themes/validate.js +3 -3
  81. package/core/server/services/url/UrlGenerator.js +1 -1
  82. package/core/server/services/webhooks/webhooks-service.js +2 -0
  83. package/core/server/views/maintenance.html +2 -2
  84. package/core/server/web/admin/views/default-prod.html +4 -4
  85. package/core/server/web/admin/views/default.html +4 -4
  86. package/core/server/web/api/app.js +0 -3
  87. package/core/server/web/api/canary/admin/middleware.js +2 -0
  88. package/core/server/web/parent/backend.js +2 -1
  89. package/core/server/web/shared/middleware/uncapitalise.js +2 -1
  90. package/core/shared/config/defaults.json +2 -2
  91. package/core/shared/config/overrides.json +7 -3
  92. package/core/shared/labs.js +8 -10
  93. package/core/shared/url-utils.js +4 -1
  94. package/package.json +37 -36
  95. package/yarn.lock +513 -329
  96. package/core/built/assets/ghost-dark-9f760f16230b8bc52e188d6ce28516b0.css +0 -1
  97. package/core/built/assets/ghost.min-f4c59dd57a2136df8b0a34f87c099034.css +0 -1
  98. package/core/built/assets/icons/event-started-subscription.svg +0 -6
  99. package/core/built/assets/icons/locked-email-back.svg +0 -1
  100. package/core/built/assets/icons/locked-email-front.svg +0 -1
  101. package/core/built/assets/icons/locked-email-lock.svg +0 -1
  102. package/core/built/assets/img/ghost-logo-de2acf283f53ba1fd1149928faeaaa74.png +0 -0
  103. package/core/server/api/canary/utils/serializers/output/utils/mapper.js +0 -213
  104. package/core/server/frontend/ghost.min.css +0 -1
@@ -1,6 +1,6 @@
1
1
  const debug = require('@tryghost/debug')('api:canary:utils:serializers:output:users');
2
2
  const tpl = require('@tryghost/tpl');
3
- const mapper = require('./utils/mapper');
3
+ const mappers = require('./mappers');
4
4
 
5
5
  const messages = {
6
6
  pwdChangedSuccessfully: 'Password changed successfully.'
@@ -11,7 +11,7 @@ module.exports = {
11
11
  debug('browse');
12
12
 
13
13
  frame.response = {
14
- users: models.data.map(model => mapper.mapUser(model, frame)),
14
+ users: models.data.map(model => mappers.users(model, frame)),
15
15
  meta: models.meta
16
16
  };
17
17
  },
@@ -20,7 +20,7 @@ module.exports = {
20
20
  debug('read');
21
21
 
22
22
  frame.response = {
23
- users: [mapper.mapUser(model, frame)]
23
+ users: [mappers.users(model, frame)]
24
24
  };
25
25
  },
26
26
 
@@ -21,11 +21,11 @@ module.exports.input = (apiConfig, apiSerializers, frame) => {
21
21
  const tasks = [];
22
22
  const sharedSerializers = require('./input');
23
23
 
24
- if (!apiSerializers) {
24
+ if (!apiConfig) {
25
25
  return Promise.reject(new errors.IncorrectUsageError());
26
26
  }
27
27
 
28
- if (!apiConfig) {
28
+ if (!apiSerializers) {
29
29
  return Promise.reject(new errors.IncorrectUsageError());
30
30
  }
31
31
 
@@ -1,5 +1,5 @@
1
1
  const _ = require('lodash');
2
- const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
2
+ const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues;
3
3
  const debug = require('@tryghost/debug')('api:v2:utils:serializers:input:pages');
4
4
  const mobiledoc = require('../../../../../lib/mobiledoc');
5
5
  const url = require('./utils/url');
@@ -1,5 +1,5 @@
1
1
  const _ = require('lodash');
2
- const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
2
+ const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues;
3
3
  const debug = require('@tryghost/debug')('api:v2:utils:serializers:input:posts');
4
4
  const url = require('./utils/url');
5
5
  const localUtils = require('../../index');
@@ -1,6 +1,6 @@
1
1
  const _ = require('lodash');
2
2
  const debug = require('@tryghost/debug')('api:v3:utils:serializers:input:pages');
3
- const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
3
+ const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues;
4
4
  const mobiledoc = require('../../../../../lib/mobiledoc');
5
5
  const url = require('./utils/url');
6
6
  const slugFilterOrder = require('./utils/slug-filter-order');
@@ -1,6 +1,6 @@
1
1
  const _ = require('lodash');
2
2
  const debug = require('@tryghost/debug')('api:v3:utils:serializers:input:posts');
3
- const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
3
+ const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues;
4
4
  const url = require('./utils/url');
5
5
  const slugFilterOrder = require('./utils/slug-filter-order');
6
6
  const localUtils = require('../../index');
@@ -31,6 +31,7 @@ const BACKUP_TABLES = [
31
31
  'mobiledoc_revisions',
32
32
  'email_batches',
33
33
  'email_recipients',
34
+ 'members_cancel_events',
34
35
  'members_payment_events',
35
36
  'members_login_events',
36
37
  'members_email_change_events',
@@ -1,12 +1,10 @@
1
1
  const _ = require('lodash');
2
- const Promise = require('bluebird');
3
2
  const fs = require('fs-extra');
4
3
  const path = require('path');
5
4
  const os = require('os');
6
5
  const glob = require('glob');
7
6
  const uuid = require('uuid');
8
7
  const {extract} = require('@tryghost/zip');
9
- const {pipeline, sequence} = require('@tryghost/promise');
10
8
  const tpl = require('@tryghost/tpl');
11
9
  const logging = require('@tryghost/logging');
12
10
  const errors = require('@tryghost/errors');
@@ -21,7 +19,6 @@ const messages = {
21
19
  error: 'Import could not clean up file ',
22
20
  context: 'Your site will continue to work as expected'
23
21
  },
24
- unsupportedRoonExport: 'Your zip file looks like an old format Roon export, please re-export your Roon blog and try again.',
25
22
  noContentToImport: 'Zip did not include any content to import.',
26
23
  invalidZipStructure: 'Invalid zip file structure.',
27
24
  invalidZipFileBaseDirectory: 'Invalid zip file: base directory read failed',
@@ -41,10 +38,20 @@ let defaults = {
41
38
 
42
39
  class ImportManager {
43
40
  constructor() {
41
+ /**
42
+ * @type {Importer[]} importers
43
+ */
44
44
  this.importers = [ImageImporter, DataImporter];
45
+
46
+ /**
47
+ * @type {Handler[]}
48
+ */
45
49
  this.handlers = [ImageHandler, JSONHandler, MarkdownHandler];
46
50
 
47
51
  // Keep track of file to cleanup at the end
52
+ /**
53
+ * @type {?string}
54
+ */
48
55
  this.fileToDelete = null;
49
56
  }
50
57
 
@@ -53,7 +60,7 @@ class ImportManager {
53
60
  * @returns {string[]}
54
61
  */
55
62
  getExtensions() {
56
- return _.flatten(_.union(_.map(this.handlers, 'extensions'), defaults.extensions));
63
+ return _.union(_.flatMap(this.handlers, 'extensions'), defaults.extensions);
57
64
  }
58
65
 
59
66
  /**
@@ -61,7 +68,7 @@ class ImportManager {
61
68
  * @returns {string[]}
62
69
  */
63
70
  getContentTypes() {
64
- return _.flatten(_.union(_.map(this.handlers, 'contentTypes'), defaults.contentTypes));
71
+ return _.union(_.flatMap(this.handlers, 'contentTypes'), defaults.contentTypes);
65
72
  }
66
73
 
67
74
  /**
@@ -69,7 +76,7 @@ class ImportManager {
69
76
  * @returns {string[]}
70
77
  */
71
78
  getDirectories() {
72
- return _.flatten(_.union(_.map(this.handlers, 'directories'), defaults.directories));
79
+ return _.union(_.flatMap(this.handlers, 'directories'), defaults.directories);
73
80
  }
74
81
 
75
82
  /**
@@ -85,7 +92,7 @@ class ImportManager {
85
92
 
86
93
  /**
87
94
  * @param {String[]} extensions
88
- * @param {Number} level
95
+ * @param {Number} [level]
89
96
  * @returns {String}
90
97
  */
91
98
  getExtensionGlob(extensions, level) {
@@ -98,7 +105,7 @@ class ImportManager {
98
105
  /**
99
106
  *
100
107
  * @param {String[]} directories
101
- * @param {Number} level
108
+ * @param {Number} [level]
102
109
  * @returns {String}
103
110
  */
104
111
  getDirectoryGlob(directories, level) {
@@ -110,26 +117,24 @@ class ImportManager {
110
117
 
111
118
  /**
112
119
  * Remove files after we're done (abstracted into a function for easier testing)
113
- * @returns {Function}
120
+ * @returns {Promise<void>}
114
121
  */
115
- cleanUp() {
116
- const self = this;
117
-
118
- if (self.fileToDelete === null) {
122
+ async cleanUp() {
123
+ if (this.fileToDelete === null) {
119
124
  return;
120
125
  }
121
126
 
122
- fs.remove(self.fileToDelete, function (err) {
123
- if (err) {
124
- logging.error(new errors.InternalServerError({
125
- err: err,
126
- context: tpl(messages.couldNotCleanUpFile.error),
127
- help: tpl(messages.couldNotCleanUpFile.context)
128
- }));
129
- }
127
+ try {
128
+ await fs.remove(this.fileToDelete);
129
+ } catch (err) {
130
+ logging.error(new errors.InternalServerError({
131
+ err: err,
132
+ context: tpl(messages.couldNotCleanUpFile.error),
133
+ help: tpl(messages.couldNotCleanUpFile.context)
134
+ }));
135
+ }
130
136
 
131
- self.fileToDelete = null;
132
- });
137
+ this.fileToDelete = null;
133
138
  }
134
139
 
135
140
  /**
@@ -146,28 +151,20 @@ class ImportManager {
146
151
  * Importable content must be found either in the root, or inside one base directory
147
152
  *
148
153
  * @param {String} directory
149
- * @returns {Promise}
154
+ * @returns {boolean}
150
155
  */
151
156
  isValidZip(directory) {
152
157
  // Globs match content in the root or inside a single directory
153
- const extMatchesBase = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_OR_SINGLE_DIR), {cwd: directory});
158
+ const extMatchesBase = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_OR_SINGLE_DIR), {cwd: directory, nocase: true});
154
159
 
155
160
  const extMatchesAll = glob.sync(
156
- this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory}
161
+ this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory, nocase: true}
157
162
  );
158
163
 
159
164
  const dirMatches = glob.sync(
160
165
  this.getDirectoryGlob(this.getDirectories(), ROOT_OR_SINGLE_DIR), {cwd: directory}
161
166
  );
162
167
 
163
- const oldRoonMatches = glob.sync(this.getDirectoryGlob(['drafts', 'published', 'deleted'], ROOT_OR_SINGLE_DIR),
164
- {cwd: directory});
165
-
166
- // This is a temporary extra message for the old format roon export which doesn't work with Ghost
167
- if (oldRoonMatches.length > 0) {
168
- throw new errors.UnsupportedMediaTypeError({message: tpl(messages.unsupportedRoonExport)});
169
- }
170
-
171
168
  // If this folder contains importable files or a content or images directory
172
169
  if (extMatchesBase.length > 0 || (dirMatches.length > 0 && extMatchesAll.length > 0)) {
173
170
  return true;
@@ -182,8 +179,8 @@ class ImportManager {
182
179
 
183
180
  /**
184
181
  * Use the extract module to extract the given zip file to a temp directory & return the temp directory path
185
- * @param {String} filePath
186
- * @returns {Promise[]} Files
182
+ * @param {string} filePath
183
+ * @returns {Promise<string>} full path to the extracted folder
187
184
  */
188
185
  extractZip(filePath) {
189
186
  const tmpDir = path.join(os.tmpdir(), uuid.v4());
@@ -199,11 +196,11 @@ class ImportManager {
199
196
  * are relevant to the given handler, and return them as a name and path combo
200
197
  * @param {Object} handler
201
198
  * @param {String} directory
202
- * @returns [] Files
199
+ * @returns {File[]} Files
203
200
  */
204
201
  getFilesFromZip(handler, directory) {
205
202
  const globPattern = this.getExtensionGlob(handler.extensions, ALL_DIRS);
206
- return _.map(glob.sync(globPattern, {cwd: directory}), function (file) {
203
+ return _.map(glob.sync(globPattern, {cwd: directory, nocase: true}), function (file) {
207
204
  return {name: file, path: path.join(directory, file)};
208
205
  });
209
206
  }
@@ -215,9 +212,9 @@ class ImportManager {
215
212
  */
216
213
  getBaseDirectory(directory) {
217
214
  // Globs match root level only
218
- const extMatches = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_ONLY), {cwd: directory});
215
+ const extMatches = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_ONLY), {cwd: directory, nocase: true});
219
216
 
220
- const dirMatches = glob.sync(this.getDirectoryGlob(this.getDirectories(), ROOT_ONLY), {cwd: directory});
217
+ const dirMatches = glob.sync(this.getDirectoryGlob(this.getDirectories(), ROOT_ONLY), {cwd: directory, nocase: true});
221
218
  let extMatchesAll;
222
219
 
223
220
  // There is no base directory
@@ -226,9 +223,9 @@ class ImportManager {
226
223
  }
227
224
  // There is a base directory, grab it from any ext match
228
225
  extMatchesAll = glob.sync(
229
- this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory}
226
+ this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory, nocase: true}
230
227
  );
231
- if (extMatchesAll.length < 1 || extMatchesAll[0].split('/') < 1) {
228
+ if (extMatchesAll.length < 1 || extMatchesAll[0].split('/').length < 1) {
232
229
  throw new errors.ValidationError({message: tpl(messages.invalidZipFileBaseDirectory)});
233
230
  }
234
231
 
@@ -242,48 +239,42 @@ class ImportManager {
242
239
  * The data key contains JSON representing any data that should be imported
243
240
  * The image key contains references to images that will be stored (and where they will be stored)
244
241
  * @param {File} file
245
- * @returns {Promise(ImportData)}
242
+ * @returns {Promise<ImportData>}
246
243
  */
247
- processZip(file) {
248
- const self = this;
244
+ async processZip(file) {
245
+ const zipDirectory = await this.extractZip(file.path);
249
246
 
250
- return this.extractZip(file.path).then(function (zipDirectory) {
251
- const ops = [];
252
- const importData = {};
253
- let baseDir;
247
+ /**
248
+ * @type {ImportData}
249
+ */
250
+ const importData = {};
251
+
252
+ this.isValidZip(zipDirectory);
253
+ const baseDir = this.getBaseDirectory(zipDirectory);
254
254
 
255
- self.isValidZip(zipDirectory);
256
- baseDir = self.getBaseDirectory(zipDirectory);
255
+ for (const handler of this.handlers) {
256
+ const files = this.getFilesFromZip(handler, zipDirectory);
257
257
 
258
- _.each(self.handlers, function (handler) {
258
+ if (files.length > 0) {
259
259
  if (Object.prototype.hasOwnProperty.call(importData, handler.type)) {
260
260
  // This limitation is here to reduce the complexity of the importer for now
261
- return Promise.reject(new errors.UnsupportedMediaTypeError({
261
+ throw new errors.UnsupportedMediaTypeError({
262
262
  message: tpl(messages.zipContainsMultipleDataFormats)
263
- }));
264
- }
265
-
266
- const files = self.getFilesFromZip(handler, zipDirectory);
267
-
268
- if (files.length > 0) {
269
- ops.push(function () {
270
- return handler.loadFile(files, baseDir).then(function (data) {
271
- importData[handler.type] = data;
272
- });
273
263
  });
274
264
  }
275
- });
276
265
 
277
- if (ops.length === 0) {
278
- return Promise.reject(new errors.UnsupportedMediaTypeError({
279
- message: tpl(messages.noContentToImport)
280
- }));
266
+ const data = await handler.loadFile(files, baseDir);
267
+ importData[handler.type] = data;
281
268
  }
269
+ }
282
270
 
283
- return sequence(ops).then(function () {
284
- return importData;
271
+ if (Object.keys(importData).length === 0) {
272
+ throw new errors.UnsupportedMediaTypeError({
273
+ message: tpl(messages.noContentToImport)
285
274
  });
286
- });
275
+ }
276
+
277
+ return importData;
287
278
  }
288
279
 
289
280
  /**
@@ -293,7 +284,7 @@ class ImportManager {
293
284
  * The data key contains JSON representing any data that should be imported
294
285
  * The image key contains references to images that will be stored (and where they will be stored)
295
286
  * @param {File} file
296
- * @returns {Promise(ImportData)}
287
+ * @returns {Promise<ImportData>}
297
288
  */
298
289
  processFile(file, ext) {
299
290
  const fileHandler = _.find(this.handlers, function (handler) {
@@ -313,7 +304,7 @@ class ImportManager {
313
304
  * Load the given file into usable importData in the format: {data: {}, images: []}, regardless of
314
305
  * whether the file is a single importable file like a JSON file, or a zip file containing loads of files.
315
306
  * @param {File} file
316
- * @returns {Promise}
307
+ * @returns {Promise<ImportData>}
317
308
  */
318
309
  loadFile(file) {
319
310
  const self = this;
@@ -326,17 +317,14 @@ class ImportManager {
326
317
  * Pass the prepared importData through the preProcess function of the various importers, so that the importers can
327
318
  * make any adjustments to the data based on relationships between it
328
319
  * @param {ImportData} importData
329
- * @returns {Promise(ImportData)}
320
+ * @returns {Promise<ImportData>}
330
321
  */
331
- preProcess(importData) {
332
- const ops = [];
333
- _.each(this.importers, function (importer) {
334
- ops.push(function () {
335
- return importer.preProcess(importData);
336
- });
337
- });
322
+ async preProcess(importData) {
323
+ for (const importer of this.importers) {
324
+ importData = importer.preProcess(importData);
325
+ }
338
326
 
339
- return pipeline(ops);
327
+ return Promise.resolve(importData);
340
328
  }
341
329
 
342
330
  /**
@@ -344,65 +332,116 @@ class ImportManager {
344
332
  * Each importer gets passed the data from importData which has the key matching its type - i.e. it only gets the
345
333
  * data that it should import. Each importer then handles actually importing that data into Ghost
346
334
  * @param {ImportData} importData
347
- * @param {Object} importOptions to allow override of certain import features such as locking a user
348
- * @returns {Promise<any>}
335
+ * @param {ImportOptions} [importOptions] to allow override of certain import features such as locking a user
336
+ * @returns {Promise<ImportResult[]>} importResults
349
337
  */
350
- doImport(importData, importOptions) {
338
+ async doImport(importData, importOptions) {
351
339
  importOptions = importOptions || {};
352
- const ops = [];
353
- _.each(this.importers, function (importer) {
340
+ const importResults = [];
341
+
342
+ for (const importer of this.importers) {
354
343
  if (Object.prototype.hasOwnProperty.call(importData, importer.type)) {
355
- ops.push(function () {
356
- return importer.doImport(importData[importer.type], importOptions);
357
- });
344
+ importResults.push(await importer.doImport(importData[importer.type], importOptions));
358
345
  }
359
- });
346
+ }
360
347
 
361
- return sequence(ops).then(function (importResult) {
362
- return importResult;
363
- });
348
+ return importResults;
364
349
  }
365
350
 
366
351
  /**
367
352
  * Import Step 4:
368
353
  * Report on what was imported, currently a no-op
369
- * @param {ImportData} importData
370
- * @returns {Promise<ImportData>}
354
+ * @param {ImportResult[]} importResults
355
+ * @returns {Promise<ImportResult[]>} importResults
371
356
  */
372
- generateReport(importData) {
373
- return Promise.resolve(importData);
357
+ async generateReport(importResults) {
358
+ return Promise.resolve(importResults);
374
359
  }
375
360
 
376
361
  /**
377
362
  * Import From File
378
363
  * The main method of the ImportManager, call this to kick everything off!
379
364
  * @param {File} file
380
- * @param {Object} importOptions to allow override of certain import features such as locking a user
381
- * @returns {Promise}
365
+ * @param {ImportOptions} importOptions to allow override of certain import features such as locking a user
366
+ * @returns {Promise<ImportResult[]>}
382
367
  */
383
- importFromFile(file, importOptions = {}) {
384
- const self = this;
368
+ async importFromFile(file, importOptions = {}) {
369
+ try {
370
+ // Step 1: Handle converting the file to usable data
371
+ let importData = await this.loadFile(file);
385
372
 
386
- // Step 1: Handle converting the file to usable data
387
- return this.loadFile(file).then(function (importData) {
388
373
  // Step 2: Let the importers pre-process the data
389
- return self.preProcess(importData);
390
- }).then(function (importData) {
374
+ importData = await this.preProcess(importData);
375
+
391
376
  // Step 3: Actually do the import
392
377
  // @TODO: It would be cool to have some sort of dry run flag here
393
- return self.doImport(importData, importOptions);
394
- }).then(function (importData) {
378
+ let importResult = await this.doImport(importData, importOptions);
379
+
395
380
  // Step 4: Report on the import
396
- return self.generateReport(importData);
397
- }).finally(() => self.cleanUp()); // Step 5: Cleanup any files
381
+ return await this.generateReport(importResult);
382
+ } finally {
383
+ // Step 5: Cleanup any files
384
+ this.cleanUp();
385
+ }
398
386
  }
399
387
  }
400
388
 
401
389
  /**
402
- * A number, or a string containing a number.
390
+ * @typedef {object} ImportOptions
391
+ * @property {boolean} [returnImportedData]
392
+ * @property {boolean} [importPersistUser]
393
+ */
394
+
395
+ /**
396
+ * @typedef {object} Importer
397
+ * @property {"images"|"data"} type
398
+ * @property {PreProcessMethod} preProcess
399
+ * @property {DoImportMethod} doImport
400
+ */
401
+
402
+ /**
403
+ * @callback PreProcessMethod
404
+ * @param {ImportData} importData
405
+ * @returns {ImportData}
406
+ */
407
+
408
+ /**
409
+ * @callback DoImportMethod
410
+ * @param {object|object[]} importData
411
+ * @param {ImportOptions} importOptions
412
+ * @returns {Promise<ImportResult>} import result
413
+ */
414
+
415
+ /**
416
+ * @typedef {object} Handler
417
+ * @property {"images"|"data"} type
418
+ * @property {string[]} extensions
419
+ * @property {string[]} contentTypes
420
+ * @property {string[]} directories
421
+ * @property {LoadFileMethod} loadFile
422
+ */
423
+
424
+ /**
425
+ * @callback LoadFileMethod
426
+ * @param {File[]} files
427
+ * @param {string} [baseDir]
428
+ * @returns {Promise<object[]|object>} data
429
+ */
430
+
431
+ /**
432
+ * File object
433
+ * @typedef {Object} File
434
+ * @property {string} name
435
+ * @property {string} path
436
+ */
437
+
438
+ /**
403
439
  * @typedef {Object} ImportData
404
- * @property [Object] data
405
- * @property [Array] images
440
+ * @property {Object} [data]
441
+ * @property {Array} [images]
406
442
  */
407
443
 
444
+ /**
445
+ * @typedef {Object} ImportResult
446
+ */
408
447
  module.exports = new ImportManager();
@@ -19,6 +19,9 @@ module.exports = createTransactionalMigration(
19
19
  const id = ObjectID().toHexString();
20
20
 
21
21
  logging.info(`Adding tier "${name}"`);
22
+
23
+ // `slugify(id) is used to ensure unique slug here, as existing tiers could be using any name including "Free"
24
+ // slugs are not used anywhere user-facing so the value is acceptable
22
25
  await knex('products')
23
26
  .insert({
24
27
  id: id,
@@ -0,0 +1,66 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createTransactionalMigration} = require('../../utils');
3
+
4
+ module.exports = createTransactionalMigration(
5
+ async function up(knex) {
6
+ const portalPlanSetting = await knex('settings').select('value').where('key', 'portal_plans').first();
7
+
8
+ if (!portalPlanSetting) {
9
+ logging.warn('Could not find portal_plans setting - skipping migration');
10
+ return;
11
+ }
12
+
13
+ try {
14
+ const settingData = JSON.parse(portalPlanSetting.value);
15
+
16
+ if (!settingData.includes('free')) {
17
+ logging.warn(`portal_plans does not include "free" - skipping migration`);
18
+ return;
19
+ }
20
+
21
+ logging.info(`Updating free products to visible`);
22
+ await knex('products').update('visibility', 'public').where('type', 'free');
23
+ } catch (err) {
24
+ logging.error(err);
25
+ logging.warn('portal_plans setting is invalid - skipping migration');
26
+ return;
27
+ }
28
+ },
29
+ async function down(knex) {
30
+ const freeTier = await knex('products').select('id').where('type', 'free').first();
31
+ const portalPlanSetting = await knex('settings').select('value').where('key', 'portal_plans').first();
32
+
33
+ if (!freeTier) {
34
+ logging.info('Free tier is not visible, not updating portal_plans');
35
+ return;
36
+ }
37
+
38
+ if (!portalPlanSetting) {
39
+ logging.warn('Could not find portal_plans setting - skipping migration');
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const existingSettingData = JSON.parse(portalPlanSetting.value);
45
+ let settingData;
46
+
47
+ if (freeTier.visibility === 'public') {
48
+ if (existingSettingData.includes('free')) {
49
+ logging.info('portal_plans setting already contains "free" - skipping update');
50
+ return;
51
+ } else {
52
+ settingData = JSON.stringify(existingSettingData.concat('free'));
53
+ }
54
+ } else {
55
+ settingData = JSON.stringify(existingSettingData.filter(value => value !== 'free'));
56
+ }
57
+
58
+ logging.info(`Updating portal_plans to ${settingData}`);
59
+ await knex('settings').update('value', settingData).where('key', 'portal_plans');
60
+ } catch (err) {
61
+ logging.error(err);
62
+ logging.warn('portal_plans setting is invalid - skipping migration');
63
+ return;
64
+ }
65
+ }
66
+ );
@@ -0,0 +1,36 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createTransactionalMigration} = require('../../utils');
3
+
4
+ module.exports = createTransactionalMigration(
5
+ async function up(knex) {
6
+ const portalProductSetting = await knex('settings').select('value').where('key', 'portal_products').first();
7
+
8
+ if (!portalProductSetting) {
9
+ logging.warn('Could not find portal_products setting - skipping migration');
10
+ return;
11
+ }
12
+
13
+ try {
14
+ const settingData = JSON.parse(portalProductSetting.value);
15
+
16
+ if (settingData.length === 0) {
17
+ logging.warn(`portal_product is empty, skipping migrations`);
18
+ return;
19
+ }
20
+
21
+ logging.info(`Updating ${settingData.length} products to visible, ${settingData}`);
22
+ await knex('products').update('visibility', 'public').whereIn('id', settingData);
23
+ } catch (err) {
24
+ logging.warn('portal_products setting is invalid - skipping migration');
25
+ return;
26
+ }
27
+ },
28
+ async function down(knex) {
29
+ const visibleTiers = await knex('products').select('id').where('visibility', 'public');
30
+
31
+ const settingData = JSON.stringify(visibleTiers.map(obj => obj.id));
32
+
33
+ logging.info(`Updating portal_products to ${settingData}`);
34
+ await knex('settings').update('value', settingData).where('key', 'portal_products');
35
+ }
36
+ );