ghost 4.21.0 → 4.22.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 (73) hide show
  1. package/Gruntfile.js +1 -0
  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 +263 -50
  5. package/content/themes/casper/default.hbs +12 -3
  6. package/content/themes/casper/index.hbs +25 -23
  7. package/content/themes/casper/package.json +91 -2
  8. package/content/themes/casper/partials/post-card.hbs +1 -1
  9. package/content/themes/casper/post.hbs +18 -14
  10. package/content/themes/casper/yarn.lock +245 -192
  11. package/core/boot.js +5 -0
  12. package/core/bridge.js +14 -0
  13. package/core/built/assets/{chunk.3.065ee3c3bdf674bd81a4.js → chunk.3.1148677ff3b78e5aeaee.js} +60 -60
  14. package/core/built/assets/{ghost-dark-1328db4a7dd128305646305a8731bcfe.css → ghost-dark-684ad238e1a858c7cb5be6988de7c6f5.css} +1 -1
  15. package/core/built/assets/{ghost.min-5abc69c04ad1d5301a857e01009b9c05.css → ghost.min-66e08535f8bb797a8c40e0a2b31f1e9e.css} +1 -1
  16. package/core/built/assets/{ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js → ghost.min-efbfb823467b66f4acc66537d033aa55.js} +1742 -1891
  17. package/core/built/assets/{vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js → vendor.min-7c8fdd90f7ecd2e94328a07ea3b64608.js} +601 -571
  18. package/core/frontend/helpers/asset.js +9 -1
  19. package/core/frontend/helpers/ghost_head.js +13 -1
  20. package/core/frontend/services/card-assets/index.js +16 -0
  21. package/core/frontend/services/card-assets/service.js +101 -0
  22. package/core/frontend/services/theme-engine/config/defaults.json +4 -1
  23. package/core/frontend/services/theme-engine/config/index.js +1 -1
  24. package/core/frontend/src/cards/css/bookmark.css +83 -0
  25. package/core/frontend/src/cards/css/gallery.css +36 -0
  26. package/core/frontend/src/cards/js/gallery.js +8 -0
  27. package/core/frontend/web/middleware/serve-public-file.js +10 -1
  28. package/core/frontend/web/site.js +10 -9
  29. package/core/server/adapters/storage/LocalImagesStorage.js +50 -0
  30. package/core/server/adapters/storage/LocalMediaStorage.js +23 -0
  31. package/core/server/adapters/storage/{LocalFileStorage.js → LocalStorageBase.js} +36 -48
  32. package/core/server/adapters/storage/index.js +1 -1
  33. package/core/server/adapters/storage/utils.js +2 -2
  34. package/core/server/api/canary/index.js +4 -0
  35. package/core/server/api/canary/media.js +22 -0
  36. package/core/server/api/canary/redirects.js +1 -6
  37. package/core/server/api/canary/utils/serializers/input/pages.js +8 -0
  38. package/core/server/api/canary/utils/serializers/output/index.js +4 -0
  39. package/core/server/api/canary/utils/serializers/output/media.js +28 -0
  40. package/core/server/api/canary/utils/validators/input/index.js +4 -0
  41. package/core/server/api/canary/utils/validators/input/media.js +7 -0
  42. package/core/server/api/v2/redirects.js +1 -6
  43. package/core/server/api/v3/members.js +5 -1
  44. package/core/server/api/v3/redirects.js +1 -6
  45. package/core/server/data/migrations/utils.js +55 -16
  46. package/core/server/data/migrations/versions/4.22/01-add-is-launch-complete-setting.js +8 -0
  47. package/core/server/data/migrations/versions/4.22/02-update-launch-complete-setting-from-user-data.js +39 -0
  48. package/core/server/data/schema/default-settings.json +8 -0
  49. package/core/server/frontend/ghost.min.css +1 -1
  50. package/core/server/lib/image/blog-icon.js +2 -4
  51. package/core/server/lib/image/image-size.js +1 -1
  52. package/core/server/services/limits.js +3 -6
  53. package/core/server/services/mega/template.js +4 -0
  54. package/core/server/services/offers/service.js +1 -31
  55. package/core/server/services/redirects/api.js +270 -0
  56. package/core/server/services/redirects/index.js +27 -12
  57. package/core/server/services/themes/ThemeStorage.js +5 -5
  58. package/core/server/web/admin/views/default-prod.html +4 -4
  59. package/core/server/web/admin/views/default.html +4 -4
  60. package/core/server/web/api/canary/admin/routes.js +13 -4
  61. package/core/server/web/api/middleware/upload.js +117 -10
  62. package/core/server/web/members/app.js +1 -1
  63. package/core/server/web/shared/middlewares/index.js +0 -4
  64. package/core/shared/config/defaults.json +3 -1
  65. package/core/shared/config/helpers.js +2 -0
  66. package/core/shared/config/overrides.json +8 -0
  67. package/core/shared/labs.js +5 -3
  68. package/package.json +14 -13
  69. package/yarn.lock +875 -851
  70. package/core/built/assets/img/themes/Editorial-a25a4a34c04dedd858bd5e05ef388b1c.jpg +0 -0
  71. package/core/built/assets/img/themes/Massively-06edf00108429f7fb8e65f190fba34fe.jpg +0 -0
  72. package/core/server/services/redirects/settings.js +0 -234
  73. package/core/server/web/shared/middlewares/custom-redirects.js +0 -128
@@ -0,0 +1,270 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const moment = require('moment-timezone');
4
+ const yaml = require('js-yaml');
5
+
6
+ const logging = require('@tryghost/logging');
7
+ const tpl = require('@tryghost/tpl');
8
+ const errors = require('@tryghost/errors');
9
+
10
+ const validation = require('./validation');
11
+
12
+ const messages = {
13
+ jsonParse: 'Could not parse JSON: {context}.',
14
+ yamlParse: 'Could not parse YAML: {context}.',
15
+ yamlPlainString: 'YAML input cannot be a plain string. Check the format of your YAML file.',
16
+ redirectsHelp: 'https://ghost.org/docs/themes/routing/#redirects',
17
+ redirectsRegister: 'Could not register custom redirects.'
18
+ };
19
+
20
+ /**
21
+ * Redirect configuration object
22
+ * @typedef {Object} RedirectConfig
23
+ * @property {String} from - Defines the relative incoming URL or pattern (regex)
24
+ * @property {String} to - Defines where the incoming traffic should be redirected to, which can be a static URL, or a dynamic value using regex (example: "to": "/$1/")
25
+ * @property {boolean} permanent - Can be defined with true for a permanent HTTP 301 redirect, or false for a temporary HTTP 302 redirect
26
+ */
27
+
28
+ /**
29
+ * @param {string} redirectsPath
30
+ * @returns {Promise<string>}
31
+ */
32
+ const readRedirectsFile = async (redirectsPath) => {
33
+ try {
34
+ return await fs.readFile(redirectsPath, 'utf-8');
35
+ } catch (err) {
36
+ if (err.code === 'ENOENT') {
37
+ return '';
38
+ }
39
+
40
+ if (errors.utils.isIgnitionError(err)) {
41
+ throw err;
42
+ }
43
+
44
+ throw new errors.NotFoundError({
45
+ err: err
46
+ });
47
+ }
48
+ };
49
+
50
+ /**
51
+ *
52
+ * @param {String} content serialized JSON or YAML configuration
53
+ * @param {String} ext one of `.json` or `.yaml` extensions
54
+ *
55
+ * @returns {RedirectConfig[]} of parsed redirect config objects
56
+ */
57
+ const parseRedirectsFile = (content, ext) => {
58
+ if (ext === '.json') {
59
+ let redirects;
60
+
61
+ try {
62
+ redirects = JSON.parse(content);
63
+ } catch (err) {
64
+ throw new errors.BadRequestError({
65
+ message: tpl(messages.jsonParse, {context: err.message})
66
+ });
67
+ }
68
+
69
+ return redirects;
70
+ }
71
+
72
+ if (ext === '.yaml') {
73
+ let redirects = [];
74
+ let configYaml;
75
+
76
+ try {
77
+ configYaml = yaml.load(content);
78
+ } catch (err) {
79
+ throw new errors.BadRequestError({
80
+ message: tpl(messages.yamlParse, {context: err.message})
81
+ });
82
+ }
83
+
84
+ // yaml.load passes almost every yaml code.
85
+ // Because of that, it's hard to detect if there's an error in the file.
86
+ // But one of the obvious errors is the plain string output.
87
+ // Here we check if the user made this mistake.
88
+ if (typeof configYaml === 'string') {
89
+ throw new errors.BadRequestError({
90
+ message: tpl(messages.yamlPlainString),
91
+ help: tpl(messages.redirectsHelp)
92
+ });
93
+ }
94
+
95
+ /**
96
+ * 302: Temporary redirects
97
+ */
98
+ for (const redirect in configYaml['302']) {
99
+ redirects.push({
100
+ from: redirect,
101
+ to: configYaml['302'][redirect],
102
+ permanent: false
103
+ });
104
+ }
105
+
106
+ /**
107
+ * 301: Permanent redirects
108
+ */
109
+ for (const redirect in configYaml['301']) {
110
+ redirects.push({
111
+ from: redirect,
112
+ to: configYaml['301'][redirect],
113
+ permanent: true
114
+ });
115
+ }
116
+
117
+ return redirects;
118
+ }
119
+
120
+ throw new errors.IncorrectUsageError();
121
+ };
122
+
123
+ /**
124
+ * @param {string} filePath
125
+ * @returns {string}
126
+ */
127
+ const getBackupRedirectsFilePath = (filePath) => {
128
+ const {dir, name, ext} = path.parse(filePath);
129
+
130
+ return path.join(dir, `${name}-${moment().format('YYYY-MM-DD-HH-mm-ss')}${ext}`);
131
+ };
132
+
133
+ /**
134
+ * @typedef {object} IRedirectManager
135
+ */
136
+
137
+ class CustomRedirectsAPI {
138
+ /**
139
+ * @param {object} config
140
+ * @param {string} config.basePath
141
+ *
142
+ * @param {IRedirectManager} redirectManager
143
+ */
144
+ constructor(config, redirectManager) {
145
+ /** @private */
146
+ this.config = config;
147
+ /** @private */
148
+ this.redirectManager = redirectManager;
149
+ }
150
+
151
+ async init() {
152
+ // NOTE: the try/catch block here is due to possible breaking change for existing misconfigured
153
+ // instances in the wild. Would be a good idea to remove it during v5 migration to enforce
154
+ // fail-fast initialization.
155
+ try {
156
+ const filePath = await this.getRedirectsFilePath();
157
+
158
+ if (filePath !== null) {
159
+ const content = await readRedirectsFile(filePath);
160
+ const ext = path.extname(filePath);
161
+ const redirects = parseRedirectsFile(content, ext);
162
+ validation.validate(redirects);
163
+
164
+ this.redirectManager.removeAllRedirects();
165
+ for (const redirect of redirects) {
166
+ this.redirectManager.addRedirect(redirect.from, redirect.to, {permanent: redirect.permanent});
167
+ }
168
+ }
169
+ } catch (err) {
170
+ if (errors.utils.isIgnitionError(err)) {
171
+ logging.error(err);
172
+ } else {
173
+ logging.error(new errors.IncorrectUsageError({
174
+ message: tpl(messages.redirectsRegister),
175
+ context: err.message,
176
+ help: tpl(messages.redirectsHelp),
177
+ err
178
+ }));
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * @private
185
+ * @param {'.yaml'|'.json'} ext
186
+ *
187
+ * @returns {string}
188
+ */
189
+ createRedirectsFilePath(ext) {
190
+ return path.join(this.config.basePath, `redirects${ext}`);
191
+ }
192
+
193
+ /**
194
+ * @returns {Promise<string>}
195
+ */
196
+ async getRedirectsFilePath() {
197
+ const yamlPath = this.createRedirectsFilePath('.yaml');
198
+ const jsonPath = this.createRedirectsFilePath('.json');
199
+
200
+ const yamlExists = await fs.pathExists(yamlPath);
201
+
202
+ if (yamlExists) {
203
+ return yamlPath;
204
+ }
205
+
206
+ const jsonExist = await fs.pathExists(jsonPath);
207
+
208
+ if (jsonExist) {
209
+ return jsonPath;
210
+ }
211
+
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * @param {string} filePath
217
+ * @param {'.yaml'|'.json'} [ext]
218
+ *
219
+ * @returns {Promise<>}
220
+ */
221
+ async setFromFilePath(filePath, ext = '.json') {
222
+ const redirectsFilePath = await this.getRedirectsFilePath();
223
+
224
+ if (redirectsFilePath) {
225
+ const backupRedirectsPath = getBackupRedirectsFilePath(redirectsFilePath);
226
+
227
+ const backupExists = await fs.pathExists(backupRedirectsPath);
228
+ if (backupExists) {
229
+ await fs.unlink(backupRedirectsPath);
230
+ }
231
+
232
+ await fs.move(redirectsFilePath, backupRedirectsPath);
233
+ }
234
+
235
+ const content = await readRedirectsFile(filePath);
236
+ const parsed = parseRedirectsFile(content, ext);
237
+ validation.validate(parsed);
238
+
239
+ if (ext === '.json') {
240
+ await fs.writeFile(this.createRedirectsFilePath('.json'), JSON.stringify(content), 'utf-8');
241
+ } else if (ext === '.yaml') {
242
+ await fs.copy(filePath, this.createRedirectsFilePath('.yaml'));
243
+ }
244
+
245
+ this.redirectManager.removeAllRedirects();
246
+ for (const redirect of parsed) {
247
+ this.redirectManager.addRedirect(redirect.from, redirect.to, {permanent: redirect.permanent});
248
+ }
249
+ }
250
+
251
+ /**
252
+ * @returns {Promise<RedirectConfig[]>}
253
+ */
254
+ async get() {
255
+ const filePath = await this.getRedirectsFilePath();
256
+ if (filePath === null) {
257
+ return [];
258
+ }
259
+
260
+ const content = await readRedirectsFile(filePath);
261
+
262
+ if (path.extname(filePath) === '.json') {
263
+ return parseRedirectsFile(content, '.json');
264
+ }
265
+
266
+ return content;
267
+ }
268
+ }
269
+
270
+ module.exports = CustomRedirectsAPI;
@@ -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
  };
@@ -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 @@
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-66e08535f8bb797a8c40e0a2b31f1e9e.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-7c8fdd90f7ecd2e94328a07ea3b64608.js"></script>
63
+ <script src="assets/ghost.min-efbfb823467b66f4acc66537d033aa55.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-66e08535f8bb797a8c40e0a2b31f1e9e.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-7c8fdd90f7ecd2e94328a07ea3b64608.js"></script>
63
+ <script src="assets/ghost.min-efbfb823467b66f4acc66537d033aa55.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,15 @@ 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
+
239
248
  // ## Invites
240
249
  router.get('/invites', mw.authAdminApi, http(api.invites.browse));
241
250
  router.get('/invites/:id', mw.authAdminApi, http(api.invites.read));
@@ -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
  },
@@ -23,7 +23,9 @@
23
23
  }
24
24
  },
25
25
  "storage": {
26
- "active": "LocalFileStorage"
26
+ "active": "LocalImagesStorage",
27
+ "media": "LocalMediaStorage",
28
+ "LocalMediaStorage": {}
27
29
  },
28
30
  "scheduling": {
29
31
  "active": "SchedulingDefault"
@@ -32,6 +32,8 @@ const getContentPath = function getContentPath(type) {
32
32
  switch (type) {
33
33
  case 'images':
34
34
  return path.join(this.get('paths:contentPath'), 'images/');
35
+ case 'media':
36
+ return path.join(this.get('paths:contentPath'), 'media/');
35
37
  case 'themes':
36
38
  return path.join(this.get('paths:contentPath'), 'themes/');
37
39
  case 'adapters':
@@ -30,6 +30,14 @@
30
30
  "extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico", ".webp"],
31
31
  "contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"]
32
32
  },
33
+ "media": {
34
+ "extensions": [".mp4",".webm", ".ogv"],
35
+ "contentTypes": ["video/mp4", "video/webm", "video/ogg"]
36
+ },
37
+ "thumbnails": {
38
+ "extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico", ".webp"],
39
+ "contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"]
40
+ },
33
41
  "icons": {
34
42
  "extensions": [".png", ".ico"],
35
43
  "contentTypes": ["image/png", "image/x-icon", "image/vnd.microsoft.icon"]
@@ -15,8 +15,7 @@ const messages = {
15
15
 
16
16
  // flags in this list always return `true`, allows quick global enable prior to full flag removal
17
17
  const GA_FEATURES = [
18
- 'customThemeSettings',
19
- 'offers'
18
+ 'customThemeSettings'
20
19
  ];
21
20
 
22
21
  // NOTE: this allowlist is meant to be used to filter out any unexpected
@@ -28,7 +27,10 @@ const BETA_FEATURES = [
28
27
 
29
28
  const ALPHA_FEATURES = [
30
29
  'oauthLogin',
31
- 'membersActivity'
30
+ 'membersActivity',
31
+ 'cardSettingsPanel',
32
+ 'mediaAPI',
33
+ 'membersAutoLogin'
32
34
  ];
33
35
 
34
36
  module.exports.GA_KEYS = [...GA_FEATURES];