diff --git a/install/package.json b/install/package.json index 461f4be496..8fab478da4 100644 --- a/install/package.json +++ b/install/package.json @@ -51,7 +51,6 @@ "helmet": "^3.11.0", "html-to-text": "^4.0.0", "ipaddr.js": "^1.5.4", - "jimp": "0.5.0", "jquery": "^3.2.1", "jsesc": "2.5.1", "json-2-csv": "^2.1.2", @@ -97,6 +96,7 @@ "sanitize-html": "^1.16.3", "semver": "^5.4.1", "serve-favicon": "^2.4.5", + "sharp": "0.20.8", "sitemap": "^1.13.0", "socket.io": "2.1.1", "socket.io-adapter-cluster": "^1.0.1", diff --git a/public/language/en-GB/admin/settings/uploads.json b/public/language/en-GB/admin/settings/uploads.json index e0382bd8da..83a0c73428 100644 --- a/public/language/en-GB/admin/settings/uploads.json +++ b/public/language/en-GB/admin/settings/uploads.json @@ -10,6 +10,10 @@ "resize-image-quality-help": "Use a lower quality setting to reduce the file size of resized images.", "max-file-size": "Maximum File Size (in KiB)", "max-file-size-help": "(in kibibytes, default: 2048 KiB)", + "reject-image-width": "Maximum Image Width (in pixels)", + "reject-image-width-help": "Images wider than this value will be rejected.", + "reject-image-height": "Maximum Image Height (in pixels)", + "reject-image-height-help": "Images taller than this value will be rejected.", "allow-topic-thumbnails": "Allow users to upload topic thumbnails", "topic-thumb-size": "Topic Thumb Size", "allowed-file-extensions": "Allowed File Extensions", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index e3cfab6efa..6308bb6183 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -105,6 +105,7 @@ "invalid-image-type": "Invalid image type. Allowed types are: %1", "invalid-image-extension": "Invalid image extension", "invalid-file-type": "Invalid file type. Allowed types are: %1", + "invalid-image-dimensions": "Image dimensions are too big", "group-name-too-short": "Group name too short", "group-name-too-long": "Group name too long", diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index 21dbbbca99..c7952ee98c 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -5,7 +5,6 @@ var async = require('async'); var nconf = require('nconf'); var mime = require('mime'); var fs = require('fs'); -var jimp = require('jimp'); var meta = require('../../meta'); var posts = require('../../posts'); @@ -177,16 +176,13 @@ uploadsController.uploadTouchIcon = function (req, res, next) { } // Resize the image into squares for use as touch icons at various DPIs - async.each(sizes, function (size, next) { - async.series([ - async.apply(file.saveFileToLocal, 'touchicon-' + size + '.png', 'system', uploadedFile.path), - async.apply(image.resizeImage, { - path: path.join(nconf.get('upload_path'), 'system', 'touchicon-' + size + '.png'), - extension: 'png', - width: size, - height: size, - }), - ], next); + async.eachSeries(sizes, function (size, next) { + image.resizeImage({ + path: uploadedFile.path, + target: path.join(nconf.get('upload_path'), 'system', 'touchicon-' + size + '.png'), + width: size, + height: size, + }, next); }, function (err) { file.delete(uploadedFile.path); @@ -291,7 +287,6 @@ function uploadImage(filename, folder, uploadedFile, req, res, next) { async.apply(image.resizeImage, { path: uploadedFile.path, target: uploadPath, - extension: 'png', height: 50, }), async.apply(meta.configs.set, 'brand:emailLogo', path.join(nconf.get('upload_url'), 'system/site-logo-x50.png')), @@ -299,15 +294,16 @@ function uploadImage(filename, folder, uploadedFile, req, res, next) { next(err, imageData); }); } else if (path.basename(filename, path.extname(filename)) === 'og:image' && folder === 'system') { - jimp.read(imageData.path).then(function (image) { + image.size(imageData.path, function (err, size) { + if (err) { + next(err); + } meta.configs.setMultiple({ - 'og:image:height': image.bitmap.height, - 'og:image:width': image.bitmap.width, + 'og:image:width': size.width, + 'og:image:height': size.height, }, function (err) { next(err, imageData); }); - }).catch(function (err) { - next(err); }); } else { setImmediate(next, null, imageData); diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index f837d58529..20431d5e3c 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -25,7 +25,7 @@ uploadsController.upload = function (req, res, filesIterator) { files = files[0]; } - async.map(files, filesIterator, function (err, images) { + async.mapSeries(files, filesIterator, function (err, images) { deleteTempFiles(files); if (err) { @@ -56,6 +56,9 @@ function uploadAsImage(req, uploadedFile, callback) { if (!canUpload) { return next(new Error('[[error:no-privileges]]')); } + image.checkDimensions(uploadedFile.path, next); + }, + function (next) { if (plugins.hasListeners('filter:uploadImage')) { return plugins.fireHook('filter:uploadImage', { image: uploadedFile, @@ -113,14 +116,9 @@ function resizeImage(fileObj, callback) { return callback(null, fileObj); } - var dirname = path.dirname(fileObj.path); - var extname = path.extname(fileObj.path); - var basename = path.basename(fileObj.path, extname); - image.resizeImage({ path: fileObj.path, - target: path.join(dirname, basename + '-resized' + extname), - extension: extname, + target: file.appendToFileName(fileObj.path, '-resized'), width: parseInt(meta.config.maximumImageWidth, 10) || 760, quality: parseInt(meta.config.resizeImageQuality, 10) || 60, }, next); @@ -157,7 +155,6 @@ uploadsController.uploadThumb = function (req, res, next) { var size = parseInt(meta.config.topicThumbSize, 10) || 120; image.resizeImage({ path: uploadedFile.path, - extension: path.extname(uploadedFile.name), width: size, height: size, }, next); diff --git a/src/file.js b/src/file.js index 3f0aaabcf0..a4dd98212d 100644 --- a/src/file.js +++ b/src/file.js @@ -4,7 +4,7 @@ var fs = require('fs'); var nconf = require('nconf'); var path = require('path'); var winston = require('winston'); -var jimp = require('jimp'); +var sharp = require('sharp'); var mkdirp = require('mkdirp'); var mime = require('mime'); var graceful = require('graceful-fs'); @@ -107,12 +107,22 @@ file.isFileTypeAllowed = function (path, callback) { }); } - // Attempt to read the file, if it passes, file type is allowed - jimp.read(path, function (err) { + sharp(path, { + failOnError: true, + }).metadata(function (err) { callback(err); }); }; +// https://stackoverflow.com/a/31205878/583363 +file.appendToFileName = function (filename, string) { + var dotIndex = filename.lastIndexOf('.'); + if (dotIndex === -1) { + return filename + string; + } + return filename.substring(0, dotIndex) + string + filename.substring(dotIndex); +}; + file.allowedExtensions = function () { var meta = require('./meta'); var allowedExtensions = (meta.config.allowedFileExtensions || '').trim(); @@ -163,7 +173,7 @@ file.existsSync = function (path) { file.delete = function (path, callback) { callback = callback || function () {}; if (!path) { - return callback(); + return setImmediate(callback); } fs.unlink(path, function (err) { if (err) { diff --git a/src/groups/cover.js b/src/groups/cover.js index 289ee16302..ae2f86c90f 100644 --- a/src/groups/cover.js +++ b/src/groups/cover.js @@ -2,7 +2,6 @@ var async = require('async'); var path = require('path'); -var Jimp = require('jimp'); var mime = require('mime'); var db = require('../database'); @@ -27,7 +26,6 @@ module.exports = function (Groups) { var tempPath = data.file ? data.file : ''; var url; var type = data.file ? mime.getType(data.file) : 'image/png'; - async.waterfall([ function (next) { if (tempPath) { @@ -49,7 +47,10 @@ module.exports = function (Groups) { Groups.setGroupField(data.groupName, 'cover:url', url, next); }, function (next) { - resizeCover(tempPath, next); + image.resizeImage({ + path: tempPath, + width: 358, + }, next); }, function (next) { uploadsController.uploadGroupCover(uid, { @@ -74,22 +75,6 @@ module.exports = function (Groups) { }); }; - function resizeCover(path, callback) { - async.waterfall([ - function (next) { - new Jimp(path, next); - }, - function (image, next) { - image.resize(358, Jimp.AUTO, next); - }, - function (image, next) { - image.write(path, next); - }, - ], function (err) { - callback(err); - }); - } - Groups.removeCover = function (data, callback) { db.deleteObjectFields('group:' + data.groupName, ['cover:url', 'cover:thumb:url', 'cover:position'], callback); }; diff --git a/src/image.js b/src/image.js index f99a73e3bc..afb4427590 100644 --- a/src/image.js +++ b/src/image.js @@ -3,9 +3,14 @@ var os = require('os'); var fs = require('fs'); var path = require('path'); -var Jimp = require('jimp'); -var async = require('async'); var crypto = require('crypto'); +var async = require('async'); + +var sharp = require('sharp'); +if (os.platform() === 'win32') { + // https://github.com/lovell/sharp/issues/1259 + sharp.cache(false); +} var file = require('./file'); var plugins = require('./plugins'); @@ -17,7 +22,6 @@ image.resizeImage = function (data, callback) { plugins.fireHook('filter:image.resize', { path: data.path, target: data.target, - extension: data.extension, width: data.width, height: data.height, quality: data.quality, @@ -25,64 +29,25 @@ image.resizeImage = function (data, callback) { callback(err); }); } else { - new Jimp(data.path, function (err, image) { - if (err) { - return callback(err); - } - - var w = image.bitmap.width; - var h = image.bitmap.height; - var origRatio = w / h; - var desiredRatio = data.width && data.height ? data.width / data.height : origRatio; - var x = 0; - var y = 0; - var crop; - - if (image._exif && image._exif.tags && image._exif.tags.Orientation) { - image.exifRotate(); - } - - if (origRatio !== desiredRatio) { - if (desiredRatio > origRatio) { - desiredRatio = 1 / desiredRatio; - } - if (origRatio >= 1) { - y = 0; // height is the smaller dimension here - x = Math.floor((w / 2) - (h * desiredRatio / 2)); - crop = async.apply(image.crop.bind(image), x, y, h * desiredRatio, h); - } else { - x = 0; // width is the smaller dimension here - y = Math.floor((h / 2) - (w * desiredRatio / 2)); - crop = async.apply(image.crop.bind(image), x, y, w, w * desiredRatio); + async.waterfall([ + function (next) { + fs.readFile(data.path, next); + }, + function (buffer, next) { + var sharpImage = sharp(buffer, { + failOnError: true, + }); + sharpImage.rotate(); // auto-orients based on exif data + sharpImage.resize(data.hasOwnProperty('width') ? data.width : null, data.hasOwnProperty('height') ? data.height : null); + + if (data.quality) { + sharpImage.jpeg({ quality: data.quality }); } - } else { - // Simple resize given either width, height, or both - crop = async.apply(setImmediate); - } - - async.waterfall([ - crop, - function (_image, next) { - if (typeof _image === 'function' && !next) { - next = _image; - _image = image; - } - - if ((data.width && data.height) || (w > data.width) || (h > data.height)) { - _image.resize(data.width || Jimp.AUTO, data.height || Jimp.AUTO, next); - } else { - next(null, image); - } - }, - function (image, next) { - if (data.quality) { - image.quality(data.quality); - } - image.write(data.target || data.path, next); - }, - ], function (err) { - callback(err); - }); + + sharpImage.toFile(data.target || data.path, next); + }, + ], function (err) { + callback(err); }); } }; @@ -91,21 +56,13 @@ image.normalise = function (path, extension, callback) { if (plugins.hasListeners('filter:image.normalise')) { plugins.fireHook('filter:image.normalise', { path: path, - extension: extension, }, function (err) { callback(err, path + '.png'); }); } else { - async.waterfall([ - function (next) { - new Jimp(path, next); - }, - function (image, next) { - image.write(path + '.png', function (err) { - next(err, path + '.png'); - }); - }, - ], callback); + sharp(path, { failOnError: true }).png().toFile(path + '.png', function (err) { + callback(err, path + '.png'); + }); } }; @@ -114,15 +71,32 @@ image.size = function (path, callback) { plugins.fireHook('filter:image.size', { path: path, }, function (err, image) { - callback(err, image); + callback(err, image ? { width: image.width, height: image.height } : undefined); }); } else { - new Jimp(path, function (err, data) { - callback(err, data ? data.bitmap : null); + sharp(path, { failOnError: true }).metadata(function (err, metadata) { + callback(err, metadata ? { width: metadata.width, height: metadata.height } : undefined); }); } }; +image.checkDimensions = function (path, callback) { + const meta = require('./meta'); + image.size(path, function (err, result) { + if (err) { + return callback(err); + } + + const maxWidth = parseInt(meta.config.rejectImageWidth, 10) || 5000; + const maxHeight = parseInt(meta.config.rejectImageHeight, 10) || 5000; + if (result.width > maxWidth || result.height > maxHeight) { + return callback(new Error('[[error:invalid-image-dimensions]]')); + } + + callback(); + }); +}; + image.convertImageToBase64 = function (path, callback) { fs.readFile(path, 'base64', callback); }; diff --git a/src/topics/thumb.js b/src/topics/thumb.js index cfd13f3fb8..17d2806810 100644 --- a/src/topics/thumb.js +++ b/src/topics/thumb.js @@ -49,7 +49,6 @@ module.exports = function (Topics) { var size = parseInt(meta.config.topicThumbSize, 10) || 120; image.resizeImage({ path: pathToUpload, - extension: path.extname(pathToUpload), width: size, height: size, }, next); diff --git a/src/upgrades/1.6.0/generate-email-logo.js b/src/upgrades/1.6.0/generate-email-logo.js index 6115e773a1..5832fb9739 100644 --- a/src/upgrades/1.6.0/generate-email-logo.js +++ b/src/upgrades/1.6.0/generate-email-logo.js @@ -34,7 +34,6 @@ module.exports = { image.resizeImage({ path: sourcePath, target: uploadPath, - extension: 'png', height: 50, }, next); }); diff --git a/src/user/picture.js b/src/user/picture.js index d1a4dac7b0..05ce284238 100644 --- a/src/user/picture.js +++ b/src/user/picture.js @@ -123,11 +123,9 @@ module.exports = function (User) { }, function (path, next) { picture.path = path; - var imageDimension = parseInt(meta.config.profileImageDimension, 10) || 200; image.resizeImage({ path: picture.path, - extension: extension, width: imageDimension, height: imageDimension, }, next); diff --git a/src/views/admin/settings/uploads.tpl b/src/views/admin/settings/uploads.tpl index 34d7f96771..d008b0d9d3 100644 --- a/src/views/admin/settings/uploads.tpl +++ b/src/views/admin/settings/uploads.tpl @@ -21,7 +21,7 @@
- +

[[admin/settings/uploads:private-uploads-extensions-help]] @@ -52,6 +52,22 @@

+
+ + +

+ [[admin/settings/uploads:reject-image-width-help]] +

+
+ +
+ + +

+ [[admin/settings/uploads:reject-image-height-help]] +

+
+