ghost 4.15.0 → 4.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.eslintrc.js +7 -1
  2. package/content/themes/casper/assets/built/screen.css +1 -1
  3. package/content/themes/casper/assets/built/screen.css.map +1 -1
  4. package/content/themes/casper/assets/css/screen.css +1 -1
  5. package/content/themes/casper/default.hbs +2 -2
  6. package/content/themes/casper/package.json +1 -1
  7. package/content/themes/casper/page.hbs +28 -26
  8. package/content/themes/casper/partials/post-card.hbs +2 -2
  9. package/content/themes/casper/post.hbs +67 -65
  10. package/content/themes/casper/tag.hbs +2 -2
  11. package/core/boot.js +7 -7
  12. package/core/bridge.js +4 -3
  13. package/core/built/assets/{chunk.3.4b1d9e20e57164ac9c29.js → chunk.3.b80d3e1e6b8556aaff3c.js} +72 -71
  14. package/core/built/assets/ghost-dark-f7bf2dd8d8c702716f75bfa4ccd92df2.css +1 -0
  15. package/core/built/assets/{ghost.min-e35cfee26d942c364166f57f3dcc9e75.js → ghost.min-52a5420ffcea6bf17761b5c59cf020e2.js} +979 -908
  16. package/core/built/assets/ghost.min-741246f42f000c073999a5363434ea2c.css +1 -0
  17. package/core/built/assets/icons/discount-bubble.svg +1 -0
  18. package/core/built/assets/{vendor.min-ca33abc718f21a51327841d58f8875d0.js → vendor.min-1bfc9d56d27508db88ef417deb55f16f.js} +454 -434
  19. package/core/frontend/apps/amp/lib/helpers/amp_analytics.js +2 -2
  20. package/core/frontend/apps/amp/lib/helpers/amp_components.js +2 -1
  21. package/core/frontend/apps/amp/lib/helpers/amp_content.js +5 -1
  22. package/core/frontend/apps/amp/lib/helpers/amp_style.js +1 -1
  23. package/core/frontend/apps/amp/lib/router.js +8 -4
  24. package/core/frontend/apps/private-blogging/index.js +13 -5
  25. package/core/frontend/apps/private-blogging/lib/helpers/input_password.js +1 -1
  26. package/core/frontend/apps/private-blogging/lib/middleware.js +8 -3
  27. package/core/frontend/helpers/asset.js +10 -2
  28. package/core/frontend/helpers/author.js +5 -3
  29. package/core/frontend/helpers/authors.js +4 -3
  30. package/core/frontend/helpers/body_class.js +1 -1
  31. package/core/frontend/helpers/cancel_link.js +9 -2
  32. package/core/frontend/helpers/concat.js +1 -1
  33. package/core/frontend/helpers/content.js +1 -1
  34. package/core/frontend/helpers/date.js +1 -1
  35. package/core/frontend/helpers/encode.js +1 -1
  36. package/core/frontend/helpers/excerpt.js +2 -1
  37. package/core/frontend/helpers/facebook_url.js +2 -1
  38. package/core/frontend/helpers/foreach.js +11 -2
  39. package/core/frontend/helpers/get.js +14 -3
  40. package/core/frontend/helpers/ghost_foot.js +2 -1
  41. package/core/frontend/helpers/ghost_head.js +10 -1
  42. package/core/frontend/helpers/has.js +8 -3
  43. package/core/frontend/helpers/img_url.js +9 -3
  44. package/core/frontend/helpers/is.js +7 -2
  45. package/core/frontend/helpers/lang.js +1 -1
  46. package/core/frontend/helpers/link.js +11 -2
  47. package/core/frontend/helpers/link_class.js +11 -2
  48. package/core/frontend/helpers/match.js +12 -3
  49. package/core/frontend/helpers/navigation.js +13 -4
  50. package/core/frontend/helpers/pagination.js +15 -5
  51. package/core/frontend/helpers/plural.js +8 -2
  52. package/core/frontend/helpers/post_class.js +1 -1
  53. package/core/frontend/helpers/prev_post.js +9 -2
  54. package/core/frontend/helpers/price.js +11 -6
  55. package/core/frontend/helpers/products.js +2 -1
  56. package/core/frontend/helpers/reading_time.js +4 -2
  57. package/core/frontend/helpers/t.js +1 -1
  58. package/core/frontend/helpers/tags.js +3 -1
  59. package/core/frontend/helpers/title.js +1 -1
  60. package/core/frontend/helpers/twitter_url.js +2 -1
  61. package/core/frontend/helpers/url.js +3 -1
  62. package/core/frontend/services/proxy.js +34 -57
  63. package/core/frontend/services/rendering.js +24 -0
  64. package/core/frontend/services/routing/controllers/channel.js +6 -2
  65. package/core/frontend/services/routing/controllers/collection.js +6 -2
  66. package/core/frontend/services/routing/middlewares/page-param.js +6 -2
  67. package/core/frontend/services/theme-engine/middleware.js +23 -6
  68. package/core/frontend/services/theme-engine/preview.js +31 -8
  69. package/core/server/adapters/scheduling/post-scheduling/scheduler-intergation.js +6 -4
  70. package/core/server/adapters/storage/LocalFileStorage.js +10 -4
  71. package/core/server/api/canary/custom-theme-settings.js +22 -0
  72. package/core/server/api/canary/index.js +4 -0
  73. package/core/server/api/canary/members.js +1 -1
  74. package/core/server/api/canary/redirects.js +5 -5
  75. package/core/server/api/canary/settings.js +16 -148
  76. package/core/server/api/canary/utils/serializers/output/custom-theme-settings.js +13 -0
  77. package/core/server/api/canary/utils/serializers/output/index.js +4 -0
  78. package/core/server/api/canary/utils/validators/input/settings.js +23 -1
  79. package/core/server/api/v2/redirects.js +3 -3
  80. package/core/server/api/v2/settings.js +3 -4
  81. package/core/server/api/v3/redirects.js +5 -5
  82. package/core/server/api/v3/settings.js +16 -136
  83. package/core/server/api/v3/utils/validators/input/settings.js +23 -1
  84. package/core/server/data/db/state-manager.js +1 -1
  85. package/core/server/data/exporter/table-lists.js +3 -1
  86. package/core/server/data/importer/import-manager.js +398 -0
  87. package/core/server/data/importer/importers/data/data-importer.js +162 -0
  88. package/core/server/data/importer/importers/data/index.js +1 -162
  89. package/core/server/data/importer/index.js +1 -379
  90. package/core/server/data/migrations/versions/4.16/01-add-custom-theme-settings-table.js +9 -0
  91. package/core/server/data/migrations/versions/4.17/01-add-custom-theme-settings-permissions.js +21 -0
  92. package/core/server/data/migrations/versions/4.17/02-add-offers-table.js +19 -0
  93. package/core/server/data/migrations/versions/4.17/03-add-offers-permissions.js +35 -0
  94. package/core/server/data/schema/fixtures/fixtures.json +32 -0
  95. package/core/server/data/schema/schema.js +33 -0
  96. package/core/server/models/custom-theme-setting.js +9 -0
  97. package/core/server/models/index.js +2 -0
  98. package/core/server/services/custom-theme-settings.js +8 -0
  99. package/core/server/services/members/api.js +4 -1
  100. package/core/server/services/redirects/index.js +15 -0
  101. package/core/{frontend → server}/services/redirects/settings.js +13 -6
  102. package/core/server/services/redirects/validation.js +44 -0
  103. package/core/{frontend/services/settings → server/services/route-settings}/default-routes.yaml +0 -0
  104. package/core/server/services/route-settings/default-settings-manager.js +62 -0
  105. package/core/server/services/route-settings/index.js +32 -1
  106. package/core/server/services/route-settings/route-settings.js +38 -12
  107. package/core/server/services/route-settings/settings-loader.js +102 -0
  108. package/core/{frontend/services/settings → server/services/route-settings}/validate.js +38 -28
  109. package/core/server/services/route-settings/yaml-parser.js +53 -0
  110. package/core/server/services/settings/index.js +13 -16
  111. package/core/server/services/settings/settings-bread-service.js +188 -0
  112. package/core/server/services/settings/settings-utils.js +32 -0
  113. package/core/server/services/themes/ThemeStorage.js +5 -4
  114. package/core/server/services/themes/activation-bridge.js +14 -0
  115. package/core/server/services/themes/validate.js +5 -2
  116. package/core/server/web/admin/views/default-prod.html +4 -4
  117. package/core/server/web/admin/views/default.html +4 -4
  118. package/core/server/web/api/canary/admin/routes.js +5 -1
  119. package/core/server/web/members/app.js +3 -0
  120. package/core/server/web/oauth/app.js +7 -8
  121. package/core/server/web/shared/middlewares/custom-redirects.js +82 -59
  122. package/core/server/web/site/routes.js +2 -2
  123. package/core/shared/config/defaults.json +2 -2
  124. package/core/shared/config/overrides.json +1 -1
  125. package/core/shared/custom-theme-settings-cache.js +3 -0
  126. package/core/shared/i18n/translations/en.json +2 -13
  127. package/core/shared/labs.js +2 -2
  128. package/package.json +42 -41
  129. package/yarn.lock +916 -901
  130. package/core/built/assets/ghost-dark-faf931d90e92535e6c03ca16793cbe7b.css +0 -1
  131. package/core/built/assets/ghost.min-7aa074ad556a8455155ac88ceaca03ab.css +0 -1
  132. package/core/frontend/services/redirects/index.js +0 -9
  133. package/core/frontend/services/redirects/validation.js +0 -28
  134. package/core/frontend/services/settings/ensure-settings.js +0 -47
  135. package/core/frontend/services/settings/index.js +0 -104
  136. package/core/frontend/services/settings/loader.js +0 -89
  137. package/core/frontend/services/settings/yaml-parser.js +0 -31
@@ -1,15 +1,14 @@
1
1
  const Promise = require('bluebird');
2
2
  const _ = require('lodash');
3
- const validator = require('@tryghost/validator');
4
3
  const models = require('../../models');
5
4
  const routeSettings = require('../../services/route-settings');
6
- const frontendSettings = require('../../../frontend/services/settings');
7
5
  const i18n = require('../../../shared/i18n');
8
- const {BadRequestError, NoPermissionError, NotFoundError} = require('@tryghost/errors');
6
+ const {BadRequestError, NoPermissionError} = require('@tryghost/errors');
9
7
  const settingsService = require('../../services/settings');
10
- const settingsCache = require('../../../shared/settings-cache');
11
8
  const membersService = require('../../services/members');
12
9
 
10
+ const settingsBREADService = settingsService.getSettingsBREADServiceInstance();
11
+
13
12
  module.exports = {
14
13
  docName: 'settings',
15
14
 
@@ -17,26 +16,7 @@ module.exports = {
17
16
  options: ['type', 'group'],
18
17
  permissions: true,
19
18
  query(frame) {
20
- let settings = settingsCache.getAll();
21
-
22
- // CASE: no context passed (functional call)
23
- if (!frame.options.context) {
24
- return Promise.resolve(settings.filter((setting) => {
25
- return setting.group === 'site';
26
- }));
27
- }
28
-
29
- if (!frame.options.context.internal) {
30
- // CASE: omit core settings unless internal request
31
- settings = _.filter(settings, (setting) => {
32
- const isCore = setting.group === 'core';
33
- return !isCore;
34
- });
35
- // CASE: omit secret settings unless internal request
36
- settings = settings.map(settingsService.hideValueIfSecret);
37
- }
38
-
39
- return settings;
19
+ return settingsBREADService.browse(frame.options.context);
40
20
  }
41
21
  },
42
22
 
@@ -55,41 +35,7 @@ module.exports = {
55
35
  }
56
36
  },
57
37
  query(frame) {
58
- let setting;
59
- if (frame.options.key === 'slack') {
60
- const slackURL = settingsCache.get('slack_url', {resolve: false});
61
- const slackUsername = settingsCache.get('slack_username', {resolve: false});
62
-
63
- setting = slackURL || slackUsername;
64
- setting.key = 'slack';
65
- setting.value = [{
66
- url: slackURL && slackURL.value,
67
- username: slackUsername && slackUsername.value
68
- }];
69
- } else {
70
- setting = settingsCache.get(frame.options.key, {resolve: false});
71
- }
72
-
73
- if (!setting) {
74
- return Promise.reject(new NotFoundError({
75
- message: i18n.t('errors.api.settings.problemFindingSetting', {
76
- key: frame.options.key
77
- })
78
- }));
79
- }
80
-
81
- // @TODO: handle in settings model permissible fn
82
- if (setting.group === 'core' && !(frame.options.context && frame.options.context.internal)) {
83
- return Promise.reject(new NoPermissionError({
84
- message: i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
85
- }));
86
- }
87
-
88
- setting = settingsService.hideValueIfSecret(setting);
89
-
90
- return {
91
- [frame.options.key]: setting
92
- };
38
+ return settingsBREADService.read(frame.options.key, frame.options.context);
93
39
  }
94
40
  },
95
41
 
@@ -153,17 +99,7 @@ module.exports = {
153
99
  ],
154
100
  async query(frame) {
155
101
  const {email, type} = frame.data;
156
- if (typeof email !== 'string' || !validator.isEmail(email)) {
157
- throw new BadRequestError({
158
- message: i18n.t('errors.api.settings.invalidEmailReceived')
159
- });
160
- }
161
102
 
162
- if (!type || !['fromAddressUpdate', 'supportAddressUpdate'].includes(type)) {
163
- throw new BadRequestError({
164
- message: 'Invalid email type recieved'
165
- });
166
- }
167
103
  try {
168
104
  // Send magic link to update fromAddress
169
105
  await membersService.settings.sendEmailAddressUpdateMagicLink({
@@ -232,76 +168,20 @@ module.exports = {
232
168
  }
233
169
  },
234
170
  async query(frame) {
171
+ let stripeConnectData;
235
172
  const stripeConnectIntegrationToken = frame.data.settings.find(setting => setting.key === 'stripe_connect_integration_token');
236
173
 
237
- const settings = frame.data.settings.filter((setting) => {
238
- // The `stripe_connect_integration_token` "setting" is only used to set the `stripe_connect_*` settings.
239
- return ![
240
- 'stripe_connect_integration_token',
241
- 'stripe_connect_publishable_key',
242
- 'stripe_connect_secret_key',
243
- 'stripe_connect_livemode',
244
- 'stripe_connect_account_id',
245
- 'stripe_connect_display_name'
246
- ].includes(setting.key)
247
- // Remove obfuscated settings
248
- && !(setting.value === settingsService.obfuscatedSetting && settingsService.isSecretSetting(setting));
249
- });
250
-
251
- const getSetting = setting => settingsCache.get(setting.key, {resolve: false});
252
-
253
- const firstUnknownSetting = settings.find(setting => !getSetting(setting));
254
-
255
- if (firstUnknownSetting) {
256
- throw new NotFoundError({
257
- message: i18n.t('errors.api.settings.problemFindingSetting', {
258
- key: firstUnknownSetting.key
259
- })
260
- });
261
- }
262
-
263
- if (!(frame.options.context && frame.options.context.internal)) {
264
- const firstCoreSetting = settings.find(setting => getSetting(setting).group === 'core');
265
- if (firstCoreSetting) {
266
- throw new NoPermissionError({
267
- message: i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
268
- });
269
- }
270
- }
271
-
272
174
  if (stripeConnectIntegrationToken && stripeConnectIntegrationToken.value) {
273
175
  const getSessionProp = prop => frame.original.session[prop];
274
- try {
275
- const data = await membersService.stripeConnect.getStripeConnectTokenData(stripeConnectIntegrationToken.value, getSessionProp);
276
- settings.push({
277
- key: 'stripe_connect_publishable_key',
278
- value: data.public_key
279
- });
280
- settings.push({
281
- key: 'stripe_connect_secret_key',
282
- value: data.secret_key
283
- });
284
- settings.push({
285
- key: 'stripe_connect_livemode',
286
- value: data.livemode
287
- });
288
- settings.push({
289
- key: 'stripe_connect_display_name',
290
- value: data.display_name
291
- });
292
- settings.push({
293
- key: 'stripe_connect_account_id',
294
- value: data.account_id
295
- });
296
- } catch (err) {
297
- throw new BadRequestError({
298
- err,
299
- message: 'The Stripe Connect token could not be parsed.'
300
- });
301
- }
176
+
177
+ stripeConnectData = await settingsBREADService.getStripeConnectData(
178
+ stripeConnectIntegrationToken,
179
+ getSessionProp,
180
+ membersService.stripeConnect.getStripeConnectTokenData
181
+ );
302
182
  }
303
183
 
304
- return models.Settings.edit(settings, frame.options);
184
+ return await settingsBREADService.edit(frame.data.settings, frame.options, stripeConnectData);
305
185
  }
306
186
  },
307
187
 
@@ -313,8 +193,8 @@ module.exports = {
313
193
  method: 'edit'
314
194
  },
315
195
  async query(frame) {
316
- await routeSettings.setFromFilePath(frame.file.path);
317
- const getRoutesHash = () => frontendSettings.getCurrentHash('routes');
196
+ await routeSettings.api.setFromFilePath(frame.file.path);
197
+ const getRoutesHash = () => routeSettings.api.getCurrentHash();
318
198
  await settingsService.syncRoutesHash(getRoutesHash);
319
199
  }
320
200
  },
@@ -333,7 +213,7 @@ module.exports = {
333
213
  method: 'browse'
334
214
  },
335
215
  query() {
336
- return routeSettings.get();
216
+ return routeSettings.api.get();
337
217
  }
338
218
  }
339
219
  };
@@ -1,7 +1,13 @@
1
1
  const Promise = require('bluebird');
2
2
  const _ = require('lodash');
3
3
  const i18n = require('../../../../../../shared/i18n');
4
- const {NotFoundError, ValidationError} = require('@tryghost/errors');
4
+ const {NotFoundError, ValidationError, BadRequestError} = require('@tryghost/errors');
5
+ const validator = require('@tryghost/validator');
6
+
7
+ const messages = {
8
+ invalidEmailReceived: 'Please send a valid email',
9
+ invalidEmailTypeReceived: 'Invalid email type received'
10
+ };
5
11
 
6
12
  module.exports = {
7
13
  read(apiConfig, frame) {
@@ -62,5 +68,21 @@ module.exports = {
62
68
  if (errors.length) {
63
69
  return Promise.reject(errors[0]);
64
70
  }
71
+ },
72
+
73
+ updateMembersEmail(apiConfig, frame) {
74
+ const {email, type} = frame.data;
75
+
76
+ if (typeof email !== 'string' || !validator.isEmail(email)) {
77
+ throw new BadRequestError({
78
+ message: messages.invalidEmailReceived
79
+ });
80
+ }
81
+
82
+ if (!type || !['fromAddressUpdate', 'supportAddressUpdate'].includes(type)) {
83
+ throw new BadRequestError({
84
+ message: messages.invalidEmailTypeReceived
85
+ });
86
+ }
65
87
  }
66
88
  };
@@ -39,7 +39,7 @@ class DatabaseStateManager {
39
39
  await this.knexMigrator.isDatabaseOK();
40
40
  return state;
41
41
  } catch (error) {
42
- // CASE: database has not yet been initialised
42
+ // CASE: database has not yet been initialized
43
43
  if (error.code === 'DB_NOT_INITIALISED') {
44
44
  state = states.NEEDS_INITIALISATION;
45
45
  return state;
@@ -36,7 +36,8 @@ const BACKUP_TABLES = [
36
36
  'members_status_events',
37
37
  'members_paid_subscription_events',
38
38
  'members_subscribe_events',
39
- 'members_product_events'
39
+ 'members_product_events',
40
+ 'offers'
40
41
  ];
41
42
 
42
43
  // NOTE: exposing only tables which are going to be included in a "default" export file
@@ -50,6 +51,7 @@ const TABLES_ALLOWLIST = [
50
51
  'roles',
51
52
  'roles_users',
52
53
  'settings',
54
+ 'custom_theme_settings',
53
55
  'tags',
54
56
  'users'
55
57
  ];
@@ -0,0 +1,398 @@
1
+ const _ = require('lodash');
2
+ const Promise = require('bluebird');
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const glob = require('glob');
7
+ const uuid = require('uuid');
8
+ const {extract} = require('@tryghost/zip');
9
+ const {pipeline, sequence} = require('@tryghost/promise');
10
+ const i18n = require('../../../shared/i18n');
11
+ const logging = require('@tryghost/logging');
12
+ const errors = require('@tryghost/errors');
13
+ const ImageHandler = require('./handlers/image');
14
+ const JSONHandler = require('./handlers/json');
15
+ const MarkdownHandler = require('./handlers/markdown');
16
+ const ImageImporter = require('./importers/image');
17
+ const DataImporter = require('./importers/data');
18
+
19
+ // Glob levels
20
+ const ROOT_ONLY = 0;
21
+
22
+ const ROOT_OR_SINGLE_DIR = 1;
23
+ const ALL_DIRS = 2;
24
+ let defaults;
25
+
26
+ defaults = {
27
+ extensions: ['.zip'],
28
+ contentTypes: ['application/zip', 'application/x-zip-compressed'],
29
+ directories: []
30
+ };
31
+
32
+ class ImportManager {
33
+ constructor() {
34
+ this.importers = [ImageImporter, DataImporter];
35
+ this.handlers = [ImageHandler, JSONHandler, MarkdownHandler];
36
+
37
+ // Keep track of file to cleanup at the end
38
+ this.fileToDelete = null;
39
+ }
40
+
41
+ /**
42
+ * Get an array of all the file extensions for which we have handlers
43
+ * @returns {string[]}
44
+ */
45
+ getExtensions() {
46
+ return _.flatten(_.union(_.map(this.handlers, 'extensions'), defaults.extensions));
47
+ }
48
+
49
+ /**
50
+ * Get an array of all the mime types for which we have handlers
51
+ * @returns {string[]}
52
+ */
53
+ getContentTypes() {
54
+ return _.flatten(_.union(_.map(this.handlers, 'contentTypes'), defaults.contentTypes));
55
+ }
56
+
57
+ /**
58
+ * Get an array of directories for which we have handlers
59
+ * @returns {string[]}
60
+ */
61
+ getDirectories() {
62
+ return _.flatten(_.union(_.map(this.handlers, 'directories'), defaults.directories));
63
+ }
64
+
65
+ /**
66
+ * Convert items into a glob string
67
+ * @param {String[]} items
68
+ * @returns {String}
69
+ */
70
+ getGlobPattern(items) {
71
+ return '+(' + _.reduce(items, function (memo, ext) {
72
+ return memo !== '' ? memo + '|' + ext : ext;
73
+ }, '') + ')';
74
+ }
75
+
76
+ /**
77
+ * @param {String[]} extensions
78
+ * @param {Number} level
79
+ * @returns {String}
80
+ */
81
+ getExtensionGlob(extensions, level) {
82
+ const prefix = level === ALL_DIRS ? '**/*' :
83
+ (level === ROOT_OR_SINGLE_DIR ? '{*/*,*}' : '*');
84
+
85
+ return prefix + this.getGlobPattern(extensions);
86
+ }
87
+
88
+ /**
89
+ *
90
+ * @param {String[]} directories
91
+ * @param {Number} level
92
+ * @returns {String}
93
+ */
94
+ getDirectoryGlob(directories, level) {
95
+ const prefix = level === ALL_DIRS ? '**/' :
96
+ (level === ROOT_OR_SINGLE_DIR ? '{*/,}' : '');
97
+
98
+ return prefix + this.getGlobPattern(directories);
99
+ }
100
+
101
+ /**
102
+ * Remove files after we're done (abstracted into a function for easier testing)
103
+ * @returns {Function}
104
+ */
105
+ cleanUp() {
106
+ const self = this;
107
+
108
+ if (self.fileToDelete === null) {
109
+ return;
110
+ }
111
+
112
+ fs.remove(self.fileToDelete, function (err) {
113
+ if (err) {
114
+ logging.error(new errors.GhostError({
115
+ err: err,
116
+ context: i18n.t('errors.data.importer.index.couldNotCleanUpFile.error'),
117
+ help: i18n.t('errors.data.importer.index.couldNotCleanUpFile.context')
118
+ }));
119
+ }
120
+
121
+ self.fileToDelete = null;
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Return true if the given file is a Zip
127
+ * @returns Boolean
128
+ */
129
+ isZip(ext) {
130
+ return _.includes(defaults.extensions, ext);
131
+ }
132
+
133
+ /**
134
+ * Checks the content of a zip folder to see if it is valid.
135
+ * Importable content includes any files or directories which the handlers can process
136
+ * Importable content must be found either in the root, or inside one base directory
137
+ *
138
+ * @param {String} directory
139
+ * @returns {Promise}
140
+ */
141
+ isValidZip(directory) {
142
+ // Globs match content in the root or inside a single directory
143
+ const extMatchesBase = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_OR_SINGLE_DIR), {cwd: directory});
144
+
145
+ const extMatchesAll = glob.sync(
146
+ this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory}
147
+ );
148
+
149
+ const dirMatches = glob.sync(
150
+ this.getDirectoryGlob(this.getDirectories(), ROOT_OR_SINGLE_DIR), {cwd: directory}
151
+ );
152
+
153
+ const oldRoonMatches = glob.sync(this.getDirectoryGlob(['drafts', 'published', 'deleted'], ROOT_OR_SINGLE_DIR),
154
+ {cwd: directory});
155
+
156
+ // This is a temporary extra message for the old format roon export which doesn't work with Ghost
157
+ if (oldRoonMatches.length > 0) {
158
+ throw new errors.UnsupportedMediaTypeError({message: i18n.t('errors.data.importer.index.unsupportedRoonExport')});
159
+ }
160
+
161
+ // If this folder contains importable files or a content or images directory
162
+ if (extMatchesBase.length > 0 || (dirMatches.length > 0 && extMatchesAll.length > 0)) {
163
+ return true;
164
+ }
165
+
166
+ if (extMatchesAll.length < 1) {
167
+ throw new errors.UnsupportedMediaTypeError({message: i18n.t('errors.data.importer.index.noContentToImport')});
168
+ }
169
+
170
+ throw new errors.UnsupportedMediaTypeError({message: i18n.t('errors.data.importer.index.invalidZipStructure')});
171
+ }
172
+
173
+ /**
174
+ * Use the extract module to extract the given zip file to a temp directory & return the temp directory path
175
+ * @param {String} filePath
176
+ * @returns {Promise[]} Files
177
+ */
178
+ extractZip(filePath) {
179
+ const tmpDir = path.join(os.tmpdir(), uuid.v4());
180
+ this.fileToDelete = tmpDir;
181
+
182
+ return extract(filePath, tmpDir).then(function () {
183
+ return tmpDir;
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Use the handler extensions to get a globbing pattern, then use that to fetch all the files from the zip which
189
+ * are relevant to the given handler, and return them as a name and path combo
190
+ * @param {Object} handler
191
+ * @param {String} directory
192
+ * @returns [] Files
193
+ */
194
+ getFilesFromZip(handler, directory) {
195
+ const globPattern = this.getExtensionGlob(handler.extensions, ALL_DIRS);
196
+ return _.map(glob.sync(globPattern, {cwd: directory}), function (file) {
197
+ return {name: file, path: path.join(directory, file)};
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Get the name of the single base directory if there is one, else return an empty string
203
+ * @param {String} directory
204
+ * @returns {String}
205
+ */
206
+ getBaseDirectory(directory) {
207
+ // Globs match root level only
208
+ const extMatches = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_ONLY), {cwd: directory});
209
+
210
+ const dirMatches = glob.sync(this.getDirectoryGlob(this.getDirectories(), ROOT_ONLY), {cwd: directory});
211
+ let extMatchesAll;
212
+
213
+ // There is no base directory
214
+ if (extMatches.length > 0 || dirMatches.length > 0) {
215
+ return;
216
+ }
217
+ // There is a base directory, grab it from any ext match
218
+ extMatchesAll = glob.sync(
219
+ this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory}
220
+ );
221
+ if (extMatchesAll.length < 1 || extMatchesAll[0].split('/') < 1) {
222
+ throw new errors.ValidationError({message: i18n.t('errors.data.importer.index.invalidZipFileBaseDirectory')});
223
+ }
224
+
225
+ return extMatchesAll[0].split('/')[0];
226
+ }
227
+
228
+ /**
229
+ * Process Zip
230
+ * Takes a reference to a zip file, extracts it, sends any relevant files from inside to the right handler, and
231
+ * returns an object in the importData format: {data: {}, images: []}
232
+ * The data key contains JSON representing any data that should be imported
233
+ * The image key contains references to images that will be stored (and where they will be stored)
234
+ * @param {File} file
235
+ * @returns {Promise(ImportData)}
236
+ */
237
+ processZip(file) {
238
+ const self = this;
239
+
240
+ return this.extractZip(file.path).then(function (zipDirectory) {
241
+ const ops = [];
242
+ const importData = {};
243
+ let baseDir;
244
+
245
+ self.isValidZip(zipDirectory);
246
+ baseDir = self.getBaseDirectory(zipDirectory);
247
+
248
+ _.each(self.handlers, function (handler) {
249
+ if (Object.prototype.hasOwnProperty.call(importData, handler.type)) {
250
+ // This limitation is here to reduce the complexity of the importer for now
251
+ return Promise.reject(new errors.UnsupportedMediaTypeError({
252
+ message: i18n.t('errors.data.importer.index.zipContainsMultipleDataFormats')
253
+ }));
254
+ }
255
+
256
+ const files = self.getFilesFromZip(handler, zipDirectory);
257
+
258
+ if (files.length > 0) {
259
+ ops.push(function () {
260
+ return handler.loadFile(files, baseDir).then(function (data) {
261
+ importData[handler.type] = data;
262
+ });
263
+ });
264
+ }
265
+ });
266
+
267
+ if (ops.length === 0) {
268
+ return Promise.reject(new errors.UnsupportedMediaTypeError({
269
+ message: i18n.t('errors.data.importer.index.noContentToImport')
270
+ }));
271
+ }
272
+
273
+ return sequence(ops).then(function () {
274
+ return importData;
275
+ });
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Process File
281
+ * Takes a reference to a single file, sends it to the relevant handler to be loaded and returns an object in the
282
+ * importData format: {data: {}, images: []}
283
+ * The data key contains JSON representing any data that should be imported
284
+ * The image key contains references to images that will be stored (and where they will be stored)
285
+ * @param {File} file
286
+ * @returns {Promise(ImportData)}
287
+ */
288
+ processFile(file, ext) {
289
+ const fileHandler = _.find(this.handlers, function (handler) {
290
+ return _.includes(handler.extensions, ext);
291
+ });
292
+
293
+ return fileHandler.loadFile([_.pick(file, 'name', 'path')]).then(function (loadedData) {
294
+ // normalize the returned data
295
+ const importData = {};
296
+ importData[fileHandler.type] = loadedData;
297
+ return importData;
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Import Step 1:
303
+ * Load the given file into usable importData in the format: {data: {}, images: []}, regardless of
304
+ * whether the file is a single importable file like a JSON file, or a zip file containing loads of files.
305
+ * @param {File} file
306
+ * @returns {Promise}
307
+ */
308
+ loadFile(file) {
309
+ const self = this;
310
+ const ext = path.extname(file.name).toLowerCase();
311
+ return this.isZip(ext) ? self.processZip(file) : self.processFile(file, ext);
312
+ }
313
+
314
+ /**
315
+ * Import Step 2:
316
+ * Pass the prepared importData through the preProcess function of the various importers, so that the importers can
317
+ * make any adjustments to the data based on relationships between it
318
+ * @param {ImportData} importData
319
+ * @returns {Promise(ImportData)}
320
+ */
321
+ preProcess(importData) {
322
+ const ops = [];
323
+ _.each(this.importers, function (importer) {
324
+ ops.push(function () {
325
+ return importer.preProcess(importData);
326
+ });
327
+ });
328
+
329
+ return pipeline(ops);
330
+ }
331
+
332
+ /**
333
+ * Import Step 3:
334
+ * Each importer gets passed the data from importData which has the key matching its type - i.e. it only gets the
335
+ * data that it should import. Each importer then handles actually importing that data into Ghost
336
+ * @param {ImportData} importData
337
+ * @param {Object} importOptions to allow override of certain import features such as locking a user
338
+ * @returns {Promise<any>}
339
+ */
340
+ doImport(importData, importOptions) {
341
+ importOptions = importOptions || {};
342
+ const ops = [];
343
+ _.each(this.importers, function (importer) {
344
+ if (Object.prototype.hasOwnProperty.call(importData, importer.type)) {
345
+ ops.push(function () {
346
+ return importer.doImport(importData[importer.type], importOptions);
347
+ });
348
+ }
349
+ });
350
+
351
+ return sequence(ops).then(function (importResult) {
352
+ return importResult;
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Import Step 4:
358
+ * Report on what was imported, currently a no-op
359
+ * @param {ImportData} importData
360
+ * @returns {Promise<ImportData>}
361
+ */
362
+ generateReport(importData) {
363
+ return Promise.resolve(importData);
364
+ }
365
+
366
+ /**
367
+ * Import From File
368
+ * The main method of the ImportManager, call this to kick everything off!
369
+ * @param {File} file
370
+ * @param {Object} importOptions to allow override of certain import features such as locking a user
371
+ * @returns {Promise}
372
+ */
373
+ importFromFile(file, importOptions = {}) {
374
+ const self = this;
375
+
376
+ // Step 1: Handle converting the file to usable data
377
+ return this.loadFile(file).then(function (importData) {
378
+ // Step 2: Let the importers pre-process the data
379
+ return self.preProcess(importData);
380
+ }).then(function (importData) {
381
+ // Step 3: Actually do the import
382
+ // @TODO: It would be cool to have some sort of dry run flag here
383
+ return self.doImport(importData, importOptions);
384
+ }).then(function (importData) {
385
+ // Step 4: Report on the import
386
+ return self.generateReport(importData);
387
+ }).finally(() => self.cleanUp()); // Step 5: Cleanup any files
388
+ }
389
+ }
390
+
391
+ /**
392
+ * A number, or a string containing a number.
393
+ * @typedef {Object} ImportData
394
+ * @property [Object] data
395
+ * @property [Array] images
396
+ */
397
+
398
+ module.exports = new ImportManager();