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,4 +1,3 @@
1
- const Promise = require('bluebird');
2
1
  const errors = require('@tryghost/errors');
3
2
  const tpl = require('@tryghost/tpl');
4
3
  const {extract, hasProvider} = require('oembed-parser');
@@ -11,6 +10,10 @@ const messages = {
11
10
  insufficientMetadata: 'URL contains insufficient metadata.'
12
11
  };
13
12
 
13
+ /**
14
+ * @param {string} url
15
+ * @returns {{url: string, provider: boolean}}
16
+ */
14
17
  const findUrlWithProvider = (url) => {
15
18
  let provider;
16
19
 
@@ -44,6 +47,12 @@ const findUrlWithProvider = (url) => {
44
47
  * @typedef {(url: string, config: Object) => Promise} IExternalRequest
45
48
  */
46
49
 
50
+ /**
51
+ * @typedef {object} ICustomProvider
52
+ * @prop {(url: URL) => Promise<boolean>} canSupportRequest
53
+ * @prop {(url: URL, externalRequest: IExternalRequest) => Promise<import('oembed-parser').OembedData>} getOEmbedData
54
+ */
55
+
47
56
  class OEmbed {
48
57
  /**
49
58
  *
@@ -53,29 +62,62 @@ class OEmbed {
53
62
  */
54
63
  constructor({config, externalRequest}) {
55
64
  this.config = config;
56
- this.externalRequest = externalRequest;
65
+ /** @type {IExternalRequest} */
66
+ this.externalRequest = async (url, requestConfig) => {
67
+ if (this.isIpOrLocalhost(url)) {
68
+ return this.unknownProvider(url);
69
+ }
70
+ const response = await externalRequest(url, requestConfig);
71
+ if (this.isIpOrLocalhost(response.url)) {
72
+ return this.unknownProvider(url);
73
+ }
74
+ return response;
75
+ };
76
+ /** @type {ICustomProvider[]} */
77
+ this.customProviders = [];
78
+ }
79
+
80
+ /**
81
+ * @param {ICustomProvider} provider
82
+ */
83
+ registerProvider(provider) {
84
+ this.customProviders.push(provider);
57
85
  }
58
86
 
59
- unknownProvider(url) {
60
- return Promise.reject(new errors.ValidationError({
87
+ /**
88
+ * @param {string} url
89
+ */
90
+ async unknownProvider(url) {
91
+ throw new errors.ValidationError({
61
92
  message: tpl(messages.unknownProvider),
62
93
  context: url
63
- }));
94
+ });
64
95
  }
65
96
 
66
- knownProvider(url) {
67
- return extract(url).catch((err) => {
68
- return Promise.reject(new errors.InternalServerError({
97
+ /**
98
+ * @param {string} url
99
+ */
100
+ async knownProvider(url) {
101
+ try {
102
+ return await extract(url);
103
+ } catch (err) {
104
+ throw new errors.InternalServerError({
69
105
  message: err.message
70
- }));
71
- });
106
+ });
107
+ }
72
108
  }
73
109
 
110
+ /**
111
+ * @param {string} url
112
+ */
74
113
  errorHandler(url) {
75
- return (err) => {
114
+ /**
115
+ * @param {Error|errors.GhostError} err
116
+ */
117
+ return async (err) => {
76
118
  // allow specific validation errors through for better error messages
77
119
  if (errors.utils.isIgnitionError(err) && err.errorType === 'ValidationError') {
78
- return Promise.reject(err);
120
+ throw err;
79
121
  }
80
122
 
81
123
  // default to unknown provider to avoid leaking any app specifics
@@ -97,19 +139,11 @@ class OEmbed {
97
139
 
98
140
  let scraperResponse;
99
141
 
100
- try {
101
- const cookieJar = new CookieJar();
102
- const response = await this.externalRequest(url, {cookieJar});
142
+ const cookieJar = new CookieJar();
143
+ const response = await this.externalRequest(url, {cookieJar});
103
144
 
104
- if (this.isIpOrLocalhost(response.url)) {
105
- scraperResponse = {};
106
- } else {
107
- const html = response.body;
108
- scraperResponse = await metascraper({html, url});
109
- }
110
- } catch (err) {
111
- return Promise.reject(err);
112
- }
145
+ const html = response.body;
146
+ scraperResponse = await metascraper({html, url});
113
147
 
114
148
  const metadata = Object.assign({}, scraperResponse, {
115
149
  thumbnail: scraperResponse.image,
@@ -119,20 +153,25 @@ class OEmbed {
119
153
  delete metadata.image;
120
154
  delete metadata.logo;
121
155
 
122
- if (metadata.title) {
123
- return Promise.resolve({
124
- type: 'bookmark',
125
- url,
126
- metadata
156
+ if (!metadata.title) {
157
+ throw new errors.ValidationError({
158
+ message: tpl(messages.insufficientMetadata),
159
+ context: url
127
160
  });
128
161
  }
129
162
 
130
- return Promise.reject(new errors.ValidationError({
131
- message: tpl(messages.insufficientMetadata),
132
- context: url
133
- }));
163
+ return {
164
+ version: '1.0',
165
+ type: 'bookmark',
166
+ url,
167
+ metadata
168
+ };
134
169
  }
135
170
 
171
+ /**
172
+ * @param {string} url
173
+ * @returns {boolean}
174
+ */
136
175
  isIpOrLocalhost(url) {
137
176
  try {
138
177
  const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
@@ -163,14 +202,7 @@ class OEmbed {
163
202
  *
164
203
  * @returns {Promise<Object>}
165
204
  */
166
- fetchOembedData(_url, cardType) {
167
- // parse the url then validate the protocol and host to make sure it's
168
- // http(s) and not an IP address or localhost to avoid potential access to
169
- // internal network endpoints
170
- if (this.isIpOrLocalhost(_url)) {
171
- return this.unknownProvider();
172
- }
173
-
205
+ async fetchOembedData(_url, cardType) {
174
206
  // check against known oembed list
175
207
  let {url, provider} = findUrlWithProvider(_url);
176
208
  if (provider) {
@@ -180,88 +212,81 @@ class OEmbed {
180
212
  // url not in oembed list so fetch it in case it's a redirect or has a
181
213
  // <link rel="alternate" type="application/json+oembed"> element
182
214
  const cookieJar = new CookieJar();
183
- return this.externalRequest(url, {
215
+ const pageResponse = await this.externalRequest(url, {
184
216
  method: 'GET',
185
217
  timeout: 2 * 1000,
186
218
  followRedirect: true,
187
219
  cookieJar
188
- }).then((pageResponse) => {
189
- // url changed after fetch, see if we were redirected to a known oembed
190
- if (pageResponse.url !== url) {
191
- ({url, provider} = findUrlWithProvider(pageResponse.url));
192
- if (provider) {
193
- return this.knownProvider(url);
194
- }
220
+ });
221
+ // url changed after fetch, see if we were redirected to a known oembed
222
+ if (pageResponse.url !== url) {
223
+ ({url, provider} = findUrlWithProvider(pageResponse.url));
224
+ if (provider) {
225
+ return this.knownProvider(url);
195
226
  }
227
+ }
196
228
 
197
- // check for <link rel="alternate" type="application/json+oembed"> element
198
- let oembedUrl;
199
- try {
200
- oembedUrl = cheerio('link[type="application/json+oembed"]', pageResponse.body).attr('href');
201
- } catch (e) {
202
- return this.unknownProvider(url);
229
+ // check for <link rel="alternate" type="application/json+oembed"> element
230
+ let oembedUrl;
231
+ try {
232
+ oembedUrl = cheerio('link[type="application/json+oembed"]', pageResponse.body).attr('href');
233
+ } catch (e) {
234
+ return this.unknownProvider(url);
235
+ }
236
+
237
+ if (oembedUrl) {
238
+ // for standard WP oembed's we want to insert a bookmark card rather than their blockquote+script
239
+ // which breaks in the editor and most Ghost themes. Only fallback if card type was not explicitly chosen
240
+ if (!cardType && oembedUrl.match(/wp-json\/oembed/)) {
241
+ return;
203
242
  }
204
243
 
205
- if (oembedUrl) {
206
- // make sure the linked url is not an ip address or localhost
207
- if (this.isIpOrLocalhost(oembedUrl)) {
208
- return this.unknownProvider(oembedUrl);
244
+ // fetch oembed response from embedded rel="alternate" url
245
+ const oembedResponse = await this.externalRequest(oembedUrl, {
246
+ method: 'GET',
247
+ json: true,
248
+ timeout: 2 * 1000,
249
+ followRedirect: true,
250
+ cookieJar
251
+ });
252
+ // validate the fetched json against the oembed spec to avoid
253
+ // leaking non-oembed responses
254
+ const body = oembedResponse.body;
255
+ const hasRequiredFields = body.type && body.version;
256
+ const hasValidType = ['photo', 'video', 'link', 'rich'].includes(body.type);
257
+
258
+ if (hasRequiredFields && hasValidType) {
259
+ // extract known oembed fields from the response to limit leaking of unrecognised data
260
+ const knownFields = [
261
+ 'type',
262
+ 'version',
263
+ 'html',
264
+ 'url',
265
+ 'title',
266
+ 'width',
267
+ 'height',
268
+ 'author_name',
269
+ 'author_url',
270
+ 'provider_name',
271
+ 'provider_url',
272
+ 'thumbnail_url',
273
+ 'thumbnail_width',
274
+ 'thumbnail_height'
275
+ ];
276
+ const oembed = _.pick(body, knownFields);
277
+
278
+ // ensure we have required data for certain types
279
+ if (oembed.type === 'photo' && !oembed.url) {
280
+ return;
209
281
  }
210
-
211
- // for standard WP oembed's we want to insert a bookmark card rather than their blockquote+script
212
- // which breaks in the editor and most Ghost themes. Only fallback if card type was not explicitly chosen
213
- if (!cardType && oembedUrl.match(/wp-json\/oembed/)) {
282
+ if ((oembed.type === 'video' || oembed.type === 'rich') && (!oembed.html || !oembed.width || !oembed.height)) {
214
283
  return;
215
284
  }
216
285
 
217
- // fetch oembed response from embedded rel="alternate" url
218
- return this.externalRequest(oembedUrl, {
219
- method: 'GET',
220
- json: true,
221
- timeout: 2 * 1000,
222
- followRedirect: true,
223
- cookieJar
224
- }).then((oembedResponse) => {
225
- // validate the fetched json against the oembed spec to avoid
226
- // leaking non-oembed responses
227
- const body = oembedResponse.body;
228
- const hasRequiredFields = body.type && body.version;
229
- const hasValidType = ['photo', 'video', 'link', 'rich'].includes(body.type);
230
-
231
- if (hasRequiredFields && hasValidType) {
232
- // extract known oembed fields from the response to limit leaking of unrecognised data
233
- const knownFields = [
234
- 'type',
235
- 'version',
236
- 'html',
237
- 'url',
238
- 'title',
239
- 'width',
240
- 'height',
241
- 'author_name',
242
- 'author_url',
243
- 'provider_name',
244
- 'provider_url',
245
- 'thumbnail_url',
246
- 'thumbnail_width',
247
- 'thumbnail_height'
248
- ];
249
- const oembed = _.pick(body, knownFields);
250
-
251
- // ensure we have required data for certain types
252
- if (oembed.type === 'photo' && !oembed.url) {
253
- return;
254
- }
255
- if ((oembed.type === 'video' || oembed.type === 'rich') && (!oembed.html || !oembed.width || !oembed.height)) {
256
- return;
257
- }
258
-
259
- // return the extracted object, don't pass through the response body
260
- return oembed;
261
- }
262
- }).catch(() => {});
286
+ // return the extracted object, don't pass through the response body
287
+ return oembed;
263
288
  }
264
- });
289
+ }
265
290
  }
266
291
 
267
292
  /**
@@ -274,6 +299,16 @@ class OEmbed {
274
299
  let data;
275
300
 
276
301
  try {
302
+ const urlObject = new URL(url);
303
+ for (const provider of this.customProviders) {
304
+ if (await provider.canSupportRequest(urlObject)) {
305
+ const result = await provider.getOEmbedData(urlObject, this.externalRequest);
306
+ if (result !== null) {
307
+ return result;
308
+ }
309
+ }
310
+ }
311
+
277
312
  if (type === 'bookmark') {
278
313
  return this.fetchBookmarkData(url);
279
314
  }
@@ -1,6 +1,3 @@
1
- const labs = require('../../../shared/labs');
2
- const events = require('../../lib/common/events');
3
-
4
1
  const DynamicRedirectManager = require('@tryghost/express-dynamic-redirects');
5
2
  const OffersModule = require('@tryghost/members-offers');
6
3
 
@@ -28,34 +25,7 @@ module.exports = {
28
25
 
29
26
  this.api = offersModule.api;
30
27
 
31
- let initCalled = false;
32
- if (labs.isSet('offers')) {
33
- // handles setting up redirects
34
- const promise = offersModule.init();
35
- initCalled = true;
36
- await promise;
37
- }
38
-
39
- // TODO: Delete after GA
40
- let offersEnabled = labs.isSet('offers');
41
- events.on('settings.labs.edited', async () => {
42
- if (labs.isSet('offers') && !initCalled) {
43
- const promise = offersModule.init();
44
- initCalled = true;
45
- await promise;
46
- } else if (labs.isSet('offers') !== offersEnabled) {
47
- offersEnabled = labs.isSet('offers');
48
-
49
- if (offersEnabled) {
50
- const offers = await this.api.listOffers({});
51
- for (const offer of offers) {
52
- redirectManager.addRedirect(`/${offer.code}`, `/#/portal/offers/${offer.id}`, {permanent: false});
53
- }
54
- } else {
55
- redirectManager.removeAllRedirects();
56
- }
57
- }
58
- });
28
+ await offersModule.init();
59
29
  },
60
30
 
61
31
  api: null,
@@ -16,7 +16,8 @@ module.exports = function getConfigProperties() {
16
16
  stripeDirect: config.get('stripeDirect'),
17
17
  mailgunIsConfigured: config.get('bulkEmail') && config.get('bulkEmail').mailgun,
18
18
  emailAnalytics: config.get('emailAnalytics'),
19
- hostSettings: config.get('hostSettings')
19
+ hostSettings: config.get('hostSettings'),
20
+ tenorApiKey: config.get('tenorApiKey')
20
21
  };
21
22
 
22
23
  const billingUrl = config.get('hostSettings:billing:enabled') ? config.get('hostSettings:billing:url') : '';
@@ -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;