diff --git a/api/media/routes.mjs b/api/media/routes.mjs index f1ffb35..672111b 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -114,6 +114,14 @@ export default class MediaRoutes { let keys = Object.keys(ctx.req.body) + let allowedOperations = [ + 'trim', + 'flatten', + 'resize', + 'blur', + 'extend', + ] + await Promise.all(keys.map(key => { return Promise.resolve() .then(async () => { @@ -121,8 +129,10 @@ export default class MediaRoutes { let sharp = this.sharp(`./public/${ctx.state.site}/${ctx.body.filename}`) .rotate() - if (item.resize) { - sharp = sharp.resize(item.resize) + for (let operation of allowedOperations) { + if (item[operation] != null) { + sharp = sharp[operation](item[operation]) + } } sharp = sharp[item.format](item[item.format]) diff --git a/api/media/security.mjs b/api/media/security.mjs index 5a0b188..36df233 100644 --- a/api/media/security.mjs +++ b/api/media/security.mjs @@ -32,6 +32,17 @@ export function throwIfNotPublic(site) { } } +const validObjectOperations = [ + 'resize', + 'extend', + 'flatten', +] + +const validNumberOperations = [ + 'blur', + 'trim', +] + export function verifyBody(ctx) { let keys = Object.keys(ctx.req.body) @@ -49,7 +60,7 @@ export function verifyBody(ctx) { if (typeof(item.format) !== 'string' || !item.format - || item.format === 'resize' + || validObjectOperations.includes(item.format) || item.format === 'out') { throw new HttpError(`Body item ${key} missing valid format`, 422) } @@ -68,10 +79,20 @@ export function verifyBody(ctx) { } } - if (item.resize != null) { - if (typeof(item.resize) !== 'object' - || Array.isArray(item.resize)) { - throw new HttpError(`Body item ${key} key resize was invalid`, 422) + for (let operation of validObjectOperations) { + if (item[operation] != null) { + if (typeof(item[operation]) !== 'object' + || Array.isArray(item[operation])) { + throw new HttpError(`Body item ${key} key ${operation} was invalid`, 422) + } + } + } + + for (let operation of validNumberOperations) { + if (item[operation] != null) { + if (typeof(item[operation]) !== 'number') { + throw new HttpError(`Body item ${key} key ${operation} was invalid`, 422) + } } } } diff --git a/test/helper.client.mjs b/test/helper.client.mjs index 8b03dad..782893d 100644 --- a/test/helper.client.mjs +++ b/test/helper.client.mjs @@ -113,62 +113,3 @@ Client.prototype.upload = function(url, file, method = 'POST', body = {}) { }) }) } - -/* - -export function createClient(host = config.get('server:port'), opts) { - let options = defaults(opts, {}) - - let prefix = `http://localhost:${host}` - options.headers['x-request-id'] = 'asdf' - - client.auth = (user) => { - // let m = helperDB.model('user', { - // id: user.id, - // level: (user.get && user.get('level')) || 1, - // institute_id: (user.get && user.get('institute_id')) || null, - // password: (user.get && user.get('password')) || null, - // }) - // let token = jwt.createUserToken(m) - // client.headers.authorization = `Bearer ${token}` - } - - // Simple wrappers to wrap into promises - client.getAsync = (path) => - new Promise((resolve, reject) => { - if (path.slice(0, 4) === 'http') { - return client.get(path, callback(resolve, reject)) - } - client.get(prefix + path, callback(resolve, reject)) - }) - - // Simple wrappers to wrap into promises - client.saveFileAsync = (path, destination) => - new Promise((resolve, reject) => { - client.saveFile(prefix + path, destination, callback(resolve, reject, true)) - }) - - client.postAsync = (path, data) => - new Promise((resolve, reject) => { - client.post(prefix + path, data, callback(resolve, reject)) - }) - - client.putAsync = (path, data) => - new Promise((resolve, reject) => { - client.put(prefix + path, data, callback(resolve, reject)) - }) - - client.deleteAsync = (path) => - new Promise((resolve, reject) => { - client.del(prefix + path, callback(resolve, reject)) - }) - - client.sendFileAsync = (path, files, data) => - new Promise((resolve, reject) => { - client.sendFile(prefix + path, files, data || {}, callback(resolve, reject)) - }) - - return client -} - -*/ diff --git a/test/media/api.test.mjs b/test/media/api.test.mjs index 7f07352..ecc016c 100644 --- a/test/media/api.test.mjs +++ b/test/media/api.test.mjs @@ -278,6 +278,10 @@ t.describe('Media (API)', () => { resize: { width: 300, }, + blur: 0.5, + flatten: {r:0,g:0,b:0}, + trim: 1, + extend: { top: 10, left: 10, bottom: 10, right: 10, background: {r:0,g:0,b:0} }, jpeg: { quality: 80, mozjpeg: true, @@ -324,8 +328,8 @@ t.describe('Media (API)', () => { assert.strictEqual(img.format, 'png') img = await sharp(resolve(`../../public/${data.test1.path}`)).metadata() - assert.strictEqual(img.width, 300) - assert.strictEqual(img.height, 350) + assert.strictEqual(img.width, 320) + assert.strictEqual(img.height, 413) assert.strictEqual(img.format, 'jpeg') img = await sharp(resolve(`../../public/${data.test2.path}`)).metadata() diff --git a/test/media/routes.test.mjs b/test/media/routes.test.mjs index 833540a..7074d20 100644 --- a/test/media/routes.test.mjs +++ b/test/media/routes.test.mjs @@ -285,6 +285,10 @@ t.describe('#resize', function() { const stubUpload = stub() const stubSharp = stub() const stubSharpResize = stub() + const stubSharpBlur = stub() + const stubSharpTrim = stub() + const stubSharpExtend = stub() + const stubSharpFlatten = stub() const stubSharpRotate = stub() const stubSharpToFile = stub() const stubSharpJpeg = stub() @@ -311,6 +315,10 @@ t.describe('#resize', function() { jpeg: stubSharpJpeg, png: stubSharpPng, resize: stubSharpResize, + trim: stubSharpTrim, + extend: stubSharpExtend, + flatten: stubSharpFlatten, + blur: stubSharpBlur, rotate: stubSharpRotate, toBuffer: stubSharpToBuffer, } @@ -318,17 +326,11 @@ t.describe('#resize', function() { stubStat.resolves({ size: 0 }) stubSharp.reset() stubSharp.returns(def) - stubSharpToFile.reset() + for (let key in def) { + def[key].reset() + def[key].returns(def) + } stubSharpToFile.resolves() - stubSharpJpeg.reset() - stubSharpJpeg.returns(def) - stubSharpResize.reset() - stubSharpResize.returns(def) - stubSharpRotate.reset() - stubSharpRotate.returns(def) - stubSharpPng.reset() - stubSharpPng.returns(def) - stubSharpToBuffer.reset() stubSharpToBuffer.resolves() routes.siteCache.clear() } @@ -381,53 +383,73 @@ t.describe('#resize', function() { assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.jpg`) }) - t.test('should call sharp correctly if items and resize are specified', async function() { - reset() - const assertKey = 'herpderp' - const assertPng = { a: 1 } - const assertResize = { a: 1 } - const assertFilename = 'asdfsafd' - const assertSite = 'mario' - stubVerifyToken.resolves(assertSite) - stubUpload.resolves({ filename: assertFilename + '.png' }) + let validOperations = [ + 'resize', + 'blur', + 'trim', + 'extend', + 'flatten', + ] - let called = 0 - stubStat.returnWith(function() { - called += 10 - return { size: called } + let operationStubMap = { + resize: stubSharpResize, + blur: stubSharpBlur, + trim: stubSharpTrim, + extend: stubSharpExtend, + flatten: stubSharpFlatten, + } + + validOperations.forEach(function(operation) { + t.test(`should call sharp correctly if items and ${operation} are specified`, async function() { + reset() + const assertKey = 'herpderp' + const assertPng = { a: 1 } + const assertPayload = { a: 1 } + const assertFilename = 'asdfsafd' + const assertSite = 'mario' + stubVerifyToken.resolves(assertSite) + stubUpload.resolves({ filename: assertFilename + '.png' }) + + let called = 0 + stubStat.returnWith(function() { + called += 10 + return { size: called } + }) + + let ctx = createContext({ req: { body: { + [assertKey]: { + format: 'png', + } + }}}) + ctx.req.body[assertKey].png = assertPng + ctx.req.body[assertKey][operation] = assertPayload + + await routes.resize(ctx) + + assert.ok(stubSharp.called) + assert.match(stubSharp.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}\.png`)) + assert.ok(stubSharpRotate.called) + assert.ok(stubSharpPng.called) + assert.strictEqual(stubSharpPng.firstCall[0], assertPng) + assert.ok(stubSharpToFile.called) + assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.png`)) + assert.notOk(stubSharpJpeg.called) + + assert.ok(operationStubMap[operation].called) + assert.strictEqual(operationStubMap[operation].firstCall[0], assertPayload) + + assert.strictEqual(ctx.body.filename, assertFilename + '.png') + assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}.png`) + assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.png`) + assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.png`) + + let filesFromCache = routes.filesCacheGet(assertSite) + assert.strictEqual(filesFromCache.length, 2) + assert.strictEqual(filesFromCache[0].filename, `${assertFilename}_${assertKey}\.png`) + assert.strictEqual(filesFromCache[0].size, 20) + assert.strictEqual(filesFromCache[1].filename, assertFilename + '.png') + assert.strictEqual(filesFromCache[1].size, 10) }) - - let ctx = createContext({ req: { body: { - [assertKey]: { - format: 'png', - } - }}}) - ctx.req.body[assertKey].png = assertPng - ctx.req.body[assertKey].resize = assertResize - - await routes.resize(ctx) - - assert.ok(stubSharp.called) - assert.match(stubSharp.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}\.png`)) - assert.ok(stubSharpRotate.called) - assert.ok(stubSharpPng.called) - assert.strictEqual(stubSharpPng.firstCall[0], assertPng) - assert.ok(stubSharpToFile.called) - assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.png`)) - assert.notOk(stubSharpJpeg.called) - assert.ok(stubSharpResize.called) - assert.strictEqual(stubSharpResize.firstCall[0], assertResize) - assert.strictEqual(ctx.body.filename, assertFilename + '.png') - assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}.png`) - assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.png`) - assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.png`) - - let filesFromCache = routes.filesCacheGet(assertSite) - assert.strictEqual(filesFromCache.length, 2) - assert.strictEqual(filesFromCache[0].filename, `${assertFilename}_${assertKey}\.png`) - assert.strictEqual(filesFromCache[0].size, 20) - assert.strictEqual(filesFromCache[1].filename, assertFilename + '.png') - assert.strictEqual(filesFromCache[1].size, 10) }) t.test('should notify which item failed if one fails', async function() { diff --git a/test/media/security.test.mjs b/test/media/security.test.mjs index b075343..cf4821a 100644 --- a/test/media/security.test.mjs +++ b/test/media/security.test.mjs @@ -323,58 +323,128 @@ t.describe('#verifyBody()', function() { }) }) - t.test('should allow empty value or object in resize', function() { - let ctx = createContext({ req: { body: { - item: { - format: 'test', - test: {}, - } - } } }) + let validObjectOperations = [ + 'resize', + 'extend', + 'flatten', + ] - let tests = [ - [undefined, 'undefined'], - [null, 'null'], - [{}, 'object'], - ] - - tests.forEach(function (check) { - ctx.req.body.item.resize = check[0] - - assert.doesNotThrow(function() { - verifyBody(ctx) - }, `should not throw with ${check[1]} in resize`) + validObjectOperations.forEach(function(operation) { + t.test(`should allow empty value or object in ${operation}`, function() { + let ctx = createContext({ req: { body: { + item: { + format: 'test', + test: {}, + } + } } }) + + let tests = [ + [undefined, 'undefined'], + [null, 'null'], + [{}, 'object'], + ] + + tests.forEach(function (check) { + ctx.req.body.item[operation] = check[0] + + assert.doesNotThrow(function() { + verifyBody(ctx) + }, `should not throw with ${check[1]} in ${operation}`) + }) + }) + + t.test(`should fail if ${operation} if specified is invalid`, function() { + let ctx = createContext({ req: { body: { + item: { + format: 'test', + test: {}, + } + } } }) + + let tests = [ + ['', 'emptystring'], + ['asdf', 'string'], + [0, 'emptynumber'], + [123, 'number'], + [[], 'array'], + ] + + tests.forEach(function (check) { + ctx.req.body.item[operation] = check[0] + + assert.throws(function() { verifyBody(ctx) }, function(err) { + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 422) + assert.match(err.message, /body/i) + assert.match(err.message, /item/i) + assert.match(err.message, new RegExp(operation)) + assert.match(err.message, /valid/i) + return true + }, `should fail if body item ${operation} is ${check[1]}`) + }) }) }) - t.test('should fail if resize if specified is invalid', function() { - let ctx = createContext({ req: { body: { - item: { - format: 'test', - test: {}, - } - } } }) + let validNumberOperations = [ + 'blur', + 'trim', + ] - let tests = [ - ['', 'emptystring'], - ['asdf', 'string'], - [0, 'emptynumber'], - [123, 'number'], - [[], 'array'], - ] - - tests.forEach(function (check) { - ctx.req.body.item.resize = check[0] - - assert.throws(function() { verifyBody(ctx) }, function(err) { - assert.ok(err instanceof HttpError) - assert.ok(err instanceof Error) - assert.strictEqual(err.status, 422) - assert.match(err.message, /body/i) - assert.match(err.message, /item/i) - assert.match(err.message, /resize/i) - assert.match(err.message, /valid/i) - return true - }, `should fail if body item resize is ${check[1]}`) + validNumberOperations.forEach(function(operation) { + t.test(`should allow empty value or number in ${operation}`, function() { + let ctx = createContext({ req: { body: { + item: { + format: 'test', + test: {}, + } + } } }) + + let tests = [ + [undefined, 'undefined'], + [null, 'null'], + [0, 'number'], + [0.5, 'positive number'], + ] + + tests.forEach(function (check) { + ctx.req.body.item[operation] = check[0] + + assert.doesNotThrow(function() { + verifyBody(ctx) + }, `should not throw with ${check[1]} in ${operation}`) + }) + }) + + t.test(`should fail if ${operation} if specified is invalid`, function() { + let ctx = createContext({ req: { body: { + item: { + format: 'test', + test: {}, + } + } } }) + + let tests = [ + ['', 'emptystring'], + ['asdf', 'string'], + [{}, 'object'], + [[], 'array'], + ] + + tests.forEach(function (check) { + ctx.req.body.item[operation] = check[0] + + assert.throws(function() { verifyBody(ctx) }, function(err) { + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 422) + assert.match(err.message, /body/i) + assert.match(err.message, /item/i) + assert.match(err.message, new RegExp(operation)) + assert.match(err.message, /valid/i) + return true + }, `should fail if body item ${operation} is ${check[1]}`) + }) }) }) })