mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Added new endpoint to upload square profile images with dimension validation (#9862)
refs #8576 - adds new API endpoint `/uploads/profile-image` for uploading profile images - new validation which fails with error message if uploaded image is not square - Renamed getImageSizeFromFilePath to getImageSizeFromStoragePath, because it's more explicit - Add new getImageSizeFromPath method, which is used in the new dimensions middleware - Ensure we use the sharp middleware to auto-resize the uploaded profile pictures - Ensure the new route get's added to v2 While this makes sure all future profile images uploaded are square, this doesn’t affect any existing non-square profile image. Needs more thought on how to handle existing non-square profile images for the purpose of making theming easier in future.
This commit is contained in:
parent
214d682ea3
commit
39485d17c0
9 changed files with 164 additions and 44 deletions
|
@ -1,16 +1,17 @@
|
|||
var debug = require('ghost-ignition').debug('utils:image-size'),
|
||||
sizeOf = require('image-size'),
|
||||
url = require('url'),
|
||||
Promise = require('bluebird'),
|
||||
_ = require('lodash'),
|
||||
request = require('../request'),
|
||||
urlService = require('../../services/url'),
|
||||
common = require('../common'),
|
||||
config = require('../../config'),
|
||||
storage = require('../../adapters/storage'),
|
||||
storageUtils = require('../../adapters/storage/utils'),
|
||||
getImageSizeFromUrl,
|
||||
getImageSizeFromFilePath;
|
||||
const debug = require('ghost-ignition').debug('utils:image-size');
|
||||
const sizeOf = require('image-size');
|
||||
const url = require('url');
|
||||
const Promise = require('bluebird');
|
||||
const _ = require('lodash');
|
||||
const request = require('../request');
|
||||
const urlService = require('../../services/url');
|
||||
const common = require('../common');
|
||||
const config = require('../../config');
|
||||
const storage = require('../../adapters/storage');
|
||||
const storageUtils = require('../../adapters/storage/utils');
|
||||
let getImageSizeFromUrl;
|
||||
let getImageSizeFromStoragePath;
|
||||
let getImageSizeFromPath;
|
||||
|
||||
/**
|
||||
* @description processes the Buffer result of an image file
|
||||
|
@ -18,10 +19,10 @@ var debug = require('ghost-ignition').debug('utils:image-size'),
|
|||
* @returns {Object} dimensions
|
||||
*/
|
||||
function fetchDimensionsFromBuffer(options) {
|
||||
var buffer = options.buffer,
|
||||
imagePath = options.imagePath,
|
||||
imageObject = {},
|
||||
dimensions;
|
||||
const buffer = options.buffer;
|
||||
const imagePath = options.imagePath;
|
||||
const imageObject = {};
|
||||
let dimensions;
|
||||
|
||||
imageObject.url = imagePath;
|
||||
|
||||
|
@ -33,10 +34,10 @@ function fetchDimensionsFromBuffer(options) {
|
|||
// CASE: `.ico` files might have multiple images and therefore multiple sizes.
|
||||
// We return the largest size found (image-size default is the first size found)
|
||||
if (dimensions.images) {
|
||||
dimensions.width = _.maxBy(dimensions.images, function (w) {
|
||||
dimensions.width = _.maxBy(dimensions.images, (w) => {
|
||||
return w.width;
|
||||
}).width;
|
||||
dimensions.height = _.maxBy(dimensions.images, function (h) {
|
||||
dimensions.height = _.maxBy(dimensions.images, (h) => {
|
||||
return h.height;
|
||||
}).height;
|
||||
}
|
||||
|
@ -67,7 +68,7 @@ function fetchDimensionsFromBuffer(options) {
|
|||
// if the dimensions can be fetched, and rejects with error, if not.
|
||||
// ***
|
||||
// In case we get a locally stored image, which is checked withing the `isLocalImage`
|
||||
// function we switch to read the image from the local file storage with `getImageSizeFromFilePath`.
|
||||
// function we switch to read the image from the local file storage with `getImageSizeFromStoragePath`.
|
||||
// In case the image is not stored locally and is missing the protocol (like //www.gravatar.com/andsoon),
|
||||
// we add the protocol and use urlFor() to get the absolute URL.
|
||||
// If the request fails or image-size is not able to read the file, we reject with error.
|
||||
|
@ -77,14 +78,14 @@ function fetchDimensionsFromBuffer(options) {
|
|||
* @param {String} imagePath as URL
|
||||
* @returns {Promise<Object>} imageObject or error
|
||||
*/
|
||||
getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) {
|
||||
var requestOptions,
|
||||
parsedUrl,
|
||||
timeout = config.get('times:getImageSizeTimeoutInMS') || 10000;
|
||||
getImageSizeFromUrl = (imagePath) => {
|
||||
let requestOptions;
|
||||
let parsedUrl;
|
||||
let timeout = config.get('times:getImageSizeTimeoutInMS') || 10000;
|
||||
|
||||
if (storageUtils.isLocalImage(imagePath)) {
|
||||
// don't make a request for a locally stored image
|
||||
return getImageSizeFromFilePath(imagePath);
|
||||
return getImageSizeFromStoragePath(imagePath);
|
||||
}
|
||||
|
||||
// CASE: pre 1.0 users were able to use an asset path for their blog logo
|
||||
|
@ -113,7 +114,7 @@ getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) {
|
|||
return request(
|
||||
imagePath,
|
||||
requestOptions
|
||||
).then(function (response) {
|
||||
).then((response) => {
|
||||
debug('Image fetched (URL):', imagePath);
|
||||
|
||||
return fetchDimensionsFromBuffer({
|
||||
|
@ -122,21 +123,21 @@ getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) {
|
|||
// value will be used as the URL for structured data
|
||||
imagePath: parsedUrl.href
|
||||
});
|
||||
}).catch({code: 'URL_MISSING_INVALID'}, function (err) {
|
||||
}).catch({code: 'URL_MISSING_INVALID'}, (err) => {
|
||||
return Promise.reject(new common.errors.InternalServerError({
|
||||
message: err.message,
|
||||
code: 'IMAGE_SIZE_URL',
|
||||
statusCode: err.statusCode,
|
||||
context: err.url || imagePath
|
||||
}));
|
||||
}).catch({code: 'ETIMEDOUT'}, {statusCode: 408}, function (err) {
|
||||
}).catch({code: 'ETIMEDOUT'}, {statusCode: 408}, (err) => {
|
||||
return Promise.reject(new common.errors.InternalServerError({
|
||||
message: 'Request timed out.',
|
||||
code: 'IMAGE_SIZE_URL',
|
||||
statusCode: err.statusCode,
|
||||
context: err.url || imagePath
|
||||
}));
|
||||
}).catch({code: 'ENOENT'}, {statusCode: 404}, function (err) {
|
||||
}).catch({code: 'ENOENT'}, {statusCode: 404}, (err) => {
|
||||
return Promise.reject(new common.errors.NotFoundError({
|
||||
message: 'Image not found.',
|
||||
code: 'IMAGE_SIZE_URL',
|
||||
|
@ -163,7 +164,7 @@ getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) {
|
|||
// ***
|
||||
// Takes the url or filepath of the image and reads it form the local
|
||||
// file storage.
|
||||
// getImageSizeFromFilePath returns an Object like this
|
||||
// getImageSizeFromStoragePath returns an Object like this
|
||||
// {
|
||||
// height: 50,
|
||||
// url: 'http://myblog.com/images/cat.jpg',
|
||||
|
@ -175,8 +176,8 @@ getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) {
|
|||
* @param {String} imagePath
|
||||
* @returns {object} imageObject or error
|
||||
*/
|
||||
getImageSizeFromFilePath = function getImageSizeFromFilePath(imagePath) {
|
||||
var filePath;
|
||||
getImageSizeFromStoragePath = (imagePath) => {
|
||||
let filePath;
|
||||
|
||||
imagePath = urlService.utils.urlFor('image', {image: imagePath}, true);
|
||||
|
||||
|
@ -185,7 +186,7 @@ getImageSizeFromFilePath = function getImageSizeFromFilePath(imagePath) {
|
|||
|
||||
return storage.getStorage()
|
||||
.read({path: filePath})
|
||||
.then(function readFile(buf) {
|
||||
.then((buf) => {
|
||||
debug('Image fetched (storage):', filePath);
|
||||
|
||||
return fetchDimensionsFromBuffer({
|
||||
|
@ -194,7 +195,7 @@ getImageSizeFromFilePath = function getImageSizeFromFilePath(imagePath) {
|
|||
// value will be used as the URL for structured data
|
||||
imagePath: imagePath
|
||||
});
|
||||
}).catch({code: 'ENOENT'}, function (err) {
|
||||
}).catch({code: 'ENOENT'}, (err) => {
|
||||
return Promise.reject(new common.errors.NotFoundError({
|
||||
message: err.message,
|
||||
code: 'IMAGE_SIZE_STORAGE',
|
||||
|
@ -205,7 +206,7 @@ getImageSizeFromFilePath = function getImageSizeFromFilePath(imagePath) {
|
|||
reqFilePath: filePath
|
||||
}
|
||||
}));
|
||||
}).catch(function (err) {
|
||||
}).catch((err) => {
|
||||
if (common.errors.utils.isIgnitionError(err)) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
@ -223,5 +224,46 @@ getImageSizeFromFilePath = function getImageSizeFromFilePath(imagePath) {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Supported formats of https://github.com/image-size/image-size:
|
||||
* BMP, GIF, JPEG, PNG, PSD, TIFF, WebP, SVG, ICO
|
||||
* Get dimensions for a file from its real file storage path
|
||||
* Always returns {object} getImageDimensions
|
||||
* @param {string} path
|
||||
* @returns {Promise<Object>} getImageDimensions
|
||||
* @description Takes a file path and returns width and height.
|
||||
*/
|
||||
getImageSizeFromPath = (path) => {
|
||||
return new Promise(function getSize(resolve, reject) {
|
||||
let dimensions;
|
||||
|
||||
try {
|
||||
dimensions = sizeOf(path);
|
||||
|
||||
if (dimensions.images) {
|
||||
dimensions.width = _.maxBy(dimensions.images, (w) => {
|
||||
return w.width;
|
||||
}).width;
|
||||
dimensions.height = _.maxBy(dimensions.images, (h) => {
|
||||
return h.height;
|
||||
}).height;
|
||||
}
|
||||
|
||||
return resolve({
|
||||
width: dimensions.width,
|
||||
height: dimensions.height
|
||||
});
|
||||
} catch (err) {
|
||||
return reject(new common.errors.ValidationError({
|
||||
message: common.i18n.t('errors.utils.images.invalidDimensions', {
|
||||
file: path,
|
||||
error: err.message
|
||||
})
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.getImageSizeFromUrl = getImageSizeFromUrl;
|
||||
module.exports.getImageSizeFromFilePath = getImageSizeFromFilePath;
|
||||
module.exports.getImageSizeFromStoragePath = getImageSizeFromStoragePath;
|
||||
module.exports.getImageSizeFromPath = getImageSizeFromPath;
|
||||
|
|
|
@ -124,6 +124,9 @@
|
|||
"blogIcon": {
|
||||
"error": "Could not fetch icon dimensions."
|
||||
},
|
||||
"images": {
|
||||
"invalidDimensions": "Could not fetch image dimensions."
|
||||
},
|
||||
"redirectsWrongFormat": "Incorrect redirects file format."
|
||||
},
|
||||
"config": {
|
||||
|
@ -388,7 +391,8 @@
|
|||
},
|
||||
"images": {
|
||||
"missingFile": "Please select an image.",
|
||||
"invalidFile": "Please select a valid image."
|
||||
"invalidFile": "Please select a valid image.",
|
||||
"isNotSquare": "Please select a valid image file with square dimensions."
|
||||
},
|
||||
"icons": {
|
||||
"missingFile": "Please select an icon.",
|
||||
|
|
|
@ -186,6 +186,15 @@ module.exports = function apiRoutes() {
|
|||
api.http(api.uploads.add)
|
||||
);
|
||||
|
||||
apiRouter.post('/uploads/profile-image',
|
||||
mw.authenticatePrivate,
|
||||
upload.single('uploadimage'),
|
||||
validation.upload({type: 'images'}),
|
||||
validation.profileImage,
|
||||
image.normalize,
|
||||
api.http(api.uploads.add)
|
||||
);
|
||||
|
||||
apiRouter.post('/db/backup', mw.authenticateClient('Ghost Backup'), api.http(api.db.backupContent));
|
||||
|
||||
apiRouter.post('/uploads/icon',
|
||||
|
|
|
@ -177,6 +177,15 @@ module.exports = function apiRoutes() {
|
|||
api.http(api.uploads.add)
|
||||
);
|
||||
|
||||
router.post('/uploads/profile-image',
|
||||
mw.authenticatePrivate,
|
||||
upload.single('uploadimage'),
|
||||
shared.middlewares.validation.upload({type: 'images'}),
|
||||
shared.middlewares.validation.profileImage,
|
||||
shared.middlewares.image.normalize,
|
||||
api.http(api.uploads.add)
|
||||
);
|
||||
|
||||
router.post('/db/backup', mw.authenticateClient('Ghost Backup'), api.http(api.db.backupContent));
|
||||
|
||||
router.post('/uploads/icon',
|
||||
|
|
|
@ -5,5 +5,9 @@ module.exports = {
|
|||
|
||||
get blogIcon() {
|
||||
return require('./blog-icon');
|
||||
},
|
||||
|
||||
get profileImage() {
|
||||
return require('./profile-image');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
const common = require('../../../../lib/common');
|
||||
const imageLib = require('../../../../lib/image');
|
||||
|
||||
module.exports = function profileImage(req, res, next) {
|
||||
// we checked for a valid image file, now we need to do validations for profile image
|
||||
imageLib.imageSize.getImageSizeFromPath(req.file.path).then((response) => {
|
||||
// save the image dimensions in new property for file
|
||||
req.file.dimensions = response;
|
||||
|
||||
// CASE: file needs to be a square
|
||||
if (req.file.dimensions.width !== req.file.dimensions.height) {
|
||||
return next(new common.errors.ValidationError({
|
||||
message: common.i18n.t('errors.api.images.isNotSquare')
|
||||
}));
|
||||
}
|
||||
|
||||
next();
|
||||
}).catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
};
|
|
@ -80,6 +80,22 @@ describe('Upload API', function () {
|
|||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('valid profile image', function (done) {
|
||||
request.post(testUtils.API.getApiQuery('uploads/profile-image'))
|
||||
.set('Authorization', 'Bearer ' + accesstoken)
|
||||
.expect('Content-Type', /json/)
|
||||
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/loadingcat_square.gif'))
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
images.push(res.body);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', function () {
|
||||
|
@ -128,5 +144,20 @@ describe('Upload API', function () {
|
|||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('import should fail if profile image is not square', function (done) {
|
||||
request.post(testUtils.API.getApiQuery('uploads/profile-image'))
|
||||
.set('Authorization', 'Bearer ' + accesstoken)
|
||||
.expect('Content-Type', /json/)
|
||||
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_not_square.png'))
|
||||
.expect(422)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,7 +33,7 @@ describe('lib/image: image size', function () {
|
|||
|
||||
it('[success] should have an image size function', function () {
|
||||
should.exist(imageSize.getImageSizeFromUrl);
|
||||
should.exist(imageSize.getImageSizeFromFilePath);
|
||||
should.exist(imageSize.getImageSizeFromStoragePath);
|
||||
});
|
||||
|
||||
describe('getImageSizeFromUrl', function () {
|
||||
|
@ -413,7 +413,7 @@ describe('lib/image: image size', function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getImageSizeFromFilePath', function () {
|
||||
describe('getImageSizeFromStoragePath', function () {
|
||||
it('[success] should return image dimensions for locally stored images', function (done) {
|
||||
var url = '/content/images/ghost-logo.png',
|
||||
urlForStub,
|
||||
|
@ -432,7 +432,7 @@ describe('lib/image: image size', function () {
|
|||
urlGetSubdirStub = sandbox.stub(urlService.utils, 'getSubdir');
|
||||
urlGetSubdirStub.returns('');
|
||||
|
||||
result = imageSize.getImageSizeFromFilePath(url).then(function (res) {
|
||||
result = imageSize.getImageSizeFromStoragePath(url).then(function (res) {
|
||||
should.exist(res);
|
||||
should.exist(res.width);
|
||||
res.width.should.be.equal(expectedImageObject.width);
|
||||
|
@ -462,7 +462,7 @@ describe('lib/image: image size', function () {
|
|||
urlGetSubdirStub = sandbox.stub(urlService.utils, 'getSubdir');
|
||||
urlGetSubdirStub.returns('/blog');
|
||||
|
||||
result = imageSize.getImageSizeFromFilePath(url).then(function (res) {
|
||||
result = imageSize.getImageSizeFromStoragePath(url).then(function (res) {
|
||||
should.exist(res);
|
||||
should.exist(res.width);
|
||||
res.width.should.be.equal(expectedImageObject.width);
|
||||
|
@ -492,7 +492,7 @@ describe('lib/image: image size', function () {
|
|||
urlGetSubdirStub = sandbox.stub(urlService.utils, 'getSubdir');
|
||||
urlGetSubdirStub.returns('');
|
||||
|
||||
result = imageSize.getImageSizeFromFilePath(url).then(function (res) {
|
||||
result = imageSize.getImageSizeFromStoragePath(url).then(function (res) {
|
||||
should.exist(res);
|
||||
should.exist(res.width);
|
||||
res.width.should.be.equal(expectedImageObject.width);
|
||||
|
@ -516,7 +516,7 @@ describe('lib/image: image size', function () {
|
|||
urlGetSubdirStub = sandbox.stub(urlService.utils, 'getSubdir');
|
||||
urlGetSubdirStub.returns('');
|
||||
|
||||
result = imageSize.getImageSizeFromFilePath(url)
|
||||
result = imageSize.getImageSizeFromStoragePath(url)
|
||||
.catch(function (err) {
|
||||
should.exist(err);
|
||||
(err instanceof common.errors.NotFoundError).should.eql(true);
|
||||
|
@ -540,7 +540,7 @@ describe('lib/image: image size', function () {
|
|||
urlGetSubdirStub = sandbox.stub(urlService.utils, 'getSubdir');
|
||||
urlGetSubdirStub.returns('');
|
||||
|
||||
result = imageSize.getImageSizeFromFilePath(url)
|
||||
result = imageSize.getImageSizeFromStoragePath(url)
|
||||
.catch(function (err) {
|
||||
should.exist(err);
|
||||
err.error.should.be.equal('image-size could not find dimensions');
|
||||
|
|
BIN
core/test/utils/fixtures/images/loadingcat_square.gif
Normal file
BIN
core/test/utils/fixtures/images/loadingcat_square.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 9 KiB |
Loading…
Add table
Reference in a new issue