ghost 4.21.0 → 4.22.3

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 (97) hide show
  1. package/.eslintrc.js +6 -0
  2. package/Gruntfile.js +2 -0
  3. package/content/themes/casper/assets/built/screen.css +1 -1
  4. package/content/themes/casper/assets/built/screen.css.map +1 -1
  5. package/content/themes/casper/assets/css/screen.css +263 -50
  6. package/content/themes/casper/default.hbs +12 -3
  7. package/content/themes/casper/index.hbs +25 -23
  8. package/content/themes/casper/package.json +91 -2
  9. package/content/themes/casper/partials/post-card.hbs +1 -1
  10. package/content/themes/casper/post.hbs +18 -14
  11. package/content/themes/casper/yarn.lock +245 -192
  12. package/core/boot.js +8 -0
  13. package/core/bridge.js +14 -0
  14. package/core/built/assets/{chunk.3.065ee3c3bdf674bd81a4.js → chunk.3.324fd0cc598c73650219.js} +59 -59
  15. package/core/built/assets/{ghost-dark-1328db4a7dd128305646305a8731bcfe.css → ghost-dark-39fb496d051565531062d7e047d1c0b1.css} +1 -1
  16. package/core/built/assets/{ghost.min-5abc69c04ad1d5301a857e01009b9c05.css → ghost.min-4207edfc1ae0a3f9f6505ca00d20b0c0.css} +1 -1
  17. package/core/built/assets/{ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js → ghost.min-7da921f6c6cac3fe10da1ba104575440.js} +1775 -1897
  18. package/core/built/assets/{vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js → vendor.min-413f887176a041e6dbf88214ca9a7481.js} +6849 -6688
  19. package/core/frontend/helpers/asset.js +9 -1
  20. package/core/frontend/helpers/ghost_head.js +13 -1
  21. package/core/frontend/services/card-assets/index.js +16 -0
  22. package/core/frontend/services/card-assets/service.js +109 -0
  23. package/core/frontend/services/theme-engine/config/defaults.json +4 -1
  24. package/core/frontend/services/theme-engine/config/index.js +1 -1
  25. package/core/frontend/src/cards/css/bookmark.css +83 -0
  26. package/core/frontend/src/cards/css/button.css +30 -0
  27. package/core/frontend/src/cards/css/callout.css +12 -0
  28. package/core/frontend/src/cards/css/gallery.css +36 -0
  29. package/core/frontend/src/cards/css/nft.css +85 -0
  30. package/core/frontend/src/cards/js/gallery.js +8 -0
  31. package/core/frontend/web/middleware/serve-public-file.js +10 -1
  32. package/core/frontend/web/routes.js +0 -1
  33. package/core/frontend/web/site.js +13 -9
  34. package/core/server/adapters/storage/LocalFilesStorage.js +17 -0
  35. package/core/server/adapters/storage/LocalImagesStorage.js +51 -0
  36. package/core/server/adapters/storage/LocalMediaStorage.js +24 -0
  37. package/core/server/adapters/storage/{LocalFileStorage.js → LocalStorageBase.js} +64 -51
  38. package/core/server/adapters/storage/index.js +1 -1
  39. package/core/server/adapters/storage/utils.js +2 -2
  40. package/core/server/api/canary/files.js +19 -0
  41. package/core/server/api/canary/index.js +8 -0
  42. package/core/server/api/canary/media.js +42 -0
  43. package/core/server/api/canary/oembed.js +3 -0
  44. package/core/server/api/canary/redirects.js +1 -6
  45. package/core/server/api/canary/utils/serializers/input/index.js +4 -0
  46. package/core/server/api/canary/utils/serializers/input/media.js +8 -0
  47. package/core/server/api/canary/utils/serializers/input/pages.js +8 -0
  48. package/core/server/api/canary/utils/serializers/output/config.js +21 -14
  49. package/core/server/api/canary/utils/serializers/output/files.js +27 -0
  50. package/core/server/api/canary/utils/serializers/output/index.js +8 -0
  51. package/core/server/api/canary/utils/serializers/output/media.js +37 -0
  52. package/core/server/api/canary/utils/validators/input/files.js +7 -0
  53. package/core/server/api/canary/utils/validators/input/index.js +8 -0
  54. package/core/server/api/canary/utils/validators/input/media.js +11 -0
  55. package/core/server/api/v2/redirects.js +1 -6
  56. package/core/server/api/v3/members.js +5 -1
  57. package/core/server/api/v3/redirects.js +1 -6
  58. package/core/server/data/migrations/utils.js +55 -16
  59. package/core/server/data/migrations/versions/4.22/01-add-is-launch-complete-setting.js +8 -0
  60. package/core/server/data/migrations/versions/4.22/02-update-launch-complete-setting-from-user-data.js +39 -0
  61. package/core/server/data/schema/default-settings.json +8 -0
  62. package/core/server/frontend/ghost.min.css +1 -1
  63. package/core/server/lib/image/blog-icon.js +2 -4
  64. package/core/server/lib/image/image-size.js +1 -1
  65. package/core/server/services/limits.js +3 -6
  66. package/core/server/services/mega/template.js +62 -1
  67. package/core/server/services/nft-oembed.js +71 -0
  68. package/core/server/services/oembed.js +145 -110
  69. package/core/server/services/offers/service.js +1 -31
  70. package/core/server/services/public-config/config.js +2 -1
  71. package/core/server/services/redirects/api.js +270 -0
  72. package/core/server/services/redirects/index.js +27 -12
  73. package/core/server/services/stripe/index.js +4 -2
  74. package/core/server/services/themes/ThemeStorage.js +5 -5
  75. package/core/server/services/url/Resource.js +1 -1
  76. package/core/server/services/url/Resources.js +28 -21
  77. package/core/server/services/url/UrlService.js +66 -8
  78. package/core/server/services/url/Urls.js +7 -2
  79. package/core/server/services/url/index.js +8 -1
  80. package/core/server/web/admin/views/default-prod.html +4 -4
  81. package/core/server/web/admin/views/default.html +4 -4
  82. package/core/server/web/api/canary/admin/routes.js +28 -4
  83. package/core/server/web/api/middleware/cors.js +7 -7
  84. package/core/server/web/api/middleware/upload.js +117 -10
  85. package/core/server/web/members/app.js +1 -1
  86. package/core/server/web/shared/middlewares/index.js +0 -4
  87. package/core/shared/config/defaults.json +5 -1
  88. package/core/shared/config/helpers.js +4 -0
  89. package/core/shared/config/overrides.json +8 -0
  90. package/core/shared/labs.js +12 -3
  91. package/package.json +28 -27
  92. package/urls.json +597 -0
  93. package/yarn.lock +972 -941
  94. package/core/built/assets/img/themes/Editorial-a25a4a34c04dedd858bd5e05ef388b1c.jpg +0 -0
  95. package/core/built/assets/img/themes/Massively-06edf00108429f7fb8e65f190fba34fe.jpg +0 -0
  96. package/core/server/services/redirects/settings.js +0 -234
  97. package/core/server/web/shared/middlewares/custom-redirects.js +0 -128
@@ -1,15 +1,30 @@
1
- const settings = require('./settings');
2
- const validation = require('./validation');
1
+ const config = require('../../../shared/config');
2
+ const urlUtils = require('../../../shared/url-utils');
3
3
 
4
- module.exports = {
5
- loadRedirectsFile: settings.loadRedirectsFile,
6
- validate: validation.validate,
7
- /**
8
- * Methods used in the API
9
- */
10
- api: {
11
- getRedirectsFilePath: settings.getRedirectsFilePath,
12
- get: settings.get,
13
- setFromFilePath: settings.setFromFilePath
4
+ const DynamicRedirectManager = require('@tryghost/express-dynamic-redirects');
5
+ const CustomRedirectsAPI = require('./api');
6
+
7
+ const redirectManager = new DynamicRedirectManager({
8
+ permanentMaxAge: config.get('caching:customRedirects:maxAge'),
9
+ getSubdirectoryURL: (pathname) => {
10
+ return urlUtils.urlJoin(urlUtils.getSubdir(), pathname);
14
11
  }
12
+ });
13
+
14
+ let customRedirectsAPI;
15
+
16
+ module.exports = {
17
+ init() {
18
+ customRedirectsAPI = new CustomRedirectsAPI({
19
+ basePath: config.getContentPath('data')
20
+ }, redirectManager);
21
+
22
+ return customRedirectsAPI.init();
23
+ },
24
+
25
+ get api() {
26
+ return customRedirectsAPI;
27
+ },
28
+
29
+ middleware: redirectManager.handleRequest
15
30
  };
@@ -27,7 +27,10 @@ function configureApi() {
27
27
  }
28
28
  }
29
29
 
30
- const debouncedConfigureApi = _.debounce(configureApi, 600);
30
+ const debouncedConfigureApi = _.debounce(() => {
31
+ configureApi();
32
+ events.emit('services.stripe.reconfigured');
33
+ }, 600);
31
34
 
32
35
  module.exports = {
33
36
  async init() {
@@ -37,7 +40,6 @@ module.exports = {
37
40
  return;
38
41
  }
39
42
  debouncedConfigureApi();
40
- events.emit('services.stripe.reconfigured');
41
43
  });
42
44
  },
43
45
 
@@ -4,16 +4,16 @@ const path = require('path');
4
4
  const config = require('../../../shared/config');
5
5
  const security = require('@tryghost/security');
6
6
  const {compress} = require('@tryghost/zip');
7
- const LocalFileStorage = require('../../adapters/storage/LocalFileStorage');
7
+ const LocalStorageBase = require('../../adapters/storage/LocalStorageBase');
8
8
 
9
9
  /**
10
10
  * @TODO: combine with loader.js?
11
11
  */
12
- class ThemeStorage extends LocalFileStorage {
12
+ class ThemeStorage extends LocalStorageBase {
13
13
  constructor() {
14
- super();
15
-
16
- this.storagePath = config.getContentPath('themes');
14
+ super({
15
+ storagePath: config.getContentPath('themes')
16
+ });
17
17
  }
18
18
 
19
19
  getTargetDir() {
@@ -8,7 +8,7 @@ const errors = require('@tryghost/errors');
8
8
  class Resource extends EventEmitter {
9
9
  /**
10
10
  * @param {('posts'|'pages'|'tags'|'authors')} type - of the resource
11
- * @param {Object} obj - object data to sotre
11
+ * @param {Object} obj - object data to store
12
12
  */
13
13
  constructor(type, obj) {
14
14
  super();
@@ -43,38 +43,57 @@ class Resources {
43
43
  }
44
44
 
45
45
  /**
46
- * @description Initialise the resource config. We currently fetch the data straight via the the model layer,
46
+ * @description Initialize the resource config. We currently fetch the data straight via the the model layer,
47
47
  * but because Ghost supports multiple API versions, we have to ensure we load the correct data.
48
48
  *
49
49
  * @TODO: https://github.com/TryGhost/Ghost/issues/10360
50
- * @private
51
50
  */
52
- _initResourceConfig() {
51
+ initResourceConfig() {
53
52
  if (!_.isEmpty(this.resourcesConfig)) {
54
53
  return;
55
54
  }
56
55
 
57
56
  const bridge = require('../../../bridge');
58
- this.resourcesAPIVersion = bridge.getFrontendApiVersion();
59
- this.resourcesConfig = require(`./configs/${this.resourcesAPIVersion}`);
57
+ const resourcesAPIVersion = bridge.getFrontendApiVersion();
58
+ this.resourcesConfig = require(`./configs/${resourcesAPIVersion}`);
60
59
  }
61
60
 
62
61
  /**
63
- * @description Helper function to initialise data fetching. Each resource type needs to register resource/model
64
- * events to get notified about updates/deletions/inserts.
62
+ * @description Helper function to initialize data fetching.
65
63
  */
66
64
  fetchResources() {
67
65
  const ops = [];
68
66
  debug('fetchResources');
69
67
 
70
- this._initResourceConfig();
71
-
72
68
  // NOTE: Iterate over all resource types (posts, users etc..) and call `_fetch`.
73
69
  _.each(this.resourcesConfig, (resourceConfig) => {
74
70
  this.data[resourceConfig.type] = [];
75
71
 
76
72
  // NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow.
77
73
  ops.push(this._fetch(resourceConfig));
74
+ });
75
+
76
+ return Promise.all(ops);
77
+ }
78
+
79
+ /**
80
+ * @description Each resource type needs to register resource/model events to get notified
81
+ * about updates/deletions/inserts.
82
+ *
83
+ * For example for a "tag" resource type with following configuration:
84
+ * events: {
85
+ * add: 'tag.added',
86
+ * update: ['tag.edited', 'tag.attached', 'tag.detached'],
87
+ * remove: 'tag.deleted'
88
+ * }
89
+ * there would be:
90
+ * 1 event listener connected to "_onResourceAdded" handler and it's 'tag.added' event
91
+ * 3 event listeners connected to "_onResourceUpdated" handler and it's 'tag.edited', 'tag.attached', 'tag.detached' events
92
+ * 1 event listener connected to "_onResourceRemoved" handler and it's 'tag.deleted' event
93
+ */
94
+ initEvenListeners() {
95
+ _.each(this.resourcesConfig, (resourceConfig) => {
96
+ this.data[resourceConfig.type] = [];
78
97
 
79
98
  this._listenOn(resourceConfig.events.add, (model) => {
80
99
  return this._onResourceAdded.bind(this)(resourceConfig.type, model);
@@ -96,16 +115,6 @@ class Resources {
96
115
  return this._onResourceRemoved.bind(this)(resourceConfig.type, model);
97
116
  });
98
117
  });
99
-
100
- Promise.all(ops)
101
- .then(() => {
102
- // CASE: all resources are fetched, start the queue
103
- this.queue.start({
104
- event: 'init',
105
- tolerance: 100,
106
- requiredSubscriberCount: 1
107
- });
108
- });
109
118
  }
110
119
 
111
120
  /**
@@ -430,8 +439,6 @@ class Resources {
430
439
  * @description Reset this class instance.
431
440
  *
432
441
  * Is triggered if you switch API versions.
433
- *
434
- * @param {Object} options
435
442
  */
436
443
  reset() {
437
444
  _.each(this.listeners, (obj) => {
@@ -1,7 +1,9 @@
1
+ const fs = require('fs-extra');
1
2
  const _debug = require('@tryghost/debug')._base;
2
3
  const debug = _debug('ghost:services:url:service');
3
4
  const _ = require('lodash');
4
5
  const errors = require('@tryghost/errors');
6
+ const labs = require('../../../shared/labs');
5
7
  const UrlGenerator = require('./UrlGenerator');
6
8
  const Queue = require('./Queue');
7
9
  const Urls = require('./Urls');
@@ -17,12 +19,18 @@ const events = require('../../lib/common/events');
17
19
  * It will tell you if the url generation is in progress or not.
18
20
  */
19
21
  class UrlService {
20
- constructor() {
22
+ /**
23
+ *
24
+ * @param {Object} options
25
+ * @param {String} [options.urlCachePath] - path to store cached URLs at
26
+ */
27
+ constructor({urlCachePath} = {}) {
21
28
  this.utils = urlUtils;
22
-
29
+ this.urlCachePath = urlCachePath;
23
30
  this.finished = false;
24
31
  this.urlGenerators = [];
25
32
 
33
+ // Get urls
26
34
  this.urls = new Urls();
27
35
  this.queue = new Queue();
28
36
  this.resources = new Resources(this.queue);
@@ -281,13 +289,63 @@ class UrlService {
281
289
  }
282
290
 
283
291
  /**
284
- * @description Internal helper to re-trigger fetching resources on theme change.
285
- *
286
- * @TODO: Either remove this helper or rename to `_init`, because it's a little confusing,
287
- * because this service get's initalised via events.
292
+ * @description Initializes components needed for the URL Service to function
288
293
  */
289
- init() {
290
- this.resources.fetchResources();
294
+ async init() {
295
+ this.resources.initResourceConfig();
296
+ this.resources.initEvenListeners();
297
+
298
+ const persistedUrls = await this.fetchUrls();
299
+ if (persistedUrls) {
300
+ this.urls = new Urls({
301
+ urls: persistedUrls
302
+ });
303
+ this.finished = true;
304
+ } else {
305
+ await this.resources.fetchResources();
306
+ }
307
+
308
+ // CASE: all resources are fetched, start the queue
309
+ this.queue.start({
310
+ event: 'init',
311
+ tolerance: 100,
312
+ requiredSubscriberCount: 1
313
+ });
314
+ }
315
+
316
+ async persistUrls() {
317
+ if (!labs.isSet('urlCache') || !this.urlCachePath) {
318
+ return null;
319
+ }
320
+
321
+ return fs.writeFile(this.urlCachePath, JSON.stringify(this.urls.urls, null, 4));
322
+ }
323
+
324
+ async fetchUrls() {
325
+ if (!labs.isSet('urlCache') || !this.urlCachePath) {
326
+ return null;
327
+ }
328
+
329
+ let urlsCacheExists = false;
330
+ let urls;
331
+
332
+ try {
333
+ await fs.stat(this.urlCachePath);
334
+ urlsCacheExists = true;
335
+ } catch (e) {
336
+ urlsCacheExists = false;
337
+ }
338
+
339
+ if (urlsCacheExists) {
340
+ try {
341
+ const urlsFile = await fs.readFile(this.urlCachePath, 'utf8');
342
+ urls = JSON.parse(urlsFile);
343
+ } catch (e) {
344
+ //noop as we'd start a long boot process if there are any errors in the file
345
+ }
346
+ }
347
+
348
+ return urls;
291
349
  }
292
350
 
293
351
  /**
@@ -20,8 +20,13 @@ const events = require('../../lib/common/events');
20
20
  * You can easily ask `this.urls[resourceId]`.
21
21
  */
22
22
  class Urls {
23
- constructor() {
24
- this.urls = {};
23
+ /**
24
+ *
25
+ * @param {Object} [options]
26
+ * @param {Object} [options.urls] map of available URLs with their resources
27
+ */
28
+ constructor({urls = {}} = {}) {
29
+ this.urls = urls;
25
30
  }
26
31
 
27
32
  /**
@@ -1,5 +1,12 @@
1
+ const path = require('path');
2
+ const config = require('../../../shared/config');
1
3
  const UrlService = require('./UrlService');
2
- const urlService = new UrlService();
4
+
5
+ // NOTE: instead of a path we could give UrlService a "data-resolver" of some sort
6
+ // so it doesn't have to contain the logic to read data at all. This would be
7
+ // a possible improvement in the future
8
+ const urlCachePath = path.join(config.getContentPath('data'), 'urls.json');
9
+ const urlService = new UrlService({urlCachePath});
3
10
 
4
11
  // Singleton
5
12
  module.exports = urlService;
@@ -8,7 +8,7 @@
8
8
  <title>Ghost Admin</title>
9
9
 
10
10
 
11
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.21%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
11
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.22%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
12
12
 
13
13
  <meta name="HandheldFriendly" content="True" />
14
14
  <meta name="MobileOptimized" content="320" />
@@ -41,7 +41,7 @@
41
41
 
42
42
 
43
43
  <link rel="stylesheet" href="assets/vendor.min-987af30228885bce50f05c4723fe6f53.css">
44
- <link rel="stylesheet" href="assets/ghost.min-5abc69c04ad1d5301a857e01009b9c05.css" title="light">
44
+ <link rel="stylesheet" href="assets/ghost.min-4207edfc1ae0a3f9f6505ca00d20b0c0.css" title="light">
45
45
 
46
46
 
47
47
 
@@ -59,8 +59,8 @@
59
59
  <div id="ember-basic-dropdown-wormhole"></div>
60
60
 
61
61
 
62
- <script src="assets/vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js"></script>
63
- <script src="assets/ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js"></script>
62
+ <script src="assets/vendor.min-413f887176a041e6dbf88214ca9a7481.js"></script>
63
+ <script src="assets/ghost.min-7da921f6c6cac3fe10da1ba104575440.js"></script>
64
64
 
65
65
  </body>
66
66
  </html>
@@ -8,7 +8,7 @@
8
8
  <title>Ghost Admin</title>
9
9
 
10
10
 
11
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.21%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
11
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.22%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
12
12
 
13
13
  <meta name="HandheldFriendly" content="True" />
14
14
  <meta name="MobileOptimized" content="320" />
@@ -41,7 +41,7 @@
41
41
 
42
42
 
43
43
  <link rel="stylesheet" href="assets/vendor.min-987af30228885bce50f05c4723fe6f53.css">
44
- <link rel="stylesheet" href="assets/ghost.min-5abc69c04ad1d5301a857e01009b9c05.css" title="light">
44
+ <link rel="stylesheet" href="assets/ghost.min-4207edfc1ae0a3f9f6505ca00d20b0c0.css" title="light">
45
45
 
46
46
 
47
47
 
@@ -59,8 +59,8 @@
59
59
  <div id="ember-basic-dropdown-wormhole"></div>
60
60
 
61
61
 
62
- <script src="assets/vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js"></script>
63
- <script src="assets/ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js"></script>
62
+ <script src="assets/vendor.min-413f887176a041e6dbf88214ca9a7481.js"></script>
63
+ <script src="assets/ghost.min-7da921f6c6cac3fe10da1ba104575440.js"></script>
64
64
 
65
65
  </body>
66
66
  </html>
@@ -100,10 +100,10 @@ module.exports = function apiRoutes() {
100
100
  router.del('/members', mw.authAdminApi, http(api.members.bulkDestroy));
101
101
  router.put('/members/bulk', mw.authAdminApi, http(api.members.bulkEdit));
102
102
 
103
- router.get('/offers', labs.enabledMiddleware('offers'), mw.authAdminApi, http(api.offers.browse));
104
- router.post('/offers', labs.enabledMiddleware('offers'), mw.authAdminApi, http(api.offers.add));
105
- router.get('/offers/:id', labs.enabledMiddleware('offers'), mw.authAdminApi, http(api.offers.read));
106
- router.put('/offers/:id', labs.enabledMiddleware('offers'), mw.authAdminApi, http(api.offers.edit));
103
+ router.get('/offers', mw.authAdminApi, http(api.offers.browse));
104
+ router.post('/offers', mw.authAdminApi, http(api.offers.add));
105
+ router.get('/offers/:id', mw.authAdminApi, http(api.offers.read));
106
+ router.put('/offers/:id', mw.authAdminApi, http(api.offers.edit));
107
107
 
108
108
  router.get('/members/stats/count', mw.authAdminApi, http(api.members.memberStats));
109
109
  router.get('/members/stats/mrr', mw.authAdminApi, http(api.members.mrrStats));
@@ -236,6 +236,30 @@ module.exports = function apiRoutes() {
236
236
  http(api.images.upload)
237
237
  );
238
238
 
239
+ // ## media
240
+ router.post('/media/upload',
241
+ labs.enabledMiddleware('mediaAPI'),
242
+ mw.authAdminApi,
243
+ apiMw.upload.media('file', 'thumbnail'),
244
+ apiMw.upload.mediaValidation({type: 'media'}),
245
+ http(api.media.upload)
246
+ );
247
+ router.put('/media/thumbnail/upload',
248
+ labs.enabledMiddleware('mediaAPI'),
249
+ mw.authAdminApi,
250
+ apiMw.upload.single('file'),
251
+ apiMw.upload.validation({type: 'images'}),
252
+ http(api.media.uploadThumbnail)
253
+ );
254
+
255
+ // ## files
256
+ router.post('/files/upload',
257
+ labs.enabledMiddleware('filesAPI'),
258
+ mw.authAdminApi,
259
+ apiMw.upload.single('file'),
260
+ http(api.files.upload)
261
+ );
262
+
239
263
  // ## Invites
240
264
  router.get('/invites', mw.authAdminApi, http(api.invites.browse));
241
265
  router.get('/invites/:id', mw.authAdminApi, http(api.invites.read));
@@ -3,7 +3,7 @@ const url = require('url');
3
3
  const os = require('os');
4
4
  const urlUtils = require('../../../../shared/url-utils');
5
5
 
6
- let whitelist = [];
6
+ let allowlist = [];
7
7
  const ENABLE_CORS = {origin: true, maxAge: 86400};
8
8
  const DISABLE_CORS = {origin: false};
9
9
 
@@ -46,16 +46,16 @@ function getUrls() {
46
46
  return urls;
47
47
  }
48
48
 
49
- function getWhitelist() {
49
+ function getAllowlist() {
50
50
  // This needs doing just one time after init
51
- if (whitelist.length === 0) {
51
+ if (allowlist.length === 0) {
52
52
  // origins that always match: localhost, local IPs, etc.
53
- whitelist = whitelist.concat(getIPs());
53
+ allowlist = allowlist.concat(getIPs());
54
54
  // Trusted urls from config.js
55
- whitelist = whitelist.concat(getUrls());
55
+ allowlist = allowlist.concat(getUrls());
56
56
  }
57
57
 
58
- return whitelist;
58
+ return allowlist;
59
59
  }
60
60
 
61
61
  /**
@@ -73,7 +73,7 @@ function handleCORS(req, cb) {
73
73
  }
74
74
 
75
75
  // Origin matches whitelist
76
- if (getWhitelist().indexOf(url.parse(origin).hostname) > -1) {
76
+ if (getAllowlist().indexOf(url.parse(origin).hostname) > -1) {
77
77
  return cb(null, ENABLE_CORS);
78
78
  }
79
79
 
@@ -31,23 +31,30 @@ const messages = {
31
31
  icons: {
32
32
  missingFile: 'Please select an icon.',
33
33
  invalidFile: 'Icon must be a square .ico or .png file between 60px – 1,000px, under 100kb.'
34
+ },
35
+ media: {
36
+ missingFile: 'Please select a media file.',
37
+ invalidFile: 'Please select a valid media file.'
38
+ },
39
+ thumbnail: {
40
+ missingFile: 'Please select a thumbnail.',
41
+ invalidFile: 'Please select a valid thumbnail.'
34
42
  }
35
43
  };
36
44
 
37
- const upload = {
38
- enabledClear: config.get('uploadClear') || true,
39
- multer: multer({dest: os.tmpdir()})
40
- };
45
+ const enabledClear = config.get('uploadClear') || true;
46
+ const upload = multer({dest: os.tmpdir()});
41
47
 
42
48
  const deleteSingleFile = file => fs.unlink(file.path).catch(err => logging.error(err));
43
49
 
44
50
  const single = name => (req, res, next) => {
45
- const singleUpload = upload.multer.single(name);
51
+ const singleUpload = upload.single(name);
52
+
46
53
  singleUpload(req, res, (err) => {
47
54
  if (err) {
48
55
  return next(err);
49
56
  }
50
- if (upload.enabledClear) {
57
+ if (enabledClear) {
51
58
  const deleteFiles = () => {
52
59
  res.removeListener('finish', deleteFiles);
53
60
  res.removeListener('close', deleteFiles);
@@ -70,6 +77,43 @@ const single = name => (req, res, next) => {
70
77
  });
71
78
  };
72
79
 
80
+ const media = (fileName, thumbName) => (req, res, next) => {
81
+ const mediaUpload = upload.fields([{
82
+ name: fileName,
83
+ maxCount: 1
84
+ }, {
85
+ name: thumbName,
86
+ maxCount: 1
87
+ }]);
88
+
89
+ mediaUpload(req, res, (err) => {
90
+ if (err) {
91
+ return next(err);
92
+ }
93
+
94
+ if (enabledClear) {
95
+ const deleteFiles = () => {
96
+ res.removeListener('finish', deleteFiles);
97
+ res.removeListener('close', deleteFiles);
98
+ if (!req.disableUploadClear) {
99
+ if (req.files.file) {
100
+ return req.files.file.forEach(deleteSingleFile);
101
+ }
102
+ if (req.files.thumbnail) {
103
+ return req.files.thumbnail.forEach(deleteSingleFile);
104
+ }
105
+ }
106
+ };
107
+ if (!req.disableUploadClear) {
108
+ res.on('finish', deleteFiles);
109
+ res.on('close', deleteFiles);
110
+ }
111
+ }
112
+
113
+ next();
114
+ });
115
+ };
116
+
73
117
  const checkFileExists = (fileData) => {
74
118
  return !!(fileData.mimetype && fileData.path);
75
119
  };
@@ -84,9 +128,13 @@ const checkFileIsValid = (fileData, types, extensions) => {
84
128
  return false;
85
129
  };
86
130
 
87
- const validation = function (options) {
88
- const type = options.type;
89
-
131
+ /**
132
+ *
133
+ * @param {Object} options
134
+ * @param {String} options.type - type of the file
135
+ * @returns {Function}
136
+ */
137
+ const validation = function ({type}) {
90
138
  // if we finish the data/importer logic, we forward the request to the specified importer
91
139
  return function uploadValidation(req, res, next) {
92
140
  const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
@@ -116,9 +164,68 @@ const validation = function (options) {
116
164
  };
117
165
  };
118
166
 
167
+ /**
168
+ *
169
+ * @param {Object} options
170
+ * @param {String} options.type - type of the file
171
+ * @returns {Function}
172
+ */
173
+ const mediaValidation = function ({type}) {
174
+ return function mediaUploadValidation(req, res, next) {
175
+ const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
176
+ const contentTypes = (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || [];
177
+
178
+ const thumbnailExtensions = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.extensions) || [];
179
+ const thumbnailContentTypes = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.contentTypes) || [];
180
+
181
+ const {file: [file] = []} = req.files;
182
+ if (!file || !checkFileExists(file)) {
183
+ return next(new errors.ValidationError({
184
+ message: tpl(messages[type].missingFile)
185
+ }));
186
+ }
187
+
188
+ req.file = file;
189
+ req.file.name = req.file.originalname;
190
+ req.file.type = req.file.mimetype;
191
+ req.file.ext = path.extname(req.file.name).toLowerCase();
192
+
193
+ if (!checkFileIsValid(req.file, contentTypes, extensions)) {
194
+ return next(new errors.UnsupportedMediaTypeError({
195
+ message: tpl(messages[type].invalidFile, {extensions: extensions})
196
+ }));
197
+ }
198
+
199
+ const {thumbnail: [thumbnailFile] = []} = req.files;
200
+
201
+ if (thumbnailFile) {
202
+ if (!checkFileExists(thumbnailFile)) {
203
+ return next(new errors.ValidationError({
204
+ message: tpl(messages.thumbnail.missingFile)
205
+ }));
206
+ }
207
+
208
+ req.thumbnail = thumbnailFile;
209
+ req.thumbnail.ext = path.extname(thumbnailFile.originalname).toLowerCase();
210
+ req.thumbnail.name = `${path.basename(req.file.name, path.extname(req.file.name))}_thumb${req.thumbnail.ext}`;
211
+ req.thumbnail.type = req.thumbnail.mimetype;
212
+
213
+ if (!checkFileIsValid(req.thumbnail, thumbnailContentTypes, thumbnailExtensions)) {
214
+ return next(new errors.UnsupportedMediaTypeError({
215
+ message: tpl(messages.thumbnail.invalidFile, {extensions: thumbnailExtensions})
216
+ }));
217
+ }
218
+ }
219
+
220
+ next();
221
+ };
222
+ };
223
+
119
224
  module.exports = {
120
225
  single,
121
- validation
226
+ media,
227
+ validation,
228
+ mediaValidation
122
229
  };
123
230
 
124
231
  // Exports for testing only
@@ -37,7 +37,7 @@ module.exports = function setupMembersApp() {
37
37
  membersApp.put('/api/member', bodyParser.json({limit: '1mb'}), middleware.updateMemberData);
38
38
  membersApp.post('/api/member/email', bodyParser.json({limit: '1mb'}), (req, res) => membersService.api.middleware.updateEmailAddress(req, res));
39
39
  membersApp.get('/api/session', middleware.getIdentityToken);
40
- membersApp.get('/api/offers/:id', labs.enabledMiddleware('offers'), middleware.getOfferData);
40
+ membersApp.get('/api/offers/:id', middleware.getOfferData);
41
41
  membersApp.delete('/api/session', middleware.deleteSession);
42
42
  membersApp.get('/api/site', middleware.getMemberSiteData);
43
43
 
@@ -11,10 +11,6 @@ module.exports = {
11
11
  return require('./cache-control');
12
12
  },
13
13
 
14
- get customRedirects() {
15
- return require('./custom-redirects');
16
- },
17
-
18
14
  get errorHandler() {
19
15
  return require('./error-handler');
20
16
  },