Skip to content

Commit

Permalink
✅ Blog icon validations (#7893)
Browse files Browse the repository at this point in the history
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/Admin#397
- [X] Build server side validations -> this PR
  • Loading branch information
aileen authored and kirrg001 committed Jan 26, 2017
1 parent ca4f827 commit 5c94151
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 0 deletions.
8 changes: 8 additions & 0 deletions core/server/api/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
4 changes: 4 additions & 0 deletions core/server/config/overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
90 changes: 90 additions & 0 deletions core/server/middleware/validation/blog-icon.js
Original file line number Diff line number Diff line change
@@ -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();
});
};
};
1 change: 1 addition & 0 deletions core/server/middleware/validation/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
exports.upload = require('./upload');
exports.blogIcon = require('./blog-icon');
9 changes: 9 additions & 0 deletions core/server/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
206 changes: 206 additions & 0 deletions core/test/functional/routes/api/upload_icon_spec.js
Original file line number Diff line number Diff line change
@@ -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 added core/test/utils/fixtures/images/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 5c94151

Please sign in to comment.