Blog icon validations (#7893)

refs #7688

Adds an `uploads/icon/` endpoint to the api route to get a seperate entry point for blog icon validations. The blog icon validation will specifically check for images which have icon extensions (`.ico` & `.png`) and throw errors if:

- the icon file size is too big (>100kb)
- the icon is not a squaer
- the icon size is smaller than 32px
- the icon size is larger than 1000px
- the icon is not `.ico` or `.png` extension

TODOs for this PR:
- [X] get image dimensions
- [X] validate for image
	- [X] size
	- [X] form (must be square)
	- [X] type
	- [X] dimenstion (min 32px and max 1,000px)
- [X] return appropriate error messages
- [X] write tests

--------------------

TODOs for #7688:
- [X] Figure out, which favicon should be used (uploaded or default) -> #7713
- [ ] Serve and redirect the favicon for any browser requests, incl. redirects -> #7700 [WIP]
- [X] Upload favicon via `general/settings` and implement basic admin validations -> TryGhost/Ghost-Admin#397
- [X] Build server side validations -> this PR
This commit is contained in:
Aileen Nowak 2017-01-26 16:01:52 +07:00 committed by Katharina Irrgang
parent ca4f827945
commit 5c94151e14
15 changed files with 319 additions and 0 deletions

View File

@ -194,6 +194,14 @@ function apiRoutes() {
api.http(api.uploads.add)
);
apiRouter.post('/uploads/icon',
authenticatePrivate,
upload.single('uploadimage'),
validation.upload({type: 'icons'}),
validation.blogIcon(),
api.http(api.uploads.add)
);
// ## Invites
apiRouter.get('/invites', authenticatePrivate, api.http(api.invites.browse));
apiRouter.get('/invites/:id', authenticatePrivate, api.http(api.invites.read));

View File

@ -41,6 +41,10 @@
"extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz"],
"contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml"]
},
"icons": {
"extensions": [".png", ".ico"],
"contentTypes": ["image/png", "image/x-icon", "image/vnd.microsoft.icon"]
},
"db": {
"extensions": [".json", ".zip"],
"contentTypes": ["application/octet-stream", "application/json", "application/zip", "application/x-zip-compressed"]

View File

@ -0,0 +1,90 @@
var errors = require('../../errors'),
config = require('../../config'),
ICO = require('icojs'),
fs = require('fs'),
Promise = require('bluebird'),
sizeOf = require('image-size'),
i18n = require('../../i18n'),
_ = require('lodash'),
validIconSize,
getIconDimensions;
validIconSize = function validIconSize(size) {
return size / 1024 <= 100 ? true : false;
};
getIconDimensions = function getIconDimensions(icon) {
return new Promise(function getImageSize(resolve, reject) {
var arrayBuffer;
// image-size doesn't support .ico files
if (icon.name.match(/.ico$/i)) {
arrayBuffer = new Uint8Array(fs.readFileSync(icon.path)).buffer;
ICO.parse(arrayBuffer).then(function (result, error) {
if (error) {
return reject(new errors.ValidationError({message: i18n.t('errors.api.icons.couldNotGetSize', {file: icon.name, error: error.message})}));
}
// CASE: ico file contains only one size
if (result.length === 1) {
return resolve({
width: result[0].width,
height: result[0].height
});
} else {
// CASE: ico file contains multiple sizes, return only the max size
return resolve({
width: _.maxBy(result, function (w) {return w.width;}).width,
height: _.maxBy(result, function (h) {return h.height;}).height
});
}
});
} else {
sizeOf(icon.path, function (err, dimensions) {
if (err) {
return reject(new errors.ValidationError({message: i18n.t('errors.api.icons.couldNotGetSize', {file: icon.name, error: err.message})}));
}
return resolve({
width: dimensions.width,
height: dimensions.height
});
});
}
});
};
module.exports = function blogIcon() {
// we checked for a valid image file, now we need to do validations for blog icons
return function blogIconValidation(req, res, next) {
var iconExtensions = (config.get('uploads').icons && config.get('uploads').icons.extensions) || [];
// CASE: file should not be larger than 100kb
if (!validIconSize(req.file.size)) {
return next(new errors.RequestEntityTooLargeError({message: i18n.t('errors.api.icons.fileSizeTooLarge', {extensions: iconExtensions})}));
}
return getIconDimensions(req.file).then(function (dimensions) {
// save the image dimensions in new property for file
req.file.dimensions = dimensions;
// CASE: file needs to be a square
if (req.file.dimensions.width !== req.file.dimensions.height) {
return next(new errors.ValidationError({message: i18n.t('errors.api.icons.iconNotSquare', {extensions: iconExtensions})}));
}
// CASE: icon needs to be bigger than 32px
// .ico files can contain multiple sizes, we need at least a minimum of 32px (16px is ok, as long as 32px are present as well)
if (req.file.dimensions.width < 32) {
return next(new errors.ValidationError({message: i18n.t('errors.api.icons.fileTooSmall', {extensions: iconExtensions})}));
}
// CASE: icon needs to be smaller than 1000px
if (req.file.dimensions.width > 1000) {
return next(new errors.ValidationError({message: i18n.t('errors.api.icons.fileTooLarge', {extensions: iconExtensions})}));
}
next();
});
};
};

View File

@ -1 +1,2 @@
exports.upload = require('./upload');
exports.blogIcon = require('./blog-icon');

View File

@ -373,6 +373,15 @@
"missingFile": "Please select an image.",
"invalidFile": "Please select a valid image."
},
"icons": {
"missingFile": "Please select an icon.",
"fileSizeTooLarge": "Please select an icon file smaller than 100kb.",
"iconNotSquare": "The icon needs to be a square.",
"fileTooLarge": "Please select an icon file smaller than 1000px.",
"fileTooSmall": "Please select an icon file larger than 32px.",
"invalidFile": "Please select a valid icon file.",
"couldNotGetSize": "Couldn/'t get icon dimensions"
},
"users": {
"userNotFound": "User not found.",
"cannotChangeOwnRole": "You cannot change your own role.",

View File

@ -0,0 +1,206 @@
var testUtils = require('../../../utils'),
/*jshint unused:false*/
should = require('should'),
path = require('path'),
fs = require('fs-extra'),
supertest = require('supertest'),
ghost = testUtils.startGhost,
rewire = require('rewire'),
config = require('../../../../../core/server/config'),
request;
describe('Upload Icon API', function () {
var accesstoken = '',
getIconDimensions,
icons = [];
before(function (done) {
// starting ghost automatically populates the db
// TODO: prevent db init, and manage bringing up the DB with fixtures ourselves
ghost().then(function (ghostServer) {
request = supertest.agent(ghostServer.rootApp);
}).then(function () {
return testUtils.doAuth(request);
}).then(function (token) {
accesstoken = token;
done();
}).catch(done);
});
after(function (done) {
icons.forEach(function (icon) {
fs.removeSync(config.get('paths').appRoot + icon);
});
testUtils.clearData().then(function () {
done();
}).catch(done);
});
describe('success cases for icons', function () {
it('valid png', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon.png'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
icons.push(res.body);
done();
});
});
it('valid ico with multiple sizes', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_multi_sizes.ico'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
icons.push(res.body);
done();
});
});
it('valid ico with one size', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_32x_single.ico'))
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
icons.push(res.body);
done();
});
});
});
describe('error cases for icons', function () {
it('import should fail without file', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(403)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('import should fail with unsupported file', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/ghosticon.jpg'))
.expect(415)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('incorrect extension', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.set('content-type', 'image/png')
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/ghost-logo.pngx'))
.expect(415)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('import should fail, if icon is not square', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.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();
});
});
it('import should fail, if icon file size is too large', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_size_too_large.png'))
.expect(413)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('import should fail, if icon dimensions are too large', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_too_large.png'))
.expect(422)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('import should fail, if png icon dimensions are too small', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_too_small.png'))
.expect(422)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
it('import should fail, if png icon dimensions are too small', function (done) {
request.post(testUtils.API.getApiQuery('uploads/icon'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.attach('uploadimage', path.join(__dirname, '/../../../utils/fixtures/images/favicon_16x_single.ico'))
.expect(422)
.end(function (err) {
if (err) {
return done(err);
}
done();
});
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -55,6 +55,7 @@
"glob": "5.0.15",
"gscan": "0.2.0",
"html-to-text": "3.0.0",
"icojs": "0.5.0",
"image-size": "0.5.1",
"intl": "1.2.5",
"intl-messageformat": "1.3.0",