From a051ab3b69c578f0646a6da66b7c2edb5e86ced6 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Fri, 27 May 2022 16:36:53 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Reduced=20favicon=20requirements?= =?UTF-8?q?=20and=20added=20image=20formatting=20(#14918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://github.com/TryGhost/Team/issues/1652 fixes https://github.com/TryGhost/Ghost/issues/13319 **Image formatting** Added support for changing the format of images via the `handle-image-sizes` middleware (e.g. format SVG to png, jpeg, webp) This change was required: - Not all browsers support SVG favicons, so we need to convert them to PNGs - We can't fit image resizing and formatting in the `serve-favicon` middleware: we need to store the resized image to avoid resizing on every request. This system was already present in the `handle-image-sizes` middleware. To format an uploaded image: - Original URL: https://localhost/blog/content/images/2022/05/giphy.gif - To resize: https://localhost/blog/content/images/size/w256h256/2022/05/giphy.gif (already supported) - To resize and format to webp: https://localhost/blog/content/images/size/w256h256/format/webp/2022/05/giphy.gif - Animations are preserved when converting Gifs to Webp and in reverse, and also when only resizing (https://github.com/TryGhost/Ghost/issues/13319) **Favicons** - Custom favicons are no longer served via `/favicon.png` or `/favicon.ico` (only for default favicon), but use their full path - Added support for uploading more image extensions in Ghost as a favicon: .jpg, .jpeg, .gif, .webp and .svg are now supported (already supported .png and .ico). - File extensions other than jpg/jpeg, png, or ico will always get transformed to the image/png format to guarantee browser support (webp and svg images are not yet supported as favicons by all browsers). For all image formats, other than .ico files: - Allowed to upload images larger than 1000px in width and height, they will get cropped to 256x256px. - Allowed uploading favicons that are not square. They will get cropped automatically. - Allowed to upload larger files, up to 20MB (will get served at a lower file size after being resized) For .svg files: - The minimum size of 60x60px is no longer required. For .ico files: - The file size limit is increased to 200kb (coming from 100kb) --- .../web/middleware/handle-image-sizes.js | 60 ++- core/frontend/web/middleware/serve-favicon.js | 21 +- .../utils/serializers/output/settings.js | 13 + .../canary/utils/validators/input/images.js | 50 ++- .../canary/utils/validators/input/settings.js | 6 + core/server/lib/image/blog-icon.js | 88 ++-- core/server/lib/mobiledoc.js | 2 +- .../web/api/middleware/normalize-image.js | 2 +- core/shared/config/overrides.json | 7 +- package.json | 2 +- .../admin/__snapshots__/settings.test.js.snap | 293 +++++++++++++- test/e2e-api/admin/settings.test.js | 31 ++ test/regression/api/admin/settings.test.js | 2 +- test/unit/frontend/helpers/asset.test.js | 22 +- test/unit/frontend/helpers/ghost_head.test.js | 10 +- test/unit/frontend/meta/asset-url.test.js | 8 +- test/unit/frontend/meta/blog-logo.test.js | 2 +- .../web/middleware/handle-image-sizes.test.js | 376 +++++++++++++++++- .../web/middleware/serve-favicon.test.js | 101 ++--- test/unit/server/lib/image/blog-icon.test.js | 42 +- test/unit/server/lib/mobiledoc.test.js | 24 +- .../api/middleware/normalize-image.test.js | 2 +- yarn.lock | 8 +- 23 files changed, 955 insertions(+), 217 deletions(-) diff --git a/core/frontend/web/middleware/handle-image-sizes.js b/core/frontend/web/middleware/handle-image-sizes.js index 963ca25e06..4c04229ae3 100644 --- a/core/frontend/web/middleware/handle-image-sizes.js +++ b/core/frontend/web/middleware/handle-image-sizes.js @@ -7,6 +7,8 @@ const activeTheme = require('../../services/theme-engine/active'); const config = require('../../../shared/config'); const SIZE_PATH_REGEX = /^\/size\/([^/]+)\//; +const FORMAT_PATH_REGEX = /^\/format\/([^./]+)\//; + const TRAILING_SLASH_REGEX = /\/+$/; module.exports = function (req, res, next) { @@ -18,9 +20,29 @@ module.exports = function (req, res, next) { return next(); } - const [sizeImageDir, requestedDimension] = req.url.match(SIZE_PATH_REGEX); + const requestedDimension = req.url.match(SIZE_PATH_REGEX)[1]; + + // Note that we don't use sizeImageDir because we need to keep the trailing slash + let imagePath = req.url.replace(`/size/${requestedDimension}`, ''); + + // Check if we want to format the image + let format = null; + const matchedFormat = imagePath.match(FORMAT_PATH_REGEX); + if (matchedFormat) { + format = matchedFormat[1]; + + // Note that we don't use matchedFormat[0] because we need to keep the trailing slash + imagePath = imagePath.replace(`/format/${format}`, ''); + } + const redirectToOriginal = () => { - const url = req.originalUrl.replace(`/size/${requestedDimension}`, ''); + // We need to keep the first slash here + let url = req.originalUrl + .replace(`/size/${requestedDimension}`, ''); + + if (format) { + url = url.replace(`/format/${format}`, ''); + } return res.redirect(url); }; @@ -31,14 +53,10 @@ module.exports = function (req, res, next) { return next(); } - // CASE: image transform is not capable of transforming file (e.g. .gif) - if (!imageTransform.canTransformFileExtension(requestUrlFileExtension)) { - return redirectToOriginal(); - } - const contentImageSizes = config.get('imageOptimization:contentImageSizes'); + const internalImageSizes = config.get('imageOptimization:internalImageSizes'); const themeImageSizes = activeTheme.get().config('image_sizes'); - const imageSizes = _.merge({}, themeImageSizes, contentImageSizes); + const imageSizes = _.merge({}, themeImageSizes, internalImageSizes, contentImageSizes); // CASE: no image_sizes config (NOTE - unlikely to be reachable now we have content sizes) if (!imageSizes) { @@ -63,6 +81,25 @@ module.exports = function (req, res, next) { return redirectToOriginal(); } + // CASE: image transform is not capable of transforming some files (e.g. .ico) + if (!imageTransform.canTransformFileExtension(requestUrlFileExtension)) { + return redirectToOriginal(); + } + + if (format) { + // CASE: When formatting, we need to check if the imageTransform package supports this specific format + if (!imageTransform.canTransformToFormat(format)) { + // transform not supported + return redirectToOriginal(); + } + } + + // CASE: when transforming is supported, we need to check if it is desired + // (e.g. it is not desired to resize SVGs when not formatting them to a different type) + if (!format && !imageTransform.shouldResizeFileExtension(requestUrlFileExtension)) { + return redirectToOriginal(); + } + const storageInstance = storage.getStorage('images'); // CASE: unsupported storage adapter if (typeof storageInstance.saveRaw !== 'function') { @@ -79,7 +116,6 @@ module.exports = function (req, res, next) { return redirectToOriginal(); } - const imagePath = path.relative(sizeImageDir, req.url); const {dir, name, ext} = path.parse(imagePath); const [imageNameMatched, imageName, imageNumber] = name.match(/^(.+?)(-\d+)?$/) || [null]; @@ -104,12 +140,16 @@ module.exports = function (req, res, next) { if (originalImageBuffer.length <= 0) { throw new NoContentError(); } - return imageTransform.resizeFromBuffer(originalImageBuffer, imageDimensionConfig); + return imageTransform.resizeFromBuffer(originalImageBuffer, {withoutEnlargement: requestUrlFileExtension !== '.svg', ...imageDimensionConfig, format}); }) .then((resizedImageBuffer) => { return storageInstance.saveRaw(resizedImageBuffer, req.url); }); }).then(() => { + if (format) { + // File extension won't match the new format, so we need to update the Content-Type header manually here + res.type(format); + } next(); }).catch(function (err) { if (err.code === 'SHARP_INSTALLATION' || err.code === 'IMAGE_PROCESSING' || err.errorType === 'NoContentError') { diff --git a/core/frontend/web/middleware/serve-favicon.js b/core/frontend/web/middleware/serve-favicon.js index e7b4b1bc1a..a8bb5f8e24 100644 --- a/core/frontend/web/middleware/serve-favicon.js +++ b/core/frontend/web/middleware/serve-favicon.js @@ -3,7 +3,6 @@ const path = require('path'); const crypto = require('crypto'); const config = require('../../../shared/config'); const {blogIcon} = require('../../../server/lib/image'); -const storage = require('../../../server/adapters/storage'); const urlUtils = require('../../../shared/url-utils'); const settingsCache = require('../../../shared/settings-cache'); @@ -26,11 +25,10 @@ const buildContentResponse = (ext, buf) => { // ### serveFavicon Middleware // Handles requests to favicon.png and favicon.ico function serveFavicon() { - let iconType; let filePath; return function serveFaviconMiddleware(req, res, next) { - if (req.path.match(/^\/favicon\.(ico|png)/i)) { + if (req.path.match(/^\/favicon\.(ico|png|jpe?g)/i)) { // CASE: favicon is default // confusing: if you upload an icon, it's same logic as storing images // we store as /content/images, because this is the url path images get requested via the browser @@ -44,21 +42,8 @@ function serveFavicon() { // CASE: custom favicon exists, load it from local file storage if (settingsCache.get('icon')) { - // depends on the uploaded icon extension - if (originalExtension !== requestedExtension) { - return res.redirect(302, urlUtils.urlFor({relativeUrl: `/favicon${originalExtension}`})); - } - - storage.getStorage('images') - .read({path: filePath}) - .then((buf) => { - iconType = blogIcon.getIconType(); - content = buildContentResponse(iconType, buf); - - res.writeHead(200, content.headers); - res.end(content.body); - }) - .catch(next); + // Always redirect to the icon path, which is never favicon.xxx + return res.redirect(302, blogIcon.getIconUrl()); } else { originalExtension = path.extname(filePath).toLowerCase(); diff --git a/core/server/api/canary/utils/serializers/output/settings.js b/core/server/api/canary/utils/serializers/output/settings.js index 9da4163c90..794d7f5831 100644 --- a/core/server/api/canary/utils/serializers/output/settings.js +++ b/core/server/api/canary/utils/serializers/output/settings.js @@ -33,8 +33,21 @@ function serializeSettings(models, apiConfig, frame) { // If this is public, we already have the right data, we just need to add an Array wrapper if (utils.isContentAPI(frame)) { filteredSettings = models; + + // Change the returned icon location to use a resized version, to prevent serving giant icon files + const icon = filteredSettings.icon; + if (icon) { + filteredSettings.icon = filteredSettings.icon.replace(/\/content\/images\//, '/content/images/size/w256h256/'); + } } else { filteredSettings = _.values(settingsFilter(models, frame.options.group)); + + // Change the returned icon location to use a resized version, to prevent serving giant icon files + // in admin + const icon = filteredSettings.find(setting => setting.key === 'icon'); + if (icon && icon.value) { + icon.value = icon.value.replace(/\/content\/images\//, '/content/images/size/w256h256/'); + } } frame.response = { diff --git a/core/server/api/canary/utils/validators/input/images.js b/core/server/api/canary/utils/validators/input/images.js index 87217b0d94..699be21d9d 100644 --- a/core/server/api/canary/utils/validators/input/images.js +++ b/core/server/api/canary/utils/validators/input/images.js @@ -6,7 +6,8 @@ const {imageSize, blogIcon} = require('../../../../../lib/image'); const messages = { isNotSquare: 'Please select a valid image file with square dimensions.', - invalidFile: 'Icon must be a square .ico or .png file between 60px – 1,000px, under 100kb.' + invalidIcoFile: 'Ico icons must be square, at least 60x60px, and under 200kB.', + invalidFile: 'Icon must be a .jpg, .webp, .svg or .png file, at least 60x60px, under 20MB.' }; const profileImage = (frame) => { @@ -26,14 +27,25 @@ const profileImage = (frame) => { const icon = (frame) => { const iconExtensions = (config.get('uploads').icons && config.get('uploads').icons.extensions) || []; + // We don't support resizing .ico files, so we set a lower max upload size + const isIco = frame.file.ext.toLowerCase() === '.ico'; + const isSVG = frame.file.ext.toLowerCase() === '.svg'; + const validIconFileSize = (size) => { - return (size / 1024) <= 100; + if (isIco) { + // Keep using kB instead of KB + return (size / 1024) <= 200; + } + // Use MB representation (not MiB) + return (size / 1000 / 1000) <= 20; }; - // CASE: file should not be larger than 100kb + const message = isIco ? messages.invalidIcoFile : messages.invalidFile; + + // CASE: file should not be larger than 20MB if (!validIconFileSize(frame.file.size)) { return Promise.reject(new errors.ValidationError({ - message: tpl(messages.invalidFile, {extensions: iconExtensions}) + message: tpl(message, {extensions: iconExtensions}) })); } @@ -41,25 +53,27 @@ const icon = (frame) => { // save the image dimensions in new property for file frame.file.dimensions = response; - // CASE: file needs to be a square - if (frame.file.dimensions.width !== frame.file.dimensions.height) { - return Promise.reject(new errors.ValidationError({ - message: tpl(messages.invalidFile, {extensions: iconExtensions}) - })); + if (isIco) { + // CASE: file needs to be a square + if (frame.file.dimensions.width !== frame.file.dimensions.height) { + return Promise.reject(new errors.ValidationError({ + message: tpl(message, {extensions: iconExtensions}) + })); + } + + // CASE: icon needs to be smaller than or equal to 1000px + if (frame.file.dimensions.width > 1000) { + return Promise.reject(new errors.ValidationError({ + message: tpl(message, {extensions: iconExtensions}) + })); + } } // CASE: icon needs to be bigger than or equal to 60px // .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) - if (frame.file.dimensions.width < 60) { + if (!isSVG && frame.file.dimensions.width < 60) { return Promise.reject(new errors.ValidationError({ - message: tpl(messages.invalidFile, {extensions: iconExtensions}) - })); - } - - // CASE: icon needs to be smaller than or equal to 1000px - if (frame.file.dimensions.width > 1000) { - return Promise.reject(new errors.ValidationError({ - message: tpl(messages.invalidFile, {extensions: iconExtensions}) + message: tpl(message, {extensions: iconExtensions}) })); } }); diff --git a/core/server/api/canary/utils/validators/input/settings.js b/core/server/api/canary/utils/validators/input/settings.js index 5f1be1787a..a275a736ea 100644 --- a/core/server/api/canary/utils/validators/input/settings.js +++ b/core/server/api/canary/utils/validators/input/settings.js @@ -45,6 +45,12 @@ module.exports = { } }); + // Prevent setting icon to the resized one when sending all settings received from browse again in the edit endpoint + const icon = frame.data.settings.find(setting => setting.key === 'icon'); + if (icon && icon.value) { + icon.value = icon.value.replace(/\/content\/images\/size\/([^/]+)\//, '/content/images/'); + } + if (errors.length) { return Promise.reject(errors[0]); } diff --git a/core/server/lib/image/blog-icon.js b/core/server/lib/image/blog-icon.js index ba4a2c3a23..89cb1cc33a 100644 --- a/core/server/lib/image/blog-icon.js +++ b/core/server/lib/image/blog-icon.js @@ -56,53 +56,77 @@ class BlogIcon { } /** - * Check if file is `.ico` extension - * Always returns {object} isIcoImageType - * @param {string} icon - * @returns {boolean} true if submitted path is .ico file - * @description Takes a path and returns boolean value. - */ - isIcoImageType(icon) { - const blogIcon = icon || this.settingsCache.get('icon'); - - return blogIcon.match(/.ico$/i) ? true : false; - } - - /** - * Check if file is `.ico` extension - * Always returns {object} isIcoImageType - * @param {string} icon - * @returns {boolean} true if submitted path is .ico file + * Returns the mime type (part after image/) of the favicon that will get served (not the stored one) + * @param {string} [icon] + * @returns {'png' | 'x-icon' | 'jpeg'} * @description Takes a path and returns boolean value. */ getIconType(icon) { - const blogIcon = icon || this.settingsCache.get('icon'); + const ext = this.getIconExt(icon); - return this.isIcoImageType(blogIcon) ? 'x-icon' : 'png'; + return ext === 'ico' ? 'x-icon' : ext; } /** - * Return URL for Blog icon: [subdirectory or not]favicon.[ico or png] + * We support the usage of .svg, .gif, .webp extensions, but (for now, until more browser support them) transform them to + * a simular extension + * @param {string} [icon] + * @returns {'png' | 'ico' | 'jpeg'} + */ + getIconExt(icon) { + const blogIcon = icon || this.settingsCache.get('icon'); + + // If the native format is supported, return the native format + if (blogIcon.match(/.ico$/i)) { + return 'ico'; + } + + if (blogIcon.match(/.jpe?g$/i)) { + return 'jpeg'; + } + + if (blogIcon.match(/.png$/i)) { + return 'png'; + } + + // Default to png for all other types + return 'png'; + } + + getSourceIconExt(icon) { + const blogIcon = icon || this.settingsCache.get('icon'); + return path.extname(blogIcon).toLowerCase().substring(1); + } + + /** + * Return URL for Blog icon: [subdirectory or not]favicon.[ico, jpeg, or png] * Always returns {string} getIconUrl - * @returns {string} [subdirectory or not]favicon.[ico or png] + * @returns {string} [subdirectory or not]favicon.[ico, jpeg, or png] * @description Checks if we have a custom uploaded icon and the extension of it. If no custom uploaded icon * exists, we're returning the default `favicon.ico` */ - getIconUrl(absolut) { + getIconUrl(absolute) { const blogIcon = this.settingsCache.get('icon'); - if (absolut) { - if (blogIcon) { - return this.isIcoImageType(blogIcon) ? this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, true) : this.urlUtils.urlFor({relativeUrl: '/favicon.png'}, true); - } else { - return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, true); + if (blogIcon) { + // Resize + format icon to one of the supported file extensions + const sourceExt = this.getSourceIconExt(blogIcon); + const destintationExt = this.getIconExt(blogIcon); + + if (sourceExt === 'ico') { + // Resize not supported (prevent a redirect) + return this.urlUtils.urlFor({relativeUrl: blogIcon}, absolute ? true : undefined); } + + if (sourceExt !== destintationExt) { + const formattedIcon = blogIcon.replace(/\/content\/images\//, `/content/images/size/w256h256/format/${this.getIconExt(blogIcon)}/`); + return this.urlUtils.urlFor({relativeUrl: formattedIcon}, absolute ? true : undefined); + } + + const sizedIcon = blogIcon.replace(/\/content\/images\//, '/content/images/size/w256h256/'); + return this.urlUtils.urlFor({relativeUrl: sizedIcon}, absolute ? true : undefined); } else { - if (blogIcon) { - return this.isIcoImageType(blogIcon) ? this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}) : this.urlUtils.urlFor({relativeUrl: '/favicon.png'}); - } else { - return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}); - } + return this.urlUtils.urlFor({relativeUrl: '/favicon.ico'}, absolute ? true : undefined); } } diff --git a/core/server/lib/mobiledoc.js b/core/server/lib/mobiledoc.js index 8cc474b680..790210966f 100644 --- a/core/server/lib/mobiledoc.js +++ b/core/server/lib/mobiledoc.js @@ -38,7 +38,7 @@ module.exports = { // NOTE: the "saveRaw" check is smelly return imageTransform.canTransformFiles() - && imageTransform.canTransformFileExtension(ext) + && imageTransform.shouldResizeFileExtension(ext) && typeof storage.getStorage('images').saveRaw === 'function'; } }); diff --git a/core/server/web/api/middleware/normalize-image.js b/core/server/web/api/middleware/normalize-image.js index dd5e6eb002..ec3b4708fb 100644 --- a/core/server/web/api/middleware/normalize-image.js +++ b/core/server/web/api/middleware/normalize-image.js @@ -7,7 +7,7 @@ module.exports = function normalize(req, res, next) { const imageOptimizationOptions = config.get('imageOptimization'); // CASE: image transform is not capable of transforming file (e.g. .gif) - if (!imageTransform.canTransformFileExtension(req.file.ext) || !imageOptimizationOptions.resize) { + if (!imageTransform.shouldResizeFileExtension(req.file.ext) || !imageOptimizationOptions.resize) { return next(); } diff --git a/core/shared/config/overrides.json b/core/shared/config/overrides.json index ef3bbf1ddb..f48b17d18d 100644 --- a/core/shared/config/overrides.json +++ b/core/shared/config/overrides.json @@ -49,8 +49,8 @@ "contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"] }, "icons": { - "extensions": [".png", ".ico"], - "contentTypes": ["image/png", "image/x-icon", "image/vnd.microsoft.icon"] + "extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico", ".webp"], + "contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"] }, "db": { "extensions": [".json", ".zip"], @@ -84,6 +84,9 @@ "w1000": {"width": 1000}, "w1600": {"width": 1600}, "w2400": {"width": 2400} + }, + "internalImageSizes": { + "icon": {"width": 256, "height": 256} } } } diff --git a/package.json b/package.json index caf36089c4..f76b9cc1ff 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@tryghost/errors": "1.2.12", "@tryghost/express-dynamic-redirects": "0.2.13", "@tryghost/helpers": "1.1.70", - "@tryghost/image-transform": "1.0.32", + "@tryghost/image-transform": "1.1.0", "@tryghost/job-manager": "0.8.24", "@tryghost/kg-card-factory": "3.1.3", "@tryghost/kg-default-atoms": "3.1.2", diff --git a/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 75686cf9b9..8bf90c45e3 100644 --- a/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -650,7 +650,7 @@ Object { }, Object { "key": "icon", - "value": "", + "value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png", }, Object { "key": "accent_color", @@ -908,7 +908,7 @@ exports[`Settings API Edit cannot edit uneditable settings 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3297", + "content-length": "3364", "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Origin, Accept-Encoding", @@ -916,6 +916,295 @@ Object { } `; +exports[`Settings API Edit removes image size prefixes when setting the icon 1: [body] 1`] = ` +Object { + "meta": Object {}, + "settings": Array [ + Object { + "key": "title", + "value": "[]", + }, + Object { + "key": "description", + "value": "Thoughts, stories and ideas", + }, + Object { + "key": "logo", + "value": "", + }, + Object { + "key": "cover_image", + "value": "https://static.ghost.org/v4.0.0/images/publication-cover.jpg", + }, + Object { + "key": "icon", + "value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png", + }, + Object { + "key": "accent_color", + "value": "#FF1A75", + }, + Object { + "key": "locale", + "value": "ua", + }, + Object { + "key": "timezone", + "value": "Pacific/Auckland", + }, + Object { + "key": "codeinjection_head", + "value": null, + }, + Object { + "key": "codeinjection_foot", + "value": "", + }, + Object { + "key": "facebook", + "value": "ghost", + }, + Object { + "key": "twitter", + "value": "@ghost", + }, + Object { + "key": "navigation", + "value": "[{\\"label\\":\\"label1\\"}]", + }, + Object { + "key": "secondary_navigation", + "value": "[{\\"label\\":\\"Data & privacy\\",\\"url\\":\\"/privacy/\\"},{\\"label\\":\\"Contact\\",\\"url\\":\\"/contact/\\"},{\\"label\\":\\"Contribute →\\",\\"url\\":\\"/contribute/\\"}]", + }, + Object { + "key": "meta_title", + "value": "SEO title", + }, + Object { + "key": "meta_description", + "value": "SEO description", + }, + Object { + "key": "og_image", + "value": "http://127.0.0.1:2369/content/images/2019/07/facebook.png", + }, + Object { + "key": "og_title", + "value": "facebook title", + }, + Object { + "key": "og_description", + "value": "facebook description", + }, + Object { + "key": "twitter_image", + "value": "http://127.0.0.1:2369/content/images/2019/07/twitter.png", + }, + Object { + "key": "twitter_title", + "value": "twitter title", + }, + Object { + "key": "twitter_description", + "value": "twitter description", + }, + Object { + "key": "active_theme", + "value": "casper", + }, + Object { + "key": "is_private", + "value": false, + }, + Object { + "key": "password", + "value": "", + }, + Object { + "key": "public_hash", + "value": StringMatching /\\[a-z0-9\\]\\{30\\}/, + }, + Object { + "key": "default_content_visibility", + "value": "public", + }, + Object { + "key": "default_content_visibility_tiers", + "value": "[]", + }, + Object { + "key": "members_signup_access", + "value": "all", + }, + Object { + "key": "members_support_address", + "value": "noreply", + }, + Object { + "key": "stripe_secret_key", + "value": null, + }, + Object { + "key": "stripe_publishable_key", + "value": null, + }, + Object { + "key": "stripe_plans", + "value": "[]", + }, + Object { + "key": "stripe_connect_publishable_key", + "value": "pk_test_for_stripe", + }, + Object { + "key": "stripe_connect_secret_key", + "value": "••••••••", + }, + Object { + "key": "stripe_connect_livemode", + "value": null, + }, + Object { + "key": "stripe_connect_display_name", + "value": null, + }, + Object { + "key": "stripe_connect_account_id", + "value": null, + }, + Object { + "key": "members_monthly_price_id", + "value": null, + }, + Object { + "key": "members_yearly_price_id", + "value": null, + }, + Object { + "key": "portal_name", + "value": true, + }, + Object { + "key": "portal_button", + "value": true, + }, + Object { + "key": "portal_plans", + "value": "[\\"free\\"]", + }, + Object { + "key": "portal_products", + "value": "[]", + }, + Object { + "key": "portal_button_style", + "value": "icon-and-text", + }, + Object { + "key": "portal_button_icon", + "value": null, + }, + Object { + "key": "portal_button_signup_text", + "value": "Subscribe", + }, + Object { + "key": "mailgun_domain", + "value": null, + }, + Object { + "key": "mailgun_api_key", + "value": null, + }, + Object { + "key": "mailgun_base_url", + "value": null, + }, + Object { + "key": "email_track_opens", + "value": true, + }, + Object { + "key": "email_verification_required", + "value": false, + }, + Object { + "key": "amp", + "value": false, + }, + Object { + "key": "amp_gtag_id", + "value": null, + }, + Object { + "key": "firstpromoter", + "value": false, + }, + Object { + "key": "firstpromoter_id", + "value": null, + }, + Object { + "key": "labs", + "value": "{\\"members\\":true}", + }, + Object { + "key": "slack_url", + "value": "", + }, + Object { + "key": "slack_username", + "value": "New Slack Username", + }, + Object { + "key": "unsplash", + "value": false, + }, + Object { + "key": "shared_views", + "value": "[]", + }, + Object { + "key": "editor_default_email_recipients", + "value": "visibility", + }, + Object { + "key": "editor_default_email_recipients_filter", + "value": "all", + }, + Object { + "key": "members_enabled", + "value": true, + }, + Object { + "key": "members_invite_only", + "value": false, + }, + Object { + "key": "paid_members_enabled", + "value": true, + }, + Object { + "key": "firstpromoter_account", + "value": null, + }, + ], +} +`; + +exports[`Settings API Edit removes image size prefixes when setting the icon 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "3364", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + exports[`Settings API deprecated can do updateMembersEmail 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", diff --git a/test/e2e-api/admin/settings.test.js b/test/e2e-api/admin/settings.test.js index acae92289c..06856d28f5 100644 --- a/test/e2e-api/admin/settings.test.js +++ b/test/e2e-api/admin/settings.test.js @@ -1,4 +1,5 @@ const assert = require('assert'); +const settingsCache = require('../../../core/shared/settings-cache'); const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); const {stringMatching, anyEtag, anyUuid} = matchers; @@ -164,6 +165,36 @@ describe('Settings API', function () { }); }); + it('removes image size prefixes when setting the icon', async function () { + const settingsToChange = [ + { + key: 'icon', + value: '/content/images/size/w256h256/2019/07/icon.png' + } + ]; + + const {body} = await agent.put('settings/') + .body({ + settings: settingsToChange + }) + .expectStatus(200) + .matchBodySnapshot({ + settings: matchSettingsArray(CURRENT_SETTINGS_COUNT) + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + + // Check returned WITH prefix + const val = body.settings.find(setting => setting.key === 'icon'); + assert.ok(val); + assert.equal(val.value, 'http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png'); + + // Check if not changed (also check internal ones) + const afterValue = settingsCache.get('icon'); + assert.equal(afterValue, 'http://127.0.0.1:2369/content/images/2019/07/icon.png'); + }); + it('cannot edit uneditable settings', async function () { await agent.put('settings/') .body({ diff --git a/test/regression/api/admin/settings.test.js b/test/regression/api/admin/settings.test.js index f9805289fc..46a953a515 100644 --- a/test/regression/api/admin/settings.test.js +++ b/test/regression/api/admin/settings.test.js @@ -188,7 +188,7 @@ describe('Settings API (canary)', function () { responseSettings.should.have.property('cover_image', `${config.get('url')}/content/images/cover_image.png`); responseSettings.should.have.property('logo', `${config.get('url')}/content/images/logo.png`); - responseSettings.should.have.property('icon', `${config.get('url')}/content/images/icon.png`); + responseSettings.should.have.property('icon', `${config.get('url')}/content/images/size/w256h256/icon.png`); responseSettings.should.have.property('portal_button_icon', `${config.get('url')}/content/images/portal_button_icon.png`); responseSettings.should.have.property('og_image', `${config.get('url')}/content/images/og_image.png`); responseSettings.should.have.property('twitter_image', `${config.get('url')}/content/images/twitter_image.png`); diff --git a/test/unit/frontend/helpers/asset.test.js b/test/unit/frontend/helpers/asset.test.js index 4bef9e3e8a..f9d806535b 100644 --- a/test/unit/frontend/helpers/asset.test.js +++ b/test/unit/frontend/helpers/asset.test.js @@ -40,19 +40,29 @@ describe('{{asset}} helper', function () { }); it('handles custom favicon correctly', function () { - localSettingsCache.icon = '/content/images/favicon.png'; - // with png + localSettingsCache.icon = '/content/images/favicon.png'; rendered = asset('favicon.png'); should.exist(rendered); - String(rendered).should.equal('/favicon.png'); - - localSettingsCache.icon = '/content/images/favicon.ico'; + String(rendered).should.equal('/content/images/size/w256h256/favicon.png'); // with ico + localSettingsCache.icon = '/content/images/favicon.ico'; rendered = asset('favicon.ico'); should.exist(rendered); - String(rendered).should.equal('/favicon.ico'); + String(rendered).should.equal('/content/images/favicon.ico'); + + // with webp + localSettingsCache.icon = '/content/images/favicon.webp'; + rendered = asset('favicon.png'); + should.exist(rendered); + String(rendered).should.equal('/content/images/size/w256h256/format/png/favicon.webp'); + + // with svg + localSettingsCache.icon = '/content/images/favicon.svg'; + rendered = asset('favicon.png'); + should.exist(rendered); + String(rendered).should.equal('/content/images/size/w256h256/format/png/favicon.svg'); }); it('handles public assets correctly', function () { diff --git a/test/unit/frontend/helpers/ghost_head.test.js b/test/unit/frontend/helpers/ghost_head.test.js index 7cd694f076..7f8180f865 100644 --- a/test/unit/frontend/helpers/ghost_head.test.js +++ b/test/unit/frontend/helpers/ghost_head.test.js @@ -1304,7 +1304,7 @@ describe('{{ghost_head}} helper', function () { } })).then(function (rendered) { should.exist(rendered); - rendered.string.should.match(//); + rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); @@ -1333,7 +1333,7 @@ describe('{{ghost_head}} helper', function () { } })).then(function (rendered) { should.exist(rendered); - rendered.string.should.match(//); + rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.not.match(//); + rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); @@ -1401,7 +1401,7 @@ describe('{{ghost_head}} helper', function () { } })).then(function (rendered) { should.exist(rendered); - rendered.string.should.match(//); + rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); @@ -1487,7 +1487,7 @@ describe('{{ghost_head}} helper', function () { } })).then(function (rendered) { should.exist(rendered); - rendered.string.should.match(//); + rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); rendered.string.should.match(//); diff --git a/test/unit/frontend/meta/asset-url.test.js b/test/unit/frontend/meta/asset-url.test.js index 0709cb48b9..074ebd7e48 100644 --- a/test/unit/frontend/meta/asset-url.test.js +++ b/test/unit/frontend/meta/asset-url.test.js @@ -52,7 +52,13 @@ describe('getAssetUrl', function () { it('should correct favicon path for custom png', function () { sinon.stub(settingsCache, 'get').withArgs('icon').returns('/content/images/2017/04/my-icon.png'); const testUrl = getAssetUrl('favicon.ico'); - testUrl.should.equal('/favicon.png'); + testUrl.should.equal('/content/images/size/w256h256/2017/04/my-icon.png'); + }); + + it('should correct favicon path for custom svg', function () { + sinon.stub(settingsCache, 'get').withArgs('icon').returns('/content/images/2017/04/my-icon.svg'); + const testUrl = getAssetUrl('favicon.ico'); + testUrl.should.equal('/content/images/size/w256h256/format/png/2017/04/my-icon.svg'); }); }); diff --git a/test/unit/frontend/meta/blog-logo.test.js b/test/unit/frontend/meta/blog-logo.test.js index b7d07b89cf..df70e896a3 100644 --- a/test/unit/frontend/meta/blog-logo.test.js +++ b/test/unit/frontend/meta/blog-logo.test.js @@ -35,6 +35,6 @@ describe('getBlogLogo', function () { blogLogo = getBlogLogo(); should.exist(blogLogo); - blogLogo.should.have.property('url', 'http://127.0.0.1:2369/favicon.png'); + blogLogo.should.have.property('url', 'http://127.0.0.1:2369/content/images/size/w256h256/favicon.png'); }); }); diff --git a/test/unit/frontend/web/middleware/handle-image-sizes.test.js b/test/unit/frontend/web/middleware/handle-image-sizes.test.js index 7d5cc83900..4bcf50c8c4 100644 --- a/test/unit/frontend/web/middleware/handle-image-sizes.test.js +++ b/test/unit/frontend/web/middleware/handle-image-sizes.test.js @@ -3,6 +3,7 @@ const sinon = require('sinon'); const storage = require('../../../../../core/server/adapters/storage'); const activeTheme = require('../../../../../core/frontend/services/theme-engine/active'); const handleImageSizes = require('../../../../../core/frontend/web/middleware/handle-image-sizes.js'); +const imageTransform = require('@tryghost/image-transform'); // @TODO make these tests lovely and non specific to implementation describe('handleImageSizes middleware', function () { @@ -48,15 +49,18 @@ describe('handleImageSizes middleware', function () { describe('file handling', function () { let dummyStorage; let dummyTheme; + let resizeFromBufferStub; + let buffer; this.beforeEach(function () { + buffer = Buffer.from([0]); dummyStorage = { async exists() { return true; }, read() { - return Buffer.from([]); + return buffer; }, async saveRaw(buf, url) { @@ -78,12 +82,36 @@ describe('handleImageSizes middleware', function () { sinon.stub(storage, 'getStorage').returns(dummyStorage); sinon.stub(activeTheme, 'get').returns(dummyTheme); + resizeFromBufferStub = sinon.stub(imageTransform, 'resizeFromBuffer').resolves(Buffer.from([])); }); this.afterEach(function () { sinon.restore(); }); + it('redirects for invalid format extension', function (done) { + const fakeReq = { + url: '/size/w1000/format/test/image.jpg', + originalUrl: '/blog/content/images/size/w1000/format/test/image.jpg' + }; + const fakeRes = { + redirect(url) { + try { + url.should.equal('/blog/content/images/image.jpg'); + } catch (e) { + return done(e); + } + done(); + } + }; + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(new Error('Should not have called next')); + }); + }); + it('returns original URL if file is empty', function (done) { dummyStorage.exists = async function (path) { if (path === '/blank_o.png') { @@ -93,14 +121,21 @@ describe('handleImageSizes middleware', function () { return false; } }; + dummyStorage.read = async function (path) { + return Buffer.from([]); + }; const fakeReq = { url: '/size/w1000/blank.png', - originalUrl: '/size/w1000/blank.png' + originalUrl: '/blog/content/images/size/w1000/blank.png' }; const fakeRes = { redirect(url) { - url.should.equal('/blank.png'); + try { + url.should.equal('/blog/content/images/blank.png'); + } catch (e) { + return done(e); + } done(); } }; @@ -112,5 +147,340 @@ describe('handleImageSizes middleware', function () { done(new Error('Should not have called next')); }); }); + + it('continues if file exists', function (done) { + dummyStorage.exists = async function (path) { + if (path === '/size/w1000/blank.png') { + return true; + } + }; + + const fakeReq = { + url: '/size/w1000/blank.png', + originalUrl: '/size/w1000/blank.png' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('uses unoptimizedImageExists if it exists', function (done) { + dummyStorage.exists = async function (path) { + if (path === '/blank_o.png') { + return true; + } + }; + const spy = sinon.spy(dummyStorage, 'read'); + + const fakeReq = { + url: '/size/w1000/blank.png', + originalUrl: '/size/w1000/blank.png' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + spy.calledOnceWithExactly({path: '/blank_o.png'}).should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); + + it('uses unoptimizedImageExists if it exists with formatting', function (done) { + dummyStorage.exists = async function (path) { + if (path === '/blank_o.png') { + return true; + } + }; + const spy = sinon.spy(dummyStorage, 'read'); + + const fakeReq = { + url: '/size/w1000/format/webp/blank.png', + originalUrl: '/size/w1000/format/webp/blank.png' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + }, + type: function () {} + }; + const typeStub = sinon.spy(fakeRes, 'type'); + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + spy.calledOnceWithExactly({path: '/blank_o.png'}).should.be.true(); + typeStub.calledOnceWithExactly('webp').should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); + + it('skips SVG if not formatted', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + + const fakeReq = { + url: '/size/w1000/blank.svg', + originalUrl: '/blog/content/images/size/w1000/blank.svg' + }; + const fakeRes = { + redirect(url) { + try { + url.should.equal('/blog/content/images/blank.svg'); + } catch (e) { + return done(e); + } + done(); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(new Error('Should not have called next')); + }); + }); + + it('skips formatting to ico', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + + const fakeReq = { + url: '/size/w1000/format/ico/blank.png', + originalUrl: '/blog/content/images/size/w1000/format/ico/blank.png' + }; + const fakeRes = { + redirect(url) { + try { + url.should.equal('/blog/content/images/blank.png'); + } catch (e) { + return done(e); + } + done(); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(new Error('Should not have called next')); + }); + }); + + it('skips formatting from ico', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + + const fakeReq = { + url: '/size/w1000/format/png/blank.ico', + originalUrl: '/blog/content/images/size/w1000/format/png/blank.ico' + }; + const fakeRes = { + redirect(url) { + try { + url.should.equal('/blog/content/images/blank.ico'); + } catch (e) { + return done(e); + } + done(); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(new Error('Should not have called next')); + }); + }); + + it('skips formatting to svg', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + + const fakeReq = { + url: '/size/w1000/format/svg/blank.png', + originalUrl: '/blog/content/images/size/w1000/format/svg/blank.png' + }; + const fakeRes = { + redirect(url) { + try { + url.should.equal('/blog/content/images/blank.png'); + } catch (e) { + return done(e); + } + done(); + } + }; + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + done(new Error('Should not have called next')); + }); + }); + + it('doesn\'t skip SVGs if formatted to PNG', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + + const fakeReq = { + url: '/size/w1000/format/png/blank.svg', + originalUrl: '/size/w1000/format/png/blank.svg' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + }, + type: function () {} + }; + const typeStub = sinon.spy(fakeRes, 'type'); + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + resizeFromBufferStub.calledOnceWithExactly(buffer, {withoutEnlargement: false, width: 1000, format: 'png'}).should.be.true(); + typeStub.calledOnceWithExactly('png').should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); + + it('can format PNG to WEBP', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + dummyStorage.read = async function (path) { + return buffer; + }; + + const fakeReq = { + url: '/size/w1000/format/webp/blank.png', + originalUrl: '/size/w1000/format/webp/blank.png' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + }, + type: function () {} + }; + const typeStub = sinon.spy(fakeRes, 'type'); + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + resizeFromBufferStub.calledOnceWithExactly(buffer, {withoutEnlargement: true, width: 1000, format: 'webp'}).should.be.true(); + typeStub.calledOnceWithExactly('webp').should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); + + it('can format GIF to WEBP', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + dummyStorage.read = async function (path) { + return buffer; + }; + + const fakeReq = { + url: '/size/w1000/format/webp/blank.gif', + originalUrl: '/size/w1000/format/webp/blank.gif' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + }, + type: function () {} + }; + const typeStub = sinon.spy(fakeRes, 'type'); + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + resizeFromBufferStub.calledOnceWithExactly(buffer, {withoutEnlargement: true, width: 1000, format: 'webp'}).should.be.true(); + typeStub.calledOnceWithExactly('webp').should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); + + it('can format WEBP to GIF', function (done) { + dummyStorage.exists = async function (path) { + return false; + }; + dummyStorage.read = async function (path) { + return buffer; + }; + + const fakeReq = { + url: '/size/w1000/format/gif/blank.webp', + originalUrl: '/size/w1000/format/gif/blank.webp' + }; + const fakeRes = { + redirect(url) { + done(new Error('Should not have called redirect')); + }, + type: function () {} + }; + const typeStub = sinon.spy(fakeRes, 'type'); + + handleImageSizes(fakeReq, fakeRes, function next(err) { + if (err) { + return done(err); + } + try { + resizeFromBufferStub.calledOnceWithExactly(buffer, {withoutEnlargement: true, width: 1000, format: 'gif'}).should.be.true(); + typeStub.calledOnceWithExactly('gif').should.be.true(); + } catch (e) { + return done(e); + } + done(); + }); + }); }); }); diff --git a/test/unit/frontend/web/middleware/serve-favicon.test.js b/test/unit/frontend/web/middleware/serve-favicon.test.js index caa5f8679f..373afdd8f0 100644 --- a/test/unit/frontend/web/middleware/serve-favicon.test.js +++ b/test/unit/frontend/web/middleware/serve-favicon.test.js @@ -51,66 +51,6 @@ describe('Serve Favicon', function () { }); describe('serves', function () { - it('custom uploaded favicon.png', function (done) { - const middleware = serveFavicon(); - req.path = '/favicon.png'; - - storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); - localSettingsCache.icon = 'favicon.png'; - - res = { - writeHead: function (statusCode) { - statusCode.should.eql(200); - }, - end: function (body) { - body.length.should.eql(6792); - done(); - } - }; - - middleware(req, res, next); - }); - - it('custom uploaded favicon.ico', function (done) { - const middleware = serveFavicon(); - req.path = '/favicon.ico'; - - storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); - localSettingsCache.icon = 'favicon.ico'; - - res = { - writeHead: function (statusCode) { - statusCode.should.eql(200); - }, - end: function (body) { - body.length.should.eql(15406); - done(); - } - }; - - middleware(req, res, next); - }); - - it('custom uploaded myicon.ico', function (done) { - const middleware = serveFavicon(); - req.path = '/favicon.ico'; - - storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); - localSettingsCache.icon = 'myicon.ico'; - - res = { - writeHead: function (statusCode) { - statusCode.should.eql(200); - }, - end: function (body) { - body.length.should.eql(15086); - done(); - } - }; - - middleware(req, res, next); - }); - it('default favicon.ico', function (done) { const middleware = serveFavicon(); req.path = '/favicon.ico'; @@ -131,16 +71,17 @@ describe('Serve Favicon', function () { }); describe('redirects', function () { - it('to custom favicon.ico when favicon.png is requested', function (done) { + it('custom uploaded favicon.png', function (done) { const middleware = serveFavicon(); req.path = '/favicon.png'; - configUtils.set('paths:contentPath', path.join(__dirname, '../../../../test/utils/fixtures/')); - localSettingsCache.icon = 'favicon.ico'; + storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); + localSettingsCache.icon = '/content/images/favicon.png'; res = { - redirect: function (statusCode) { + redirect: function (statusCode, p) { statusCode.should.eql(302); + p.should.eql('/content/images/size/w256h256/favicon.png'); done(); } }; @@ -148,16 +89,35 @@ describe('Serve Favicon', function () { middleware(req, res, next); }); - it('to custom favicon.png when favicon.ico is requested', function (done) { + it('custom uploaded favicon.webp', function (done) { + const middleware = serveFavicon(); + req.path = '/favicon.png'; + + storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); + localSettingsCache.icon = '/content/images/favicon.webp'; + + res = { + redirect: function (statusCode, p) { + statusCode.should.eql(302); + p.should.eql('/content/images/size/w256h256/format/png/favicon.webp'); + done(); + } + }; + + middleware(req, res, next); + }); + + it('custom uploaded favicon.ico', function (done) { const middleware = serveFavicon(); req.path = '/favicon.ico'; - configUtils.set('paths:contentPath', path.join(__dirname, '../../../../test/utils/fixtures/')); - localSettingsCache.icon = 'favicon.png'; + storage.getStorage().storagePath = path.join(__dirname, '../../../../utils/fixtures/images/'); + localSettingsCache.icon = '/content/images/favicon.ico'; res = { - redirect: function (statusCode) { + redirect: function (statusCode, p) { statusCode.should.eql(302); + p.should.eql('/content/images/favicon.ico'); done(); } }; @@ -170,11 +130,12 @@ describe('Serve Favicon', function () { req.path = '/favicon.png'; configUtils.set('paths:publicFilePath', path.join(__dirname, '../../../../test/utils/fixtures/')); - localSettingsCache.icon = ''; + localSettingsCache.icon = null; res = { - redirect: function (statusCode) { + redirect: function (statusCode, p) { statusCode.should.eql(302); + p.should.eql('/favicon.ico'); done(); } }; diff --git a/test/unit/server/lib/image/blog-icon.test.js b/test/unit/server/lib/image/blog-icon.test.js index e5a931be88..60445cbd8d 100644 --- a/test/unit/server/lib/image/blog-icon.test.js +++ b/test/unit/server/lib/image/blog-icon.test.js @@ -16,7 +16,7 @@ describe('lib/image: blog icon', function () { } } }}); - blogIcon.getIconUrl().should.deepEqual([{relativeUrl: '/favicon.ico'}, undefined]); + blogIcon.getIconUrl().should.deepEqual([{relativeUrl: '/content/images/2017/04/my-icon.ico'}, undefined]); }); it('custom uploaded png blog icon', function () { @@ -29,7 +29,7 @@ describe('lib/image: blog icon', function () { } } }}); - blogIcon.getIconUrl().should.deepEqual([{relativeUrl: '/favicon.png'}, undefined]); + blogIcon.getIconUrl().should.deepEqual([{relativeUrl: '/content/images/size/w256h256/2017/04/my-icon.png'}, undefined]); }); it('default ico blog icon', function () { @@ -52,7 +52,7 @@ describe('lib/image: blog icon', function () { } } }}); - blogIcon.getIconUrl(true).should.deepEqual([{relativeUrl: '/favicon.ico'}, true]); + blogIcon.getIconUrl(true).should.deepEqual([{relativeUrl: '/content/images/2017/04/my-icon.ico'}, true]); }); it('custom uploaded png blog icon', function () { @@ -65,7 +65,7 @@ describe('lib/image: blog icon', function () { } } }}); - blogIcon.getIconUrl(true).should.deepEqual([{relativeUrl: '/favicon.png'}, true]); + blogIcon.getIconUrl(true).should.deepEqual([{relativeUrl: '/content/images/size/w256h256/2017/04/my-icon.png'}, true]); }); it('default ico blog icon', function () { @@ -127,40 +127,6 @@ describe('lib/image: blog icon', function () { }); }); - describe('isIcoImageType', function () { - it('returns true, if icon is .ico filetype', function () { - const blogIcon = new BlogIcon({config: {}, storageUtils: {}, urlUtils: {}, settingsCache: {}}); - blogIcon.isIcoImageType('icon.ico').should.be.true(); - }); - - it('returns false, if icon is not .ico filetype', function () { - const blogIcon = new BlogIcon({config: {}, storageUtils: {}, urlUtils: {}, settingsCache: {}}); - blogIcon.isIcoImageType('icon.png').should.be.false(); - }); - - it('returns true, if icon is .ico filetype when using settingsCache', function () { - const blogIcon = new BlogIcon({config: {}, storageUtils: {}, urlUtils: {}, settingsCache: { - get: (key) => { - if (key === 'icon') { - return 'icon.ico'; - } - } - }}); - blogIcon.isIcoImageType().should.be.true(); - }); - - it('returns false, if icon is not .ico filetype when using settingsCache', function () { - const blogIcon = new BlogIcon({config: {}, storageUtils: {}, urlUtils: {}, settingsCache: { - get: (key) => { - if (key === 'icon') { - return 'icon.png'; - } - } - }}); - blogIcon.isIcoImageType().should.be.false(); - }); - }); - describe('getIconType', function () { it('returns x-icon for ico icons', function () { const blogIcon = new BlogIcon({config: {}, storageUtils: {}, urlUtils: {}, settingsCache: {}}); diff --git a/test/unit/server/lib/mobiledoc.test.js b/test/unit/server/lib/mobiledoc.test.js index a415784119..ef8582f833 100644 --- a/test/unit/server/lib/mobiledoc.test.js +++ b/test/unit/server/lib/mobiledoc.test.js @@ -207,7 +207,7 @@ describe('lib/mobiledoc', function () { .should.eql('
Birdies
'); }); - it('does not render srcsets for non-resizable images', function () { + it('does render srcsets for animated images', function () { let mobiledoc = { version: '0.3.1', atoms: [], @@ -224,7 +224,27 @@ describe('lib/mobiledoc', function () { }; mobiledocLib.mobiledocHtmlRenderer.render(mobiledoc) - .should.eql('
'); + .should.eql('
'); + }); + + it('does not render srcsets for non-resizable images', function () { + let mobiledoc = { + version: '0.3.1', + atoms: [], + cards: [ + ['image', { + cardWidth: '', + src: '/content/images/2020/07/vector.svg', + width: 4000, + height: 2000 + }] + ], + markups: [], + sections: [[10, 0]] + }; + + mobiledocLib.mobiledocHtmlRenderer.render(mobiledoc) + .should.eql('
'); }); it('does not render srcsets when sharp is not available', function () { diff --git a/test/unit/server/web/api/middleware/normalize-image.test.js b/test/unit/server/web/api/middleware/normalize-image.test.js index 130d54e9a2..d849dcc050 100644 --- a/test/unit/server/web/api/middleware/normalize-image.test.js +++ b/test/unit/server/web/api/middleware/normalize-image.test.js @@ -69,7 +69,7 @@ describe('normalize', function () { }); }); - ['.gif', '.svg', '.svgz'].forEach(function (extension) { + ['.svg', '.svgz'].forEach(function (extension) { it(`should skip resizing when file extension is ${extension}`, function (done) { req.file.ext = extension; normalize(req, res, function () { diff --git a/yarn.lock b/yarn.lock index c886d2d3de..7add29674d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1716,10 +1716,10 @@ "@tryghost/errors" "^1.2.12" "@tryghost/request" "^0.1.26" -"@tryghost/image-transform@1.0.32": - version "1.0.32" - resolved "https://registry.yarnpkg.com/@tryghost/image-transform/-/image-transform-1.0.32.tgz#3cf32ad931100d62306e601021c2fa8752ebe1aa" - integrity sha512-nHUfTA99BtGK1mEcC/iibs4czITbCK3OiGM4WVLQ2R/t9TAgGQYok2TBdRwAaSXH3OarTZHkWAXtwMkI6T8LqA== +"@tryghost/image-transform@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@tryghost/image-transform/-/image-transform-1.1.0.tgz#23f1abc7eca781cd65e6c99d1bfc463a744b40c1" + integrity sha512-8gSTIqPOnEBSOMc1s9xR43l8U0z7gorPVbBQWMQ6ZXM3hFAT8UubLdvqpO3OBDbsi115DRxVtH4RkkZVMHBfIQ== dependencies: "@tryghost/errors" "^1.2.1" bluebird "^3.7.2"