diff --git a/core/server/adapters/storage/LocalFileStorage.js b/core/server/adapters/storage/LocalFileStorage.js index da46eb9d09..71d2af0f65 100644 --- a/core/server/adapters/storage/LocalFileStorage.js +++ b/core/server/adapters/storage/LocalFileStorage.js @@ -19,6 +19,31 @@ class LocalFileStore extends StorageBase { this.storagePath = config.getContentPath('images'); } + /** + * Saves a buffer in the targetPath + * - buffer is an instance of Buffer + * - returns a Promise which returns the full URL to retrieve the data + */ + saveRaw(buffer, targetPath) { + const storagePath = path.join(this.storagePath, targetPath); + const targetDir = path.dirname(storagePath); + + return fs.mkdirs(targetDir) + .then(() => { + return fs.writeFile(storagePath, buffer); + }) + .then(() => { + // For local file system storage can use relative path so add a slash + const fullUrl = ( + urlService.utils.urlJoin('/', urlService.utils.getSubdir(), + urlService.utils.STATIC_IMAGE_URL_PREFIX, + targetPath) + ).replace(new RegExp(`\\${path.sep}`, 'g'), '/'); + + return fullUrl; + }); + } + /** * Saves the image to storage (the file system) * - image is the express image object diff --git a/core/server/lib/image/manipulator.js b/core/server/lib/image/manipulator.js index 2da4b93fd4..9051272d7b 100644 --- a/core/server/lib/image/manipulator.js +++ b/core/server/lib/image/manipulator.js @@ -61,4 +61,27 @@ const process = (options = {}) => { }); }; +const resizeImage = (originalBuffer, {width, height} = {}) => { + const sharp = require('sharp'); + return sharp(originalBuffer) + .resize(width, height, { + // CASE: dont make the image bigger than it was + withoutEnlargement: true + }) + // CASE: Automatically remove metadata and rotate based on the orientation. + .rotate() + .toBuffer() + .then((resizedBuffer) => { + return resizedBuffer.length < originalBuffer.length ? resizedBuffer : originalBuffer; + }); +}; + module.exports.process = process; +module.exports.safeResizeImage = (buffer, options) => { + try { + require('sharp'); + return resizeImage(buffer, options); + } catch (e) { + return Promise.resolve(buffer); + } +}; diff --git a/core/server/web/shared/middlewares/image/handle-image-sizes.js b/core/server/web/shared/middlewares/image/handle-image-sizes.js new file mode 100644 index 0000000000..169c3e8f73 --- /dev/null +++ b/core/server/web/shared/middlewares/image/handle-image-sizes.js @@ -0,0 +1,62 @@ +const path = require('path'); +const image = require('../../../../lib/image'); +const storage = require('../../../../adapters/storage'); +const activeTheme = require('../../../../services/themes/active'); + +const SIZE_PATH_REGEX = /^\/size\/([^/]+)\//; + +module.exports = function (req, res, next) { + if (!SIZE_PATH_REGEX.test(req.url)) { + return next(); + } + + const [sizeImageDir, requestedDimension] = req.url.match(SIZE_PATH_REGEX); + const redirectToOriginal = () => { + const url = req.originalUrl.replace(`/size/${requestedDimension}`, ''); + return res.redirect(url); + }; + + const imageSizes = activeTheme.get().config('image_sizes'); + // CASE: no image_sizes config + if (!imageSizes) { + return redirectToOriginal(); + } + + const imageDimensions = Object.keys(imageSizes).reduce((dimensions, size) => { + const {width, height} = imageSizes[size]; + const dimension = (width ? 'w' + width : '') + (height ? 'h' + height : ''); + return Object.assign({ + [dimension]: imageSizes[size] + }, dimensions); + }, {}); + + const imageDimensionConfig = imageDimensions[requestedDimension]; + // CASE: unknown dimension + if (!imageDimensionConfig || (!imageDimensionConfig.width && !imageDimensionConfig.height)) { + return redirectToOriginal(); + } + + const storageInstance = storage.getStorage(); + // CASE: unsupported storage adapter but theme is using custom image_sizes + if (typeof storageInstance.saveRaw !== 'function') { + return redirectToOriginal(); + } + + storageInstance.exists(req.url).then((exists) => { + if (exists) { + return; + } + + const originalImagePath = path.relative(sizeImageDir, req.url); + + return storageInstance.read({path: originalImagePath}) + .then((originalImageBuffer) => { + return image.manipulator.safeResizeImage(originalImageBuffer, imageDimensionConfig); + }) + .then((resizedImageBuffer) => { + return storageInstance.saveRaw(resizedImageBuffer, req.url); + }); + }).then(() => { + next(); + }).catch(next); +}; diff --git a/core/server/web/shared/middlewares/image/index.js b/core/server/web/shared/middlewares/image/index.js index f5bb85b7f3..df419227b5 100644 --- a/core/server/web/shared/middlewares/image/index.js +++ b/core/server/web/shared/middlewares/image/index.js @@ -1,5 +1,8 @@ module.exports = { get normalize() { return require('./normalize'); + }, + get handleImageSizes() { + return require('./handle-image-sizes'); } }; diff --git a/core/server/web/site/app.js b/core/server/web/site/app.js index 41ac6a2024..43ba05bb0f 100644 --- a/core/server/web/site/app.js +++ b/core/server/web/site/app.js @@ -13,6 +13,8 @@ const themeMiddleware = require('../../services/themes').middleware; const siteRoutes = require('./routes'); const shared = require('../shared'); +const STATIC_IMAGE_URL_PREFIX = `/${urlService.utils.STATIC_IMAGE_URL_PREFIX}`; + let router; function SiteRouter(req, res, next) { @@ -58,7 +60,7 @@ module.exports = function setupSiteApp(options = {}) { siteApp.use(shared.middlewares.servePublicFile('public/404-ghost.png', 'png', constants.ONE_HOUR_S)); // Serve blog images using the storage adapter - siteApp.use('/' + urlService.utils.STATIC_IMAGE_URL_PREFIX, storage.getStorage().serve()); + siteApp.use(STATIC_IMAGE_URL_PREFIX, shared.middlewares.image.handleImageSizes, storage.getStorage().serve()); // @TODO find this a better home // We do this here, at the top level, because helpers require so much stuff. diff --git a/core/test/unit/web/middleware/image/handle-image-sizes_spec.js b/core/test/unit/web/middleware/image/handle-image-sizes_spec.js new file mode 100644 index 0000000000..60c46f3bb2 --- /dev/null +++ b/core/test/unit/web/middleware/image/handle-image-sizes_spec.js @@ -0,0 +1,44 @@ +const should = require('should'); +const handleImageSizes = require('../../../../../server/web/shared/middlewares/image/handle-image-sizes.js'); + +// @TODO make these tests lovely and non specific to implementation +describe('handleImageSizes middleware', function () { + it('calls next immediately if the url does not match /size/something/', function (done) { + const fakeReq = { + url: '/size/something' + }; + // CASE: second thing middleware does is try to match to a regex + fakeReq.url.match = function () { + throw new Error('Should have exited immediately'); + }; + handleImageSizes(fakeReq, {}, function next() { + done(); + }); + }); + + it('calls next immediately if the url does not match /size/something/', function (done) { + const fakeReq = { + url: '/url/whatever/' + }; + // CASE: second thing middleware does is try to match to a regex + fakeReq.url.match = function () { + throw new Error('Should have exited immediately'); + }; + handleImageSizes(fakeReq, {}, function next() { + done(); + }); + }); + + it('calls next immediately if the url does not match /size/something/', function (done) { + const fakeReq = { + url: '/size//' + }; + // CASE: second thing middleware does is try to match to a regex + fakeReq.url.match = function () { + throw new Error('Should have exited immediately'); + }; + handleImageSizes(fakeReq, {}, function next() { + done(); + }); + }); +});