ghost 5.0.2 → 5.2.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 (58) hide show
  1. package/content/themes/casper/assets/built/screen.css +1 -1
  2. package/content/themes/casper/assets/built/screen.css.map +1 -1
  3. package/content/themes/casper/assets/css/screen.css +303 -35
  4. package/content/themes/casper/default.hbs +17 -7
  5. package/content/themes/casper/package.json +3 -2
  6. package/content/themes/casper/partials/icons/facebook.hbs +1 -1
  7. package/content/themes/casper/partials/icons/fire.hbs +3 -0
  8. package/content/themes/casper/partials/icons/lock.hbs +5 -0
  9. package/content/themes/casper/partials/icons/twitter.hbs +1 -1
  10. package/content/themes/casper/partials/post-card.hbs +48 -19
  11. package/content/themes/casper/post.hbs +20 -9
  12. package/core/built/assets/{chunk.3.52b444495dfcf50afb0b.js → chunk.3.dc389a0f93cb5fabd695.js} +19 -19
  13. package/core/built/assets/{ghost-dark-b6e3268bcae976a8675b0d9ae54540f2.css → ghost-dark-728b7c86791572db8e9126a77b2b7984.css} +1 -1
  14. package/core/built/assets/{ghost.min-b37a2752b0abda202c14c948b8bc3587.js → ghost.min-0643f338c882395ac93439fda1a36df8.js} +224 -223
  15. package/core/built/assets/{ghost.min-a40c7b301c702bd84c2fae19366ab5d7.css → ghost.min-f108dfe5d3861a2e1f0d704624261783.css} +1 -1
  16. package/core/built/assets/img/themes/Casper-19b7267aac5acc6abfaaed7e41eddae8.jpg +0 -0
  17. package/core/built/assets/{vendor.min-2bca41946dea27dc292428db605d466d.js → vendor.min-ea369e6487643585f35409d474b06789.js} +77 -94
  18. package/core/frontend/helpers/tiers.js +1 -16
  19. package/core/frontend/services/rendering/context.js +0 -3
  20. package/core/frontend/services/rendering/format-response.js +5 -1
  21. package/core/frontend/services/routing/controllers/static.js +4 -1
  22. package/core/frontend/services/routing/controllers/unsubscribe.js +10 -34
  23. package/core/frontend/web/middleware/handle-image-sizes.js +50 -10
  24. package/core/frontend/web/middleware/serve-favicon.js +3 -18
  25. package/core/server/api/canary/utils/serializers/output/mappers/posts.js +1 -4
  26. package/core/server/api/canary/utils/serializers/output/members.js +11 -14
  27. package/core/server/api/canary/utils/serializers/output/settings.js +13 -0
  28. package/core/server/api/canary/utils/validators/input/images.js +32 -18
  29. package/core/server/api/canary/utils/validators/input/settings.js +6 -0
  30. package/core/server/data/db/connection.js +13 -0
  31. package/core/server/lib/image/blog-icon.js +51 -27
  32. package/core/server/lib/mobiledoc.js +1 -1
  33. package/core/server/models/base/bookshelf.js +2 -0
  34. package/core/server/models/base/plugins/data-manipulation.js +1 -0
  35. package/core/server/models/base/plugins/relations.js +30 -0
  36. package/core/server/models/member.js +0 -12
  37. package/core/server/models/post.js +7 -4
  38. package/core/server/models/relations/authors.js +0 -19
  39. package/core/server/models/user.js +31 -3
  40. package/core/server/services/bulk-email/bulk-email-processor.js +1 -1
  41. package/core/server/services/invites/invites.js +1 -0
  42. package/core/server/services/mega/email-preview.js +1 -1
  43. package/core/server/services/mega/mega.js +7 -61
  44. package/core/server/services/members/utils.js +1 -3
  45. package/core/server/services/webhooks/webhooks-service.js +1 -1
  46. package/core/server/web/admin/views/default-prod.html +4 -4
  47. package/core/server/web/admin/views/default.html +4 -4
  48. package/core/server/web/api/canary/admin/app.js +2 -2
  49. package/core/server/web/api/canary/content/app.js +1 -1
  50. package/core/server/web/api/middleware/normalize-image.js +1 -1
  51. package/core/server/web/members/app.js +3 -3
  52. package/core/shared/config/defaults.json +2 -2
  53. package/core/shared/config/overrides.json +5 -2
  54. package/core/shared/config/utils.js +0 -9
  55. package/core/shared/labs.js +2 -13
  56. package/package.json +53 -53
  57. package/yarn.lock +573 -600
  58. package/core/built/assets/img/themes/Casper-c7e784d7188cc5d7f097d9b6c97b0263.jpg +0 -0
@@ -3,12 +3,11 @@
3
3
  //
4
4
  // Returns a string of the tiers with access to the post.
5
5
  // By default, tiers are separated by commas.
6
- const {labs} = require('../services/proxy');
7
6
  const {SafeString, escapeExpression} = require('../services/handlebars');
8
7
 
9
8
  const isString = require('lodash/isString');
10
9
 
11
- function tiers(options = {}) {
10
+ module.exports = function tiers(options = {}) {
12
11
  options = options || {};
13
12
  options.hash = options.hash || {};
14
13
 
@@ -42,18 +41,4 @@ function tiers(options = {}) {
42
41
  }
43
42
 
44
43
  return new SafeString(output);
45
- }
46
-
47
- module.exports = function productsLabsWrapper() {
48
- let self = this;
49
- let args = arguments;
50
-
51
- return labs.enabledHelper({
52
- flagKey: 'multipleProducts',
53
- flagName: 'Tiers',
54
- helperName: 'tiers',
55
- helpUrl: 'https://ghost.org/docs/themes/'
56
- }, () => {
57
- return tiers.apply(self, args);
58
- });
59
44
  };
@@ -57,9 +57,6 @@ function setResponseContext(req, res, data) {
57
57
  }
58
58
  }
59
59
 
60
- // @TODO: remove first if condition when only page key is returned
61
- // ref.: https://github.com/TryGhost/Ghost/issues/10042
62
- // The first if is now used by the preview route
63
60
  if (data && data.page) {
64
61
  if (!res.locals.context.includes('page')) {
65
62
  res.locals.context.push('page');
@@ -6,7 +6,7 @@ const {prepareContextResource} = require('../proxy');
6
6
  *
7
7
  * @return {Object} containing page variables
8
8
  */
9
- function formatPageResponse(result) {
9
+ function formatPageResponse(result, pageAsPost = false) {
10
10
  const response = {};
11
11
 
12
12
  if (result.posts) {
@@ -32,6 +32,10 @@ function formatPageResponse(result) {
32
32
  }
33
33
  });
34
34
 
35
+ if (pageAsPost && response.page) {
36
+ response.post = response.page;
37
+ }
38
+
35
39
  return response;
36
40
  }
37
41
 
@@ -60,7 +60,10 @@ module.exports = function staticController(req, res, next) {
60
60
  });
61
61
  }
62
62
 
63
- renderer.renderer(req, res, renderer.formatResponse.entries(response));
63
+ // This flag solves the confusion about whether the output contains a post or page object by duplicating the objects
64
+ // This is not ideal, but will solve some long standing pain points with dynamic routing until we can overhaul it
65
+ const duplicatePagesAsPosts = true;
66
+ renderer.renderer(req, res, renderer.formatResponse.entries(response, duplicatePagesAsPosts));
64
67
  })
65
68
  .catch(renderer.handleError(next));
66
69
  };
@@ -1,48 +1,24 @@
1
1
  const debug = require('@tryghost/debug')('services:routing:controllers:unsubscribe');
2
- const path = require('path');
3
2
  const url = require('url');
4
3
 
5
4
  const urlUtils = require('../../../../shared/url-utils');
6
- const megaService = require('../../../../server/services/mega');
7
- const renderer = require('../../rendering');
8
- const labs = require('../../../../shared/labs');
9
5
 
10
6
  module.exports = async function unsubscribeController(req, res) {
11
7
  debug('unsubscribeController');
12
8
 
13
- if (labs.isSet('multipleNewslettersUI')) {
14
- const {query} = url.parse(req.url, true);
9
+ const {query} = url.parse(req.url, true);
15
10
 
16
- if (!query || !query.uuid) {
17
- res.writeHead(400);
18
- return res.end('Email address not found.');
19
- }
20
-
21
- const redirectUrl = new URL(urlUtils.urlFor('home', true));
22
- redirectUrl.searchParams.append('uuid', query.uuid);
23
- if (query.newsletter) {
24
- redirectUrl.searchParams.append('newsletter', query.newsletter);
25
- }
26
- redirectUrl.searchParams.append('action', 'unsubscribe');
27
-
28
- return res.redirect(302, redirectUrl.href);
11
+ if (!query || !query.uuid) {
12
+ res.writeHead(400);
13
+ return res.end('Email address not found.');
29
14
  }
30
15
 
31
- let data = {};
32
-
33
- try {
34
- data.member = await megaService.mega.handleUnsubscribeRequest(req);
35
- } catch (err) {
36
- data.error = err.message;
16
+ const redirectUrl = new URL(urlUtils.urlFor('home', true));
17
+ redirectUrl.searchParams.append('uuid', query.uuid);
18
+ if (query.newsletter) {
19
+ redirectUrl.searchParams.append('newsletter', query.newsletter);
37
20
  }
21
+ redirectUrl.searchParams.append('action', 'unsubscribe');
38
22
 
39
- const templateName = 'unsubscribe';
40
-
41
- res.routerOptions = {
42
- type: 'custom',
43
- templates: templateName,
44
- defaultTemplate: path.resolve(__dirname, '../../../views/', templateName)
45
- };
46
-
47
- return renderer.renderer(req, res, data);
23
+ return res.redirect(302, redirectUrl.href);
48
24
  };
@@ -7,6 +7,8 @@ const activeTheme = require('../../services/theme-engine/active');
7
7
  const config = require('../../../shared/config');
8
8
 
9
9
  const SIZE_PATH_REGEX = /^\/size\/([^/]+)\//;
10
+ const FORMAT_PATH_REGEX = /^\/format\/([^./]+)\//;
11
+
10
12
  const TRAILING_SLASH_REGEX = /\/+$/;
11
13
 
12
14
  module.exports = function (req, res, next) {
@@ -18,9 +20,29 @@ module.exports = function (req, res, next) {
18
20
  return next();
19
21
  }
20
22
 
21
- const [sizeImageDir, requestedDimension] = req.url.match(SIZE_PATH_REGEX);
23
+ const requestedDimension = req.url.match(SIZE_PATH_REGEX)[1];
24
+
25
+ // Note that we don't use sizeImageDir because we need to keep the trailing slash
26
+ let imagePath = req.url.replace(`/size/${requestedDimension}`, '');
27
+
28
+ // Check if we want to format the image
29
+ let format = null;
30
+ const matchedFormat = imagePath.match(FORMAT_PATH_REGEX);
31
+ if (matchedFormat) {
32
+ format = matchedFormat[1];
33
+
34
+ // Note that we don't use matchedFormat[0] because we need to keep the trailing slash
35
+ imagePath = imagePath.replace(`/format/${format}`, '');
36
+ }
37
+
22
38
  const redirectToOriginal = () => {
23
- const url = req.originalUrl.replace(`/size/${requestedDimension}`, '');
39
+ // We need to keep the first slash here
40
+ let url = req.originalUrl
41
+ .replace(`/size/${requestedDimension}`, '');
42
+
43
+ if (format) {
44
+ url = url.replace(`/format/${format}`, '');
45
+ }
24
46
  return res.redirect(url);
25
47
  };
26
48
 
@@ -31,14 +53,10 @@ module.exports = function (req, res, next) {
31
53
  return next();
32
54
  }
33
55
 
34
- // CASE: image transform is not capable of transforming file (e.g. .gif)
35
- if (!imageTransform.canTransformFileExtension(requestUrlFileExtension)) {
36
- return redirectToOriginal();
37
- }
38
-
39
56
  const contentImageSizes = config.get('imageOptimization:contentImageSizes');
57
+ const internalImageSizes = config.get('imageOptimization:internalImageSizes');
40
58
  const themeImageSizes = activeTheme.get().config('image_sizes');
41
- const imageSizes = _.merge({}, themeImageSizes, contentImageSizes);
59
+ const imageSizes = _.merge({}, themeImageSizes, internalImageSizes, contentImageSizes);
42
60
 
43
61
  // CASE: no image_sizes config (NOTE - unlikely to be reachable now we have content sizes)
44
62
  if (!imageSizes) {
@@ -63,6 +81,25 @@ module.exports = function (req, res, next) {
63
81
  return redirectToOriginal();
64
82
  }
65
83
 
84
+ // CASE: image transform is not capable of transforming some files (e.g. .ico)
85
+ if (!imageTransform.canTransformFileExtension(requestUrlFileExtension)) {
86
+ return redirectToOriginal();
87
+ }
88
+
89
+ if (format) {
90
+ // CASE: When formatting, we need to check if the imageTransform package supports this specific format
91
+ if (!imageTransform.canTransformToFormat(format)) {
92
+ // transform not supported
93
+ return redirectToOriginal();
94
+ }
95
+ }
96
+
97
+ // CASE: when transforming is supported, we need to check if it is desired
98
+ // (e.g. it is not desired to resize SVGs when not formatting them to a different type)
99
+ if (!format && !imageTransform.shouldResizeFileExtension(requestUrlFileExtension)) {
100
+ return redirectToOriginal();
101
+ }
102
+
66
103
  const storageInstance = storage.getStorage('images');
67
104
  // CASE: unsupported storage adapter
68
105
  if (typeof storageInstance.saveRaw !== 'function') {
@@ -79,7 +116,6 @@ module.exports = function (req, res, next) {
79
116
  return redirectToOriginal();
80
117
  }
81
118
 
82
- const imagePath = path.relative(sizeImageDir, req.url);
83
119
  const {dir, name, ext} = path.parse(imagePath);
84
120
  const [imageNameMatched, imageName, imageNumber] = name.match(/^(.+?)(-\d+)?$/) || [null];
85
121
 
@@ -104,12 +140,16 @@ module.exports = function (req, res, next) {
104
140
  if (originalImageBuffer.length <= 0) {
105
141
  throw new NoContentError();
106
142
  }
107
- return imageTransform.resizeFromBuffer(originalImageBuffer, imageDimensionConfig);
143
+ return imageTransform.resizeFromBuffer(originalImageBuffer, {withoutEnlargement: requestUrlFileExtension !== '.svg', ...imageDimensionConfig, format});
108
144
  })
109
145
  .then((resizedImageBuffer) => {
110
146
  return storageInstance.saveRaw(resizedImageBuffer, req.url);
111
147
  });
112
148
  }).then(() => {
149
+ if (format) {
150
+ // File extension won't match the new format, so we need to update the Content-Type header manually here
151
+ res.type(format);
152
+ }
113
153
  next();
114
154
  }).catch(function (err) {
115
155
  if (err.code === 'SHARP_INSTALLATION' || err.code === 'IMAGE_PROCESSING' || err.errorType === 'NoContentError') {
@@ -3,7 +3,6 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const config = require('../../../shared/config');
5
5
  const {blogIcon} = require('../../../server/lib/image');
6
- const storage = require('../../../server/adapters/storage');
7
6
  const urlUtils = require('../../../shared/url-utils');
8
7
  const settingsCache = require('../../../shared/settings-cache');
9
8
 
@@ -26,11 +25,10 @@ const buildContentResponse = (ext, buf) => {
26
25
  // ### serveFavicon Middleware
27
26
  // Handles requests to favicon.png and favicon.ico
28
27
  function serveFavicon() {
29
- let iconType;
30
28
  let filePath;
31
29
 
32
30
  return function serveFaviconMiddleware(req, res, next) {
33
- if (req.path.match(/^\/favicon\.(ico|png)/i)) {
31
+ if (req.path.match(/^\/favicon\.(ico|png|jpe?g)/i)) {
34
32
  // CASE: favicon is default
35
33
  // confusing: if you upload an icon, it's same logic as storing images
36
34
  // we store as /content/images, because this is the url path images get requested via the browser
@@ -44,21 +42,8 @@ function serveFavicon() {
44
42
 
45
43
  // CASE: custom favicon exists, load it from local file storage
46
44
  if (settingsCache.get('icon')) {
47
- // depends on the uploaded icon extension
48
- if (originalExtension !== requestedExtension) {
49
- return res.redirect(302, urlUtils.urlFor({relativeUrl: `/favicon${originalExtension}`}));
50
- }
51
-
52
- storage.getStorage('images')
53
- .read({path: filePath})
54
- .then((buf) => {
55
- iconType = blogIcon.getIconType();
56
- content = buildContentResponse(iconType, buf);
57
-
58
- res.writeHead(200, content.headers);
59
- res.end(content.body);
60
- })
61
- .catch(next);
45
+ // Always redirect to the icon path, which is never favicon.xxx
46
+ return res.redirect(302, blogIcon.getIconUrl());
62
47
  } else {
63
48
  originalExtension = path.extname(filePath).toLowerCase();
64
49
 
@@ -13,7 +13,6 @@ const url = require('../utils/url');
13
13
  const utils = require('../../../index');
14
14
 
15
15
  const postsMetaSchema = require('../../../../../../data/schema').tables.posts_meta;
16
- const labsService = require('../../../../../../../shared/labs');
17
16
 
18
17
  const getPostServiceInstance = require('../../../../../../services/posts/posts-service');
19
18
  const postsService = getPostServiceInstance();
@@ -35,9 +34,7 @@ module.exports = async (model, frame, options = {}) => {
35
34
  extraAttrs.forPost(frame, model, jsonModel);
36
35
 
37
36
  // Attach tiers to custom nql visibility filter
38
- if (labsService.isSet('multipleProducts')
39
- && jsonModel.visibility
40
- ) {
37
+ if (jsonModel.visibility) {
41
38
  if (['members', 'public'].includes(jsonModel.visibility) && jsonModel.tiers) {
42
39
  jsonModel.tiers = tiersData || [];
43
40
  }
@@ -1,7 +1,6 @@
1
1
  //@ts-check
2
2
  const debug = require('@tryghost/debug')('api:canary:utils:serializers:output:members');
3
3
  const {unparse} = require('@tryghost/members-csv');
4
- const labsService = require('../../../../../../shared/labs');
5
4
 
6
5
  module.exports = {
7
6
  browse: createSerializer('browse', paginatedMembers),
@@ -142,19 +141,17 @@ function serializeMember(member, options) {
142
141
  delete subscription.price.product;
143
142
  }
144
143
 
145
- if (labsService.isSet('multipleNewsletters')) {
146
- if (json.newsletters) {
147
- serialized.newsletters = json.newsletters
148
- .filter(newsletter => newsletter.status === 'active')
149
- .sort((a, b) => {
150
- return a.sort_order - b.sort_order;
151
- });
152
- }
153
- // override the `subscribed` param to mean "subscribed to any active newsletter"
154
- serialized.subscribed = false;
155
- if (Array.isArray(serialized.newsletters) && serialized.newsletters.length > 0) {
156
- serialized.subscribed = true;
157
- }
144
+ if (json.newsletters) {
145
+ serialized.newsletters = json.newsletters
146
+ .filter(newsletter => newsletter.status === 'active')
147
+ .sort((a, b) => {
148
+ return a.sort_order - b.sort_order;
149
+ });
150
+ }
151
+ // override the `subscribed` param to mean "subscribed to any active newsletter"
152
+ serialized.subscribed = false;
153
+ if (Array.isArray(serialized.newsletters) && serialized.newsletters.length > 0) {
154
+ serialized.subscribed = true;
158
155
  }
159
156
 
160
157
  return serialized;
@@ -33,8 +33,21 @@ function serializeSettings(models, apiConfig, frame) {
33
33
  // If this is public, we already have the right data, we just need to add an Array wrapper
34
34
  if (utils.isContentAPI(frame)) {
35
35
  filteredSettings = models;
36
+
37
+ // Change the returned icon location to use a resized version, to prevent serving giant icon files
38
+ const icon = filteredSettings.icon;
39
+ if (icon) {
40
+ filteredSettings.icon = filteredSettings.icon.replace(/\/content\/images\//, '/content/images/size/w256h256/');
41
+ }
36
42
  } else {
37
43
  filteredSettings = _.values(settingsFilter(models, frame.options.group));
44
+
45
+ // Change the returned icon location to use a resized version, to prevent serving giant icon files
46
+ // in admin
47
+ const icon = filteredSettings.find(setting => setting.key === 'icon');
48
+ if (icon && icon.value) {
49
+ icon.value = icon.value.replace(/\/content\/images\//, '/content/images/size/w256h256/');
50
+ }
38
51
  }
39
52
 
40
53
  frame.response = {
@@ -6,7 +6,8 @@ const {imageSize, blogIcon} = require('../../../../../lib/image');
6
6
 
7
7
  const messages = {
8
8
  isNotSquare: 'Please select a valid image file with square dimensions.',
9
- invalidFile: 'Icon must be a square .ico or .png file between 60px – 1,000px, under 100kb.'
9
+ invalidIcoFile: 'Ico icons must be square, at least 60x60px, and under 200kB.',
10
+ invalidFile: 'Icon must be a .jpg, .webp, .svg or .png file, at least 60x60px, under 20MB.'
10
11
  };
11
12
 
12
13
  const profileImage = (frame) => {
@@ -26,14 +27,25 @@ const profileImage = (frame) => {
26
27
  const icon = (frame) => {
27
28
  const iconExtensions = (config.get('uploads').icons && config.get('uploads').icons.extensions) || [];
28
29
 
30
+ // We don't support resizing .ico files, so we set a lower max upload size
31
+ const isIco = frame.file.ext.toLowerCase() === '.ico';
32
+ const isSVG = frame.file.ext.toLowerCase() === '.svg';
33
+
29
34
  const validIconFileSize = (size) => {
30
- return (size / 1024) <= 100;
35
+ if (isIco) {
36
+ // Keep using kB instead of KB
37
+ return (size / 1024) <= 200;
38
+ }
39
+ // Use MB representation (not MiB)
40
+ return (size / 1000 / 1000) <= 20;
31
41
  };
32
42
 
33
- // CASE: file should not be larger than 100kb
43
+ const message = isIco ? messages.invalidIcoFile : messages.invalidFile;
44
+
45
+ // CASE: file should not be larger than 20MB
34
46
  if (!validIconFileSize(frame.file.size)) {
35
47
  return Promise.reject(new errors.ValidationError({
36
- message: tpl(messages.invalidFile, {extensions: iconExtensions})
48
+ message: tpl(message, {extensions: iconExtensions})
37
49
  }));
38
50
  }
39
51
 
@@ -41,25 +53,27 @@ const icon = (frame) => {
41
53
  // save the image dimensions in new property for file
42
54
  frame.file.dimensions = response;
43
55
 
44
- // CASE: file needs to be a square
45
- if (frame.file.dimensions.width !== frame.file.dimensions.height) {
46
- return Promise.reject(new errors.ValidationError({
47
- message: tpl(messages.invalidFile, {extensions: iconExtensions})
48
- }));
56
+ if (isIco) {
57
+ // CASE: file needs to be a square
58
+ if (frame.file.dimensions.width !== frame.file.dimensions.height) {
59
+ return Promise.reject(new errors.ValidationError({
60
+ message: tpl(message, {extensions: iconExtensions})
61
+ }));
62
+ }
63
+
64
+ // CASE: icon needs to be smaller than or equal to 1000px
65
+ if (frame.file.dimensions.width > 1000) {
66
+ return Promise.reject(new errors.ValidationError({
67
+ message: tpl(message, {extensions: iconExtensions})
68
+ }));
69
+ }
49
70
  }
50
71
 
51
72
  // CASE: icon needs to be bigger than or equal to 60px
52
73
  // .ico files can contain multiple sizes, we need at least a minimum of 60px (16px is ok, as long as 60px are present as well)
53
- if (frame.file.dimensions.width < 60) {
54
- return Promise.reject(new errors.ValidationError({
55
- message: tpl(messages.invalidFile, {extensions: iconExtensions})
56
- }));
57
- }
58
-
59
- // CASE: icon needs to be smaller than or equal to 1000px
60
- if (frame.file.dimensions.width > 1000) {
74
+ if (!isSVG && frame.file.dimensions.width < 60) {
61
75
  return Promise.reject(new errors.ValidationError({
62
- message: tpl(messages.invalidFile, {extensions: iconExtensions})
76
+ message: tpl(message, {extensions: iconExtensions})
63
77
  }));
64
78
  }
65
79
  });
@@ -45,6 +45,12 @@ module.exports = {
45
45
  }
46
46
  });
47
47
 
48
+ // Prevent setting icon to the resized one when sending all settings received from browse again in the edit endpoint
49
+ const icon = frame.data.settings.find(setting => setting.key === 'icon');
50
+ if (icon && icon.value) {
51
+ icon.value = icon.value.replace(/\/content\/images\/size\/([^/]+)\//, '/content/images/');
52
+ }
53
+
48
54
  if (errors.length) {
49
55
  return Promise.reject(errors[0]);
50
56
  }
@@ -1,4 +1,8 @@
1
+ const _ = require('lodash');
1
2
  const knex = require('knex');
3
+ const os = require('os');
4
+
5
+ const logging = require('@tryghost/logging');
2
6
  const config = require('../../../shared/config');
3
7
  let knexInstance;
4
8
 
@@ -30,6 +34,15 @@ function configure(dbConfig) {
30
34
  // Force bthreads to use child_process backend until a worker_thread-compatible version of sqlite3 is published
31
35
  // https://github.com/mapbox/node-sqlite3/issues/1386
32
36
  process.env.BTHREADS_BACKEND = 'child_process';
37
+
38
+ // In the default SQLite test config we set the path to /tmp/ghost-test.db,
39
+ // but this won't work on Windows, so we need to replace the /tmp bit with
40
+ // the Windows temp folder
41
+ const filename = dbConfig.connection.filename;
42
+ if (process.platform === 'win32' && _.isString(filename) && filename.match(/^\/tmp/)) {
43
+ dbConfig.connection.filename = filename.replace(/^\/tmp/, os.tmpdir());
44
+ logging.info(`Ghost DB path: ${dbConfig.connection.filename}`);
45
+ }
33
46
  }
34
47
 
35
48
  if (client === 'mysql2') {
@@ -56,53 +56,77 @@ class BlogIcon {
56
56
  }
57
57
 
58
58
  /**
59
- * Check if file is `.ico` extension
60
- * Always returns {object} isIcoImageType
61
- * @param {string} icon
62
- * @returns {boolean} true if submitted path is .ico file
59
+ * Returns the mime type (part after image/) of the favicon that will get served (not the stored one)
60
+ * @param {string} [icon]
61
+ * @returns {'png' | 'x-icon' | 'jpeg'}
63
62
  * @description Takes a path and returns boolean value.
64
63
  */
65
- isIcoImageType(icon) {
66
- const blogIcon = icon || this.settingsCache.get('icon');
64
+ getIconType(icon) {
65
+ const ext = this.getIconExt(icon);
67
66
 
68
- return blogIcon.match(/.ico$/i) ? true : false;
67
+ return ext === 'ico' ? 'x-icon' : ext;
69
68
  }
70
69
 
71
70
  /**
72
- * Check if file is `.ico` extension
73
- * Always returns {object} isIcoImageType
74
- * @param {string} icon
75
- * @returns {boolean} true if submitted path is .ico file
76
- * @description Takes a path and returns boolean value.
71
+ * We support the usage of .svg, .gif, .webp extensions, but (for now, until more browser support them) transform them to
72
+ * a simular extension
73
+ * @param {string} [icon]
74
+ * @returns {'png' | 'ico' | 'jpeg'}
77
75
  */
78
- getIconType(icon) {
76
+ getIconExt(icon) {
79
77
  const blogIcon = icon || this.settingsCache.get('icon');
80
78
 
81
- return this.isIcoImageType(blogIcon) ? 'x-icon' : 'png';
79
+ // If the native format is supported, return the native format
80
+ if (blogIcon.match(/.ico$/i)) {
81
+ return 'ico';
82
+ }
83
+
84
+ if (blogIcon.match(/.jpe?g$/i)) {
85
+ return 'jpeg';
86
+ }
87
+
88
+ if (blogIcon.match(/.png$/i)) {
89
+ return 'png';
90
+ }
91
+
92
+ // Default to png for all other types
93
+ return 'png';
94
+ }
95
+
96
+ getSourceIconExt(icon) {
97
+ const blogIcon = icon || this.settingsCache.get('icon');
98
+ return path.extname(blogIcon).toLowerCase().substring(1);
82
99
  }
83
100
 
84
101
  /**
85
- * Return URL for Blog icon: [subdirectory or not]favicon.[ico or png]
102
+ * Return URL for Blog icon: [subdirectory or not]favicon.[ico, jpeg, or png]
86
103
  * Always returns {string} getIconUrl
87
- * @returns {string} [subdirectory or not]favicon.[ico or png]
104
+ * @returns {string} [subdirectory or not]favicon.[ico, jpeg, or png]
88
105
  * @description Checks if we have a custom uploaded icon and the extension of it. If no custom uploaded icon
89
106
  * exists, we're returning the default `favicon.ico`
90
107
  */
91
- getIconUrl(absolut) {
108
+ getIconUrl(absolute) {
92
109
  const blogIcon = this.settingsCache.get('icon');
93
110
 
94
- if (absolut) {
95
- if (blogIcon) {
96
- return this.isIcoImageType(blogIcon) ? this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, true) : this.urlUtils.urlFor({relativeUrl: '/favicon.png'}, true);
97
- } else {
98
- return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, true);
111
+ if (blogIcon) {
112
+ // Resize + format icon to one of the supported file extensions
113
+ const sourceExt = this.getSourceIconExt(blogIcon);
114
+ const destintationExt = this.getIconExt(blogIcon);
115
+
116
+ if (sourceExt === 'ico') {
117
+ // Resize not supported (prevent a redirect)
118
+ return this.urlUtils.urlFor({relativeUrl: blogIcon}, absolute ? true : undefined);
99
119
  }
100
- } else {
101
- if (blogIcon) {
102
- return this.isIcoImageType(blogIcon) ? this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}) : this.urlUtils.urlFor({relativeUrl: '/favicon.png'});
103
- } else {
104
- return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'});
120
+
121
+ if (sourceExt !== destintationExt) {
122
+ const formattedIcon = blogIcon.replace(/\/content\/images\//, `/content/images/size/w256h256/format/${this.getIconExt(blogIcon)}/`);
123
+ return this.urlUtils.urlFor({relativeUrl: formattedIcon}, absolute ? true : undefined);
105
124
  }
125
+
126
+ const sizedIcon = blogIcon.replace(/\/content\/images\//, '/content/images/size/w256h256/');
127
+ return this.urlUtils.urlFor({relativeUrl: sizedIcon}, absolute ? true : undefined);
128
+ } else {
129
+ return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, absolute ? true : undefined);
106
130
  }
107
131
  }
108
132
 
@@ -38,7 +38,7 @@ module.exports = {
38
38
 
39
39
  // NOTE: the "saveRaw" check is smelly
40
40
  return imageTransform.canTransformFiles()
41
- && imageTransform.canTransformFileExtension(ext)
41
+ && imageTransform.shouldResizeFileExtension(ext)
42
42
  && typeof storage.getStorage('images').saveRaw === 'function';
43
43
  }
44
44
  });
@@ -61,6 +61,8 @@ ghostBookshelf.plugin(require('./plugins/data-manipulation'));
61
61
 
62
62
  ghostBookshelf.plugin(require('./plugins/overrides'));
63
63
 
64
+ ghostBookshelf.plugin(require('./plugins/relations'));
65
+
64
66
  // Manages nested updates (relationships)
65
67
  ghostBookshelf.plugin('bookshelf-relations', {
66
68
  allowedOptions: ['context', 'importing', 'migrating'],
@@ -31,6 +31,7 @@ module.exports = function (Bookshelf) {
31
31
 
32
32
  _.each(attrs, function each(value, key) {
33
33
  if (value !== null
34
+ && Object.prototype.hasOwnProperty.call(schema.tables, self.tableName)
34
35
  && Object.prototype.hasOwnProperty.call(schema.tables[self.tableName], key)
35
36
  && schema.tables[self.tableName][key].type === 'dateTime') {
36
37
  attrs[key] = moment(value).format('YYYY-MM-DD HH:mm:ss');