diff --git a/api/media/routes.mjs b/api/media/routes.mjs index 672111b..0b908d8 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -107,9 +107,7 @@ export default class MediaRoutes { return this.upload(ctx, true) } - async resize(ctx) { - await this.upload(ctx) - + async resizeFile(ctx, sourceFile) { this.security.verifyBody(ctx) let keys = Object.keys(ctx.req.body) @@ -126,7 +124,7 @@ export default class MediaRoutes { return Promise.resolve() .then(async () => { let item = ctx.req.body[key] - let sharp = this.sharp(`./public/${ctx.state.site}/${ctx.body.filename}`) + let sharp = this.sharp(`./public/${ctx.state.site}/${sourceFile}`) .rotate() for (let operation of allowedOperations) { @@ -136,7 +134,7 @@ export default class MediaRoutes { } sharp = sharp[item.format](item[item.format]) - let target = ctx.body.filename + let target = sourceFile if (path.extname(target).length > 0) { target = target.slice(0, -path.extname(target).length) } @@ -167,6 +165,20 @@ export default class MediaRoutes { })) } + async resizeExisting(ctx) { + let site = await this.security.verifyToken(ctx) + ctx.state.site = site + + ctx.body = {} + await this.resizeFile(ctx, ctx.params.filename) + } + + async resize(ctx) { + await this.upload(ctx) + + await this.resizeFile(ctx, ctx.body.filename) + } + async remove(ctx) { let site = await this.security.verifyToken(ctx) diff --git a/api/server.mjs b/api/server.mjs index 96b2a59..7c1d3be 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -1,5 +1,5 @@ import { performance } from 'perf_hooks' -import { Flaska, QueryHandler } from 'flaska' +import { Flaska, QueryHandler, JsonHandler } from 'flaska' import TestRoutes from './test/routes.mjs' import MediaRoutes from './media/routes.mjs' @@ -57,6 +57,7 @@ app.get('/media/:site', media.listPublicFiles.bind(media)) app.post('/media', [QueryHandler()], media.upload.bind(media)) app.post('/media/noprefix', [QueryHandler()], media.uploadNoPrefix.bind(media)) app.post('/media/resize', [QueryHandler()], media.resize.bind(media)) +app.post('/media/resize/:filename', [QueryHandler(), JsonHandler()], media.resizeExisting.bind(media)) app.delete('/media/:filename', [QueryHandler()], media.remove.bind(media)) app.listen(config.get('server:port'), function(a,b) { diff --git a/package.json b/package.json index 81bb116..8384ef8 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,24 @@ { "name": "storage-upload", - "version": "2.1.1", + "version": "2.1.2", "description": "", "main": "index.js", "scripts": { "start": "node api/server.mjs", "start:bunyan": "node api/server.mjs | bunyan", "test": "set NODE_ENV=test&& eltro test/**/*.test.mjs -r dot", - "test:linux": "NODE_ENV=test eltro 'test/**/*.test.mjs' -r dot" + "test:linux": "NODE_ENV=test eltro 'test/**/*.test.mjs' -r dot", + "test:watch": "npm-watch test" + }, + "watch": { + "test": { + "patterns": [ + "{api,test}/*" + ], + "extensions": "js,mjs", + "quiet": true, + "inherit": true + } }, "repository": { "type": "git", @@ -21,7 +32,7 @@ "homepage": "https://github.com/nfp-projects/storage-upload#readme", "dependencies": { "bunyan-lite": "^1.1.1", - "flaska": "^0.9.8", + "flaska": "^1.2.3", "formidable": "^1.2.2", "nconf-lite": "^2.0.0", "sharp-lite": "^1.29.6" diff --git a/test/helper.client.mjs b/test/helper.client.mjs index 782893d..a5be504 100644 --- a/test/helper.client.mjs +++ b/test/helper.client.mjs @@ -67,6 +67,16 @@ Client.prototype.get = function(url = '/') { return this.customRequest('GET', url, null) } +Client.prototype.post = function(url = '/', body = {}) { + let parsed = JSON.stringify(body) + return this.customRequest('POST', url, parsed, { + headers: { + 'Content-Type': 'application/json', + 'Content-Length': parsed.length, + }, + }) +} + Client.prototype.del = function(url = '/', body = {}) { return this.customRequest('DELETE', url, JSON.stringify(body)) } diff --git a/test/media/api.test.mjs b/test/media/api.test.mjs index ecc016c..ca33fae 100644 --- a/test/media/api.test.mjs +++ b/test/media/api.test.mjs @@ -390,6 +390,170 @@ t.describe('Media (API)', () => { }) }) + t.timeout(10000).describe('POST /media/resize/:filename', function temp() { + let sourceFilename + let sourcePath + + t.before(async function() { + let token = encode(null, { iss: 'development' }, secret) + + let data = await assert.isFulfilled( + client.upload( + `/media/resize?token=${token}`, + resolve('test.png'), + 'POST', + { } + ) + ) + + testFiles.push(data.path) + + assert.ok(data) + assert.ok(data.filename) + assert.ok(data.filename.startsWith(currYear)) + assert.ok(data.path) + + sourceFilename = data.filename + sourcePath = data.path + }) + + t.test('should require authentication', async () => { + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + let err = await assert.isRejected( + client.post(`/media/resize/${sourceFilename}`, {}) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Mm]issing/) + + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Mm]issing/) + }) + + t.test('should verify token correctly', async () => { + const assertToken = 'asdf.asdf.asdf' + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + assert.strictEqual(server.log.info.callCount, 0) + + let err = await assert.isRejected( + client.post(`/media/resize/${sourceFilename}?token=${assertToken}`, {}) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Ii]nvalid/) + + assert.strictEqual(server.log.error.callCount, 1) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/) + assert.ok(server.log.error.lastCall[0] instanceof Error) + assert.match(server.log.error.lastCall[1], new RegExp(assertToken)) + }) + + t.test('should create multiple sizes for existing file', async () => { + let token = encode(null, { iss: 'development' }, secret) + + let data = await assert.isFulfilled( + client.post( + `/media/resize/${sourceFilename}?token=${token}`, + { + test1: { + format: 'jpeg', + 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, + } + }, + test2: { + format: 'png', + resize: { + width: 150, + }, + png: { + compressionLevel: 9, + } + }, + } + ) + ) + + testFiles.push(data.test1.path) + testFiles.push(data.test2.path) + + assert.ok(data.test1.filename) + assert.ok(data.test1.filename.startsWith(currYear)) + assert.ok(data.test1.path) + assert.ok(data.test2.filename) + assert.ok(data.test2.filename.startsWith(currYear)) + assert.ok(data.test2.path) + + let img = await sharp(resolve(`../../public/${sourcePath}`)).metadata() + assert.strictEqual(img.width, 600) + assert.strictEqual(img.height, 700) + assert.strictEqual(img.format, 'png') + + img = await sharp(resolve(`../../public/${data.test1.path}`)).metadata() + assert.strictEqual(img.width, 320) + assert.strictEqual(img.height, 413) + assert.strictEqual(img.format, 'jpeg') + + img = await sharp(resolve(`../../public/${data.test2.path}`)).metadata() + assert.strictEqual(img.width, 150) + assert.strictEqual(img.height, 175) + assert.strictEqual(img.format, 'png') + }) + + t.test('should base64 output of existing file', async () => { + let token = encode(null, { iss: 'development' }, secret) + + let data = await assert.isFulfilled( + client.post( + `/media/resize/${sourceFilename}?token=${token}`, + { + outtest: { + out: 'base64', + format: 'jpeg', + resize: { + width: 10, + }, + jpeg: { + quality: 80, + mozjpeg: true, + } + }, + } + ) + ) + + assert.ok(data) + assert.ok(data.outtest.base64) + + let bufferBase64 = Buffer.from(data.outtest.base64.slice(data.outtest.base64.indexOf(',')), 'base64') + + let img = await sharp(bufferBase64).metadata() + assert.strictEqual(img.width, 10) + assert.strictEqual(img.height, 12) + assert.strictEqual(img.format, 'jpeg') + }) + }) + t.timeout(10000).describe('DELETE /media/:filename', function temp() { t.test('should require authentication', async () => { resetLog() diff --git a/test/media/routes.test.mjs b/test/media/routes.test.mjs index 7074d20..33d6e4b 100644 --- a/test/media/routes.test.mjs +++ b/test/media/routes.test.mjs @@ -279,6 +279,267 @@ t.describe('#uploadNoPrefix', function() { }) }) +t.describe('#resizeExisting', function() { + const stubVerifyToken = stub() + const stubVerifyBody = 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() + const stubSharpPng = stub() + const stubSharpToBuffer = stub() + const stubStat = stub() + + const routes = new MediaRoutes({ + security: { + verifyToken: stubVerifyToken, + verifyBody: stubVerifyBody, + }, + fs: { stat: stubStat }, + sharp: stubSharp, + }) + + function reset() { + stubVerifyToken.reset() + stubVerifyBody.reset() + let def = { + toFile: stubSharpToFile, + jpeg: stubSharpJpeg, + png: stubSharpPng, + resize: stubSharpResize, + trim: stubSharpTrim, + extend: stubSharpExtend, + flatten: stubSharpFlatten, + blur: stubSharpBlur, + rotate: stubSharpRotate, + toBuffer: stubSharpToBuffer, + } + stubStat.reset() + stubStat.resolves({ size: 0 }) + stubSharp.reset() + stubSharp.returns(def) + for (let key in def) { + def[key].reset() + def[key].returns(def) + } + stubSharpToFile.resolves() + stubSharpToBuffer.resolves() + routes.siteCache.clear() + } + + t.test('should call security verifyToken correctly', async function() { + reset() + + let ctx = createContext({ req: { body: { } } }) + const assertError = new Error('temp') + stubVerifyToken.rejects(assertError) + + let err = await assert.isRejected(routes.resizeExisting(ctx)) + + assert.ok(stubVerifyToken.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubVerifyToken.firstCall[0], ctx) + }) + + t.test('should call security verifyBody correctly', async function() { + reset() + + let ctx = createContext({ req: { body: { }}}) + const assertError = new Error('temp') + stubVerifyBody.throws(assertError) + + let err = await assert.isRejected(routes.resizeExisting(ctx)) + + assert.strictEqual(err, assertError) + assert.ok(stubVerifyBody.called) + assert.strictEqual(stubVerifyBody.firstCall[0], ctx) + }) + + t.test('should call sharp correctly if items are specified', async function() { + reset() + const assertKey = 'asdf' + const assertJpeg = { a: 1 } + const assertFilename = 'asdfsafd' + const assertSite = 'mario' + stubVerifyToken.resolves(assertSite) + + let ctx = createContext({ req: { body: { + [assertKey]: { + format: 'jpeg', + } + }}}) + ctx.params.filename = assertFilename + '.png' + ctx.req.body[assertKey].jpeg = assertJpeg + + await routes.resizeExisting(ctx) + + assert.ok(stubSharp.called) + assert.match(stubSharp.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}\.png`)) + assert.ok(stubSharpRotate.called) + assert.ok(stubSharpJpeg.called) + assert.strictEqual(stubSharpJpeg.firstCall[0], assertJpeg) + assert.ok(stubSharpToFile.called) + assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.jpg`)) + assert.notOk(stubSharpPng.called) + assert.notOk(stubSharpResize.called) + assert.notOk(ctx.body.filename) + assert.notOk(ctx.body.path) + assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.jpg`) + assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.jpg`) + }) + + let validOperations = [ + 'resize', + 'blur', + 'trim', + 'extend', + 'flatten', + ] + + 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) + + let called = 0 + stubStat.returnWith(function() { + called += 10 + return { size: called } + }) + + let ctx = createContext({ req: { body: { + [assertKey]: { + format: 'png', + } + }}}) + ctx.params.filename = assertFilename + '.png' + ctx.req.body[assertKey].png = assertPng + ctx.req.body[assertKey][operation] = assertPayload + + await routes.resizeExisting(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.notOk(ctx.body.filename) + assert.notOk(ctx.body.path) + 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, 1) + assert.strictEqual(filesFromCache[0].filename, `${assertFilename}_${assertKey}\.png`) + assert.strictEqual(filesFromCache[0].size, 10) + }) + }) + + t.test('should notify which item failed if one fails', async function() { + reset() + const assertValidKey1 = 'herp' + const assertValidKey2 = 'derp' + const assertErrorKey = 'throwmyerr' + const assertErrorMessage = 'some message here' + stubVerifyToken.resolves('asdf') + + let called = 0 + stubStat.returnWith(function() { + called += 10 + return { size: called } + }) + + let ctx = createContext({ req: { body: { + [assertValidKey1]: { + format: 'png', + png: { a: 1 }, + }, + [assertValidKey2]: { + format: 'png', + png: { a: 1 }, + }, + [assertErrorKey]: { + format: 'png', + png: { a: 1 }, + }, + }}}) + ctx.params.filename = 'file.png' + + stubSharpToFile.returnWith(function(file) { + if (file.match(new RegExp(assertErrorKey))) { + throw new Error(assertErrorMessage) + } + }) + + let err = await assert.isRejected(routes.resizeExisting(ctx)) + + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 422) + assert.match(err.message, new RegExp(assertErrorKey)) + assert.match(err.message, new RegExp(assertErrorMessage)) + }) + + t.test('should call sharp correctly and return base64 format if out is base64', async function() { + reset() + const assertKey = 'outtest' + const assertFilename = 'asdfsafd.png' + const assertSite = 'mario' + const assertBase64Data = 'asdf1234' + stubVerifyToken.resolves(assertSite) + + let ctx = createContext({ req: { body: { + [assertKey]: { + format: 'png', + png: { a: 1 }, + out: 'base64', + } + }}}) + ctx.params.filename = assertFilename + + stubSharpToBuffer.resolves(Buffer.from(assertBase64Data)) + + await routes.resizeExisting(ctx) + + assert.ok(stubSharp.called) + assert.notOk(stubSharpToFile.called) + assert.ok(stubSharpToBuffer.called) + assert.ok(ctx.body[assertKey].base64) + assert.ok(ctx.body[assertKey].base64.startsWith(`data:image/png;base64,`)) + let base64 = ctx.body[assertKey].base64 + let bufferBase64 = Buffer.from(base64.slice(base64.indexOf(',')), 'base64') + assert.strictEqual(bufferBase64.toString(), assertBase64Data) + + let filesFromCache = routes.filesCacheGet(assertSite) + assert.strictEqual(filesFromCache.length, 0) + }) +}) + t.describe('#resize', function() { const stubVerifyToken = stub() const stubVerifyBody = stub()