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.
- package/.eslintrc.js +6 -0
- package/Gruntfile.js +2 -0
- package/content/themes/casper/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +263 -50
- package/content/themes/casper/default.hbs +12 -3
- package/content/themes/casper/index.hbs +25 -23
- package/content/themes/casper/package.json +91 -2
- package/content/themes/casper/partials/post-card.hbs +1 -1
- package/content/themes/casper/post.hbs +18 -14
- package/content/themes/casper/yarn.lock +245 -192
- package/core/boot.js +8 -0
- package/core/bridge.js +14 -0
- package/core/built/assets/{chunk.3.065ee3c3bdf674bd81a4.js → chunk.3.324fd0cc598c73650219.js} +59 -59
- package/core/built/assets/{ghost-dark-1328db4a7dd128305646305a8731bcfe.css → ghost-dark-39fb496d051565531062d7e047d1c0b1.css} +1 -1
- package/core/built/assets/{ghost.min-5abc69c04ad1d5301a857e01009b9c05.css → ghost.min-4207edfc1ae0a3f9f6505ca00d20b0c0.css} +1 -1
- package/core/built/assets/{ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js → ghost.min-7da921f6c6cac3fe10da1ba104575440.js} +1775 -1897
- package/core/built/assets/{vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js → vendor.min-413f887176a041e6dbf88214ca9a7481.js} +6849 -6688
- package/core/frontend/helpers/asset.js +9 -1
- package/core/frontend/helpers/ghost_head.js +13 -1
- package/core/frontend/services/card-assets/index.js +16 -0
- package/core/frontend/services/card-assets/service.js +109 -0
- package/core/frontend/services/theme-engine/config/defaults.json +4 -1
- package/core/frontend/services/theme-engine/config/index.js +1 -1
- package/core/frontend/src/cards/css/bookmark.css +83 -0
- package/core/frontend/src/cards/css/button.css +30 -0
- package/core/frontend/src/cards/css/callout.css +12 -0
- package/core/frontend/src/cards/css/gallery.css +36 -0
- package/core/frontend/src/cards/css/nft.css +85 -0
- package/core/frontend/src/cards/js/gallery.js +8 -0
- package/core/frontend/web/middleware/serve-public-file.js +10 -1
- package/core/frontend/web/routes.js +0 -1
- package/core/frontend/web/site.js +13 -9
- package/core/server/adapters/storage/LocalFilesStorage.js +17 -0
- package/core/server/adapters/storage/LocalImagesStorage.js +51 -0
- package/core/server/adapters/storage/LocalMediaStorage.js +24 -0
- package/core/server/adapters/storage/{LocalFileStorage.js → LocalStorageBase.js} +64 -51
- package/core/server/adapters/storage/index.js +1 -1
- package/core/server/adapters/storage/utils.js +2 -2
- package/core/server/api/canary/files.js +19 -0
- package/core/server/api/canary/index.js +8 -0
- package/core/server/api/canary/media.js +42 -0
- package/core/server/api/canary/oembed.js +3 -0
- package/core/server/api/canary/redirects.js +1 -6
- package/core/server/api/canary/utils/serializers/input/index.js +4 -0
- package/core/server/api/canary/utils/serializers/input/media.js +8 -0
- package/core/server/api/canary/utils/serializers/input/pages.js +8 -0
- package/core/server/api/canary/utils/serializers/output/config.js +21 -14
- package/core/server/api/canary/utils/serializers/output/files.js +27 -0
- package/core/server/api/canary/utils/serializers/output/index.js +8 -0
- package/core/server/api/canary/utils/serializers/output/media.js +37 -0
- package/core/server/api/canary/utils/validators/input/files.js +7 -0
- package/core/server/api/canary/utils/validators/input/index.js +8 -0
- package/core/server/api/canary/utils/validators/input/media.js +11 -0
- package/core/server/api/v2/redirects.js +1 -6
- package/core/server/api/v3/members.js +5 -1
- package/core/server/api/v3/redirects.js +1 -6
- package/core/server/data/migrations/utils.js +55 -16
- package/core/server/data/migrations/versions/4.22/01-add-is-launch-complete-setting.js +8 -0
- package/core/server/data/migrations/versions/4.22/02-update-launch-complete-setting-from-user-data.js +39 -0
- package/core/server/data/schema/default-settings.json +8 -0
- package/core/server/frontend/ghost.min.css +1 -1
- package/core/server/lib/image/blog-icon.js +2 -4
- package/core/server/lib/image/image-size.js +1 -1
- package/core/server/services/limits.js +3 -6
- package/core/server/services/mega/template.js +62 -1
- package/core/server/services/nft-oembed.js +71 -0
- package/core/server/services/oembed.js +145 -110
- package/core/server/services/offers/service.js +1 -31
- package/core/server/services/public-config/config.js +2 -1
- package/core/server/services/redirects/api.js +270 -0
- package/core/server/services/redirects/index.js +27 -12
- package/core/server/services/stripe/index.js +4 -2
- package/core/server/services/themes/ThemeStorage.js +5 -5
- package/core/server/services/url/Resource.js +1 -1
- package/core/server/services/url/Resources.js +28 -21
- package/core/server/services/url/UrlService.js +66 -8
- package/core/server/services/url/Urls.js +7 -2
- package/core/server/services/url/index.js +8 -1
- package/core/server/web/admin/views/default-prod.html +4 -4
- package/core/server/web/admin/views/default.html +4 -4
- package/core/server/web/api/canary/admin/routes.js +28 -4
- package/core/server/web/api/middleware/cors.js +7 -7
- package/core/server/web/api/middleware/upload.js +117 -10
- package/core/server/web/members/app.js +1 -1
- package/core/server/web/shared/middlewares/index.js +0 -4
- package/core/shared/config/defaults.json +5 -1
- package/core/shared/config/helpers.js +4 -0
- package/core/shared/config/overrides.json +8 -0
- package/core/shared/labs.js +12 -3
- package/package.json +28 -27
- package/urls.json +597 -0
- package/yarn.lock +972 -941
- package/core/built/assets/img/themes/Editorial-a25a4a34c04dedd858bd5e05ef388b1c.jpg +0 -0
- package/core/built/assets/img/themes/Massively-06edf00108429f7fb8e65f190fba34fe.jpg +0 -0
- package/core/server/services/redirects/settings.js +0 -234
- 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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
const response = await this.externalRequest(url, {cookieJar});
|
|
142
|
+
const cookieJar = new CookieJar();
|
|
143
|
+
const response = await this.externalRequest(url, {cookieJar});
|
|
103
144
|
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
215
|
+
const pageResponse = await this.externalRequest(url, {
|
|
184
216
|
method: 'GET',
|
|
185
217
|
timeout: 2 * 1000,
|
|
186
218
|
followRedirect: true,
|
|
187
219
|
cookieJar
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
218
|
-
return
|
|
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
|
-
|
|
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;
|