✅ 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
|
@ -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));
|
||||
|
|
|
@ -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"]
|
||||
|
|
90
core/server/middleware/validation/blog-icon.js
Normal 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();
|
||||
});
|
||||
};
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
exports.upload = require('./upload');
|
||||
exports.blogIcon = require('./blog-icon');
|
||||
|
|
|
@ -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.",
|
||||
|
|
206
core/test/functional/routes/api/upload_icon_spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
BIN
core/test/utils/fixtures/images/favicon.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
core/test/utils/fixtures/images/favicon_16x_single.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
core/test/utils/fixtures/images/favicon_32x_single.ico
Executable file
After Width: | Height: | Size: 4.2 KiB |
BIN
core/test/utils/fixtures/images/favicon_multi_sizes.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
core/test/utils/fixtures/images/favicon_not_square.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
core/test/utils/fixtures/images/favicon_size_too_large.png
Normal file
After Width: | Height: | Size: 241 KiB |
BIN
core/test/utils/fixtures/images/favicon_too_large.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
core/test/utils/fixtures/images/favicon_too_small.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
|
@ -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",
|
||||
|
|