Skip to content

Commit

Permalink
✨ Supported dynamic image resizing for LocalFileStorage(#10184)
Browse files Browse the repository at this point in the history
refs #10181 

* Added initial handleImageSizes middleware

* Implemented saveRaw method on local file storage

* Wired up handleImageSizes middleware

* Implemented delete for LocalFileStorage

* Removed delete method from theme Storage class

* Deleted sizes directory when theme is activated

* Ensured that smaller images are not enlarged

* Renamed sizes -> size

* Exited middleware as early as possible

* Called getStorage as late as possible

* Updated image sizes middleware to handle dimension paths

* Revert "Deleted sizes directory when theme is activated"

This reverts commit 9204dfcc73a6a79d597dbf23651817bcbfc59991.

* Revert "Removed delete method from theme Storage class"

This reverts commit b45fdb405a05faeaf4bd87e977c4ac64ff96b057.

* Revert "Implemented delete for LocalFileStorage"

This reverts commit a587cd6bae45b68a293b2d5cfd9b7705a29e7bfa.

* Fixed typo

Co-Authored-By: allouis <fabien@allou.is>

* Redirected to original image if no image_sizes config

* Refactored redirection because rule of three

* Updated comments

* Added rubbish tests

* Added @todo comment for handleImageSizes tests

* Added safeResizeImage method to image manipulator

* Used image manipulator lib in image_size middleware
  • Loading branch information
allouis committed Dec 13, 2018
1 parent 2860dde commit 7099dd4
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 1 deletion.
25 changes: 25 additions & 0 deletions core/server/adapters/storage/LocalFileStorage.js
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions core/server/lib/image/manipulator.js
Expand Up @@ -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);
}
};
62 changes: 62 additions & 0 deletions 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);
};
3 changes: 3 additions & 0 deletions 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');
}
};
4 changes: 3 additions & 1 deletion core/server/web/site/app.js
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions 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();
});
});
});

0 comments on commit 7099dd4

Please sign in to comment.