├── .gitignore ├── testdata ├── invalidImage.png ├── notajpeg.jpg ├── something.txt ├── turtle.jpg ├── ancillaryChunks.png ├── purplealpha24bit.png ├── addBogusElement.js └── dialog-information.svg ├── .jshintignore ├── test ├── mocha.opts └── processImage.js ├── lib ├── errors.js ├── processImage.js └── getFilterInfosAndTargetContentTypeFromQueryString.js ├── bin └── processImage ├── package.json ├── LICENSE ├── README.md └── .jshintrc /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /testdata/invalidImage.png: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /testdata/notajpeg.jpg: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /testdata/something.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | testdata 3 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --timeout 60000 3 | --recursive 4 | --check-leaks 5 | -------------------------------------------------------------------------------- /testdata/turtle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podio/express-processimage/master/testdata/turtle.jpg -------------------------------------------------------------------------------- /testdata/ancillaryChunks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podio/express-processimage/master/testdata/ancillaryChunks.png -------------------------------------------------------------------------------- /testdata/purplealpha24bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podio/express-processimage/master/testdata/purplealpha24bit.png -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | var createError = require('createerror'); 2 | 3 | module.exports = { 4 | OutputDimensionsExceeded: createError({name: 'OutputDimensionsExceeded'}) 5 | }; 6 | -------------------------------------------------------------------------------- /testdata/addBogusElement.js: -------------------------------------------------------------------------------- 1 | var g = document.createElement('g'); 2 | g.setAttribute('id', svgFilter.bogusElementId || 'blablaf'); 3 | document.documentElement.appendChild(g); 4 | -------------------------------------------------------------------------------- /bin/processImage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'), 4 | getFiltersAndTargetContentTypeFromQueryString = require('../lib/getFiltersAndTargetContentTypeFromQueryString'), 5 | commandLineOptions = require('optimist') 6 | .usage('$0 imageProcessingSpec [-o outputFileName]') 7 | .option('o', { 8 | alias: 'output', 9 | describe: 'The file to output' 10 | }) 11 | .demand(1) 12 | .argv; 13 | 14 | var filtersAndTargetContentType = getFiltersAndTargetContentTypeFromQueryString(commandLineOptions._[0]), 15 | filters = filtersAndTargetContentType.filters, 16 | inputStream = process.stdin, 17 | outputStream; 18 | 19 | if (commandLineOptions.o) { 20 | outputStream = fs.createWriteStream(commandLineOptions.o); 21 | } else { 22 | outputStream = process.stdout; 23 | } 24 | 25 | inputStream.pipe(filters[0]); 26 | 27 | for (var i = 0 ; i < filters.length ; i += 1) { 28 | var filter = filters[i]; 29 | filter.on('error', function (err) { 30 | if ('commandLine' in this) { 31 | err.message = this.commandLine + ': ' + err.message; 32 | } 33 | throw err; 34 | }); 35 | if (i < filters.length - 1) { 36 | filter.pipe(filters[i + 1]); 37 | } 38 | } 39 | 40 | filters[filters.length - 1].pipe(outputStream); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-processimage", 3 | "version": "2.1.1-podio", 4 | "description": "Express middleware that processes served images according to the query string", 5 | "main": "lib/processImage.js", 6 | "directories": { 7 | "test": "test", 8 | "bin": "bin" 9 | }, 10 | "dependencies": { 11 | "createerror": "0.5.1", 12 | "express-hijackresponse": "0.2.1", 13 | "gm": "1.6.1", 14 | "inkscape": "0.0.5", 15 | "jpegtran": "0.0.6", 16 | "mime": "1.2.11", 17 | "optimist": "0.6.1", 18 | "optipng": "0.1.1", 19 | "passerror": "0.0.1", 20 | "pngcrush": "0.1.0", 21 | "pngquant": "https://github.com/podio/node-pngquant/archive/v0.4.1-podio.tar.gz" 22 | }, 23 | "optionalDependencies": { 24 | "sharp": "0.9.0", 25 | "svgfilter": "0.4.0" 26 | }, 27 | "devDependencies": { 28 | "express": "3.0.3", 29 | "jshint": "2.5.0", 30 | "mocha": "1.20.0", 31 | "request": "2.12.0", 32 | "unexpected": "3.2.0" 33 | }, 34 | "scripts": { 35 | "test": "mocha", 36 | "lint": "jshint ." 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git://github.com/papandreou/express-processimage.git" 41 | }, 42 | "keywords": [ 43 | "express", 44 | "middleware", 45 | "image", 46 | "images", 47 | "png", 48 | "jpg", 49 | "jpeg", 50 | "resize", 51 | "scale", 52 | "graphicsmagick", 53 | "optipng", 54 | "pngcrush", 55 | "pngquant", 56 | "jpegtran" 57 | ], 58 | "author": "Andreas Lind Petersen ", 59 | "license": "BSD" 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Andreas Lind Petersen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of the author nor the names of contributors may 15 | be used to endorse or promote products derived from this 16 | software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 19 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 20 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 21 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | express-processimage 2 | ==================== 3 | 4 | Middleware that processes images according to the query 5 | string. Intended to be used in a development setting with the 6 | `connect.static` middleware, but should work with any middleware 7 | further down the stack, even an http proxy. 8 | 9 | **Important note: This module is intended for development. You're 10 | strongly discouraged from using it in production or with any kind of 11 | untrusted input. Parts of the query string will be passed directly to 12 | various command line tools.** 13 | 14 | The response will be be processed under these circumstances: 15 | 16 | * If the request has a query string and accepts `image/*`. 17 | * If the response is served with a `Content-Type` of `image/*`. 18 | 19 | `express-processimage` plays nice with conditional GET. If the 20 | original response has an ETag, `express-processimage` will add to it 21 | so the ETag of the processed image never clashes with the original 22 | ETag. That prevents the middleware issuing the original response from 23 | being confused into sending a false positive `304 Not Modified` if 24 | `express-processimage` is turned off or removed from the stack later. 25 | 26 | 27 | Query string syntax 28 | ------------------- 29 | 30 | `express-processimage` supports `pngcrush`, `pngquant`, `optipng`, 31 | `jpegtran`, `inkscape`, `svgfilter`, 34 | and all methods listed under "manipulation" and "drawing primitives" 35 | in the documentation 36 | for the gm module. 37 | 38 | Multiple tools can be applied to the same image (separated by `&`, and 39 | the order is significant). Arguments for the individual tools are 40 | separated by non-URL encoded comma or plus. 41 | 42 | ``` 43 | http://localhost:1337/myImage.png?pngcrush=-rem,alla 44 | http://localhost:1337/myImage.png?pngcrush=-rem+alla 45 | http://localhost:1337/myImage.png?optipng=-o7 46 | http://localhost:1337/bigImage.png?resize=400,300&pngquant=128&pngcrush 47 | http://localhost:1337/hello.png?setFormat=gif 48 | http://localhost:1337/logo.svg?inkscape 49 | http://localhost:1337/file.svg?svgfilter=--runScript=makeItBlue.js 50 | ``` 51 | 52 | Installation 53 | ------------ 54 | 55 | Make sure you have node.js and npm installed, then run: 56 | 57 | npm install express-processimage 58 | 59 | Example usage 60 | ------------- 61 | 62 | Express 3.0 syntax: 63 | 64 | ```javascript 65 | var express = require('express'), 66 | processImage = require('express-processimage'), 67 | root = '/path/to/my/static/files'; 68 | 69 | express() 70 | .use(processImage({root: root})) 71 | .use(express.static(root)) 72 | .listen(1337); 73 | ``` 74 | 75 | The `root` option is used by node-svgfilter 77 | for finding the location of external JavaScript files to run on the SVG document. 78 | 79 | License 80 | ------- 81 | 82 | 3-clause BSD license -- see the `LICENSE` file for details. 83 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // JSHint Default Configuration File (as on JSHint website) 3 | // See http://jshint.com/docs/ for more details 4 | 5 | "maxerr" : 50, // {int} Maximum error before stopping 6 | 7 | // Enforcing 8 | "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) 9 | "camelcase" : false, // true: Identifiers must be in camelCase 10 | "curly" : true, // true: Require {} for every new block or scope 11 | "eqeqeq" : true, // true: Require triple equals (===) for comparison 12 | "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() 13 | "immed" : true, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` 14 | "indent" : 4, // {int} Number of spaces to use for indentation 15 | "latedef" : true, // true: Require variables/functions to be defined before being used 16 | "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()` 17 | "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` 18 | "noempty" : true, // true: Prohibit use of empty blocks 19 | "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) 20 | "plusplus" : true, // true: Prohibit use of `++` & `--` 21 | "quotmark" : "single", // Quotation mark consistency: 22 | // false : do nothing (default) 23 | // true : ensure whatever is used is consistent 24 | // "single" : require single quotes 25 | // "double" : require double quotes 26 | "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) 27 | "unused" : "vars", // true: Require all defined variables be used 28 | "strict" : false, // true: Requires all functions run in ES5 Strict Mode 29 | "trailing" : true, // true: Prohibit trailing whitespaces 30 | "maxparams" : false, // {int} Max number of formal params allowed per function 31 | "maxdepth" : false, // {int} Max depth of nested blocks (within functions) 32 | "maxstatements" : false, // {int} Max number statements per function 33 | "maxcomplexity" : false, // {int} Max cyclomatic complexity per function 34 | "maxlen" : false, // {int} Max number of characters per line 35 | 36 | // Relaxing 37 | "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) 38 | "boss" : false, // true: Tolerate assignments where comparisons would be expected 39 | "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. 40 | "eqnull" : false, // true: Tolerate use of `== null` 41 | "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) 42 | "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) 43 | // (ex: `for each`, multiple try/catch, function expression…) 44 | "evil" : false, // true: Tolerate use of `eval` and `new Function()` 45 | "expr" : false, // true: Tolerate `ExpressionStatement` as Programs 46 | "funcscope" : false, // true: Tolerate defining variables inside control statements" 47 | "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') 48 | "iterator" : false, // true: Tolerate using the `__iterator__` property 49 | "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block 50 | "laxbreak" : false, // true: Tolerate possibly unsafe line breakings 51 | "laxcomma" : false, // true: Tolerate comma-first style coding 52 | "loopfunc" : true, // true: Tolerate functions being defined in loops 53 | "multistr" : false, // true: Tolerate multi-line strings 54 | "proto" : false, // true: Tolerate using the `__proto__` property 55 | "scripturl" : false, // true: Tolerate script-targeted URLs 56 | "smarttabs" : false, // true: Tolerate mixed tabs/spaces when used for alignment 57 | "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` 58 | "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation 59 | "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` 60 | "validthis" : false, // true: Tolerate using this in a non-constructor function 61 | 62 | // Environments 63 | "browser" : false, // Web Browser (window, document, etc) 64 | "couch" : false, // CouchDB 65 | "devel" : true, // Development/debugging (alert, confirm, etc) 66 | "dojo" : false, // Dojo Toolkit 67 | "jquery" : true, // jQuery 68 | "mootools" : false, // MooTools 69 | "node" : true, // Node.js 70 | "nonstandard" : true, // Widely adopted globals (escape, unescape, etc) 71 | "prototypejs" : false, // Prototype and Scriptaculous 72 | "rhino" : false, // Rhino 73 | "worker" : false, // Web Workers 74 | "wsh" : false, // Windows Scripting Host 75 | "yui" : false, // Yahoo User Interface 76 | 77 | // Legacy 78 | "nomen" : false, // true: Prohibit dangling `_` in variables 79 | "onevar" : false, // true: Allow only one `var` statement per function 80 | "passfail" : false, // true: Stop on first error 81 | "white" : true, // true: Check against strict whitespace and indentation rules 82 | 83 | // Custom Globals 84 | "predef" : [ // additional predefined global variables 85 | "TR", 86 | "TRPAT", 87 | "GETTEXT", 88 | "GETSTATICURL", 89 | "INCLUDE", 90 | "define", 91 | "require" 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /lib/processImage.js: -------------------------------------------------------------------------------- 1 | var Path = require('path'), 2 | getFilterInfosAndTargetContentTypeFromQueryString = require('./getFilterInfosAndTargetContentTypeFromQueryString'), 3 | mime = require('mime'); 4 | 5 | require('express-hijackresponse'); 6 | 7 | var isImageByExtension = {}; 8 | 9 | Object.keys(mime.extensions).forEach(function (contentType) { 10 | if (/^image\//.test(contentType)) { 11 | var extension = mime.extensions[contentType]; 12 | isImageByExtension[extension] = true; 13 | } 14 | }); 15 | 16 | isImageByExtension.jpg = true; 17 | 18 | function isImageExtension(extension) { 19 | return isImageByExtension[extension.toLowerCase()]; 20 | } 21 | 22 | module.exports = function (options) { 23 | options = options || {}; 24 | return function (req, res, next) { 25 | var matchExtensionAndQueryString = req.url.match(/\.(\w+)\?(.*)$/); 26 | if (matchExtensionAndQueryString && isImageExtension(matchExtensionAndQueryString[1]) && req.accepts('image/*')) { 27 | // Prevent If-None-Match revalidation with the downstream middleware with ETags that aren't suffixed with "-processimage": 28 | var queryString = matchExtensionAndQueryString[2], 29 | ifNoneMatch = req.headers['if-none-match']; 30 | if (ifNoneMatch) { 31 | var validIfNoneMatchTokens = ifNoneMatch.split(' ').filter(function (etag) { 32 | return (/-processimage["-]/).test(etag); 33 | }); 34 | if (validIfNoneMatchTokens.length > 0) { 35 | req.headers['if-none-match'] = validIfNoneMatchTokens.join(' '); 36 | } else { 37 | delete req.headers['if-none-match']; 38 | } 39 | } 40 | delete req.headers['if-modified-since']; // Prevent false positive conditional GETs after enabling processimage 41 | res.hijack(function (err, res) { 42 | var contentType = res.getHeader('Content-Type'), 43 | etagFragments = [], 44 | seenData = false, 45 | hasEnded = false; 46 | 47 | function sendErrorResponse(err) { 48 | if (!hasEnded) { 49 | hasEnded = true; 50 | if ('commandLine' in this) { 51 | err.message = this.commandLine + ': ' + err.message; 52 | } 53 | if (seenData) { 54 | res.statusCode = 500; 55 | res.end(); 56 | } else { 57 | res.unhijack(function () { 58 | next(err); 59 | }); 60 | } 61 | } 62 | } 63 | 64 | if (contentType && contentType.indexOf('image/') === 0) { 65 | var filterInfosAndTargetFormat = getFilterInfosAndTargetContentTypeFromQueryString(queryString, { 66 | rootPath: options.root, 67 | sourceFilePath: options.root && Path.resolve(options.root, req.url.substr(1)) 68 | }), 69 | targetContentType = filterInfosAndTargetFormat.targetContentType; 70 | if (filterInfosAndTargetFormat.filterInfos.length === 0) { 71 | return res.unhijack(true); 72 | } 73 | if (targetContentType) { 74 | res.setHeader('Content-Type', targetContentType); 75 | } 76 | res.removeHeader('Content-Length'); 77 | var oldETag = res.getHeader('ETag'), 78 | newETag; 79 | if (oldETag) { 80 | newETag = '"' + oldETag.replace(/^"|"$/g, '') + '-processimage"'; 81 | res.setHeader('ETag', newETag); 82 | 83 | if (ifNoneMatch && ifNoneMatch.indexOf(newETag) !== -1) { 84 | return res.send(304); 85 | } 86 | } 87 | 88 | var filters = filterInfosAndTargetFormat.filterInfos.map(function (filterInfo) { 89 | return filterInfo.create(); 90 | }); 91 | 92 | for (var i = 0 ; i < filters.length ; i += 1) { 93 | if (i < filters.length - 1) { 94 | filters[i].pipe(filters[i + 1]); 95 | } 96 | filters[i].on('etagFragment', function (etagFragment) { 97 | etagFragments.push(etagFragment); 98 | }); 99 | filters[i].on('error', sendErrorResponse); 100 | } 101 | if (filters[0]._readableState) { 102 | // For some reason res.pipe(filters[0]) doesn't work with sharp streams. Probably an express-hijackresponse problem. 103 | // Work around it by forcing it into streams1 mode, sacrificing backpressure: 104 | res.on('data', function (chunk) { 105 | filters[0].write(chunk); 106 | }).on('end', function () { 107 | filters[0].end(); 108 | }); 109 | } else { 110 | res.pipe(filters[0]); 111 | } 112 | // Cannot use Stream.prototype.pipe here because it tears down the pipe when the destination stream emits the 'end' event. 113 | // There are plans to fix this as part of the streams2 effort: https://github.com/joyent/node/pull/2524 114 | // filters[filters.length - 1].pipe(res); 115 | filters[filters.length - 1].on('data', function (chunk) { 116 | seenData = true; 117 | if (!hasEnded) { 118 | res.write(chunk); 119 | } 120 | }).on('end', function () { 121 | if (!hasEnded) { 122 | if (seenData) { 123 | res.end(); 124 | } else { 125 | sendErrorResponse(new Error('Last filter emitted end without producing any output')); 126 | } 127 | } 128 | }); 129 | 130 | res.on('error', function () { 131 | res.unhijack(); 132 | next(500); 133 | }); 134 | } else { 135 | res.unhijack(true); 136 | } 137 | }); 138 | next(); 139 | } else { 140 | next(); 141 | } 142 | }; 143 | }; 144 | -------------------------------------------------------------------------------- /test/processImage.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, before, after*/ 2 | var express = require('express'), 3 | Path = require('path'), 4 | request = require('request'), 5 | passError = require('passerror'), 6 | expect = require('unexpected'), 7 | processImage = require('../lib/processImage'), 8 | Stream = require('stream'), 9 | gm = require('gm'); 10 | 11 | function getImageMetadataFromBuffer(buffer, cb) { 12 | var readStream = new Stream(); 13 | readStream.readable = true; 14 | gm(readStream).identify(cb); 15 | process.nextTick(function () { 16 | readStream.emit('data', buffer); 17 | readStream.emit('end'); 18 | }); 19 | } 20 | 21 | describe('test server', function () { 22 | // Pick a random TCP port above 10000 (.listen(0) doesn't work anymore?) 23 | var portNumber = 10000 + Math.floor(55536 * Math.random()), 24 | baseUrl = 'http://127.0.0.1:' + portNumber, 25 | server; 26 | 27 | before(function (done) { 28 | var root = Path.resolve(__dirname, '..', 'testdata') + '/'; 29 | server = express() 30 | .use(processImage({root: root})) 31 | .use(express.static(root)) 32 | .use(function errorHandler(err, req, res, next) { 33 | res.writeHead(500, { 34 | 'content-type': 'text/plain' 35 | }); 36 | res.end(err.stack || err); 37 | }) 38 | .listen(portNumber, done); 39 | }); 40 | 41 | after(function () { 42 | server.close(); 43 | }); 44 | 45 | it('should not mess with request for non-image file', function (done) { 46 | request(baseUrl + '/something.txt', passError(done, function (response, body) { 47 | expect(body, 'to equal', 'foo\n'); 48 | expect(response.headers['content-type'], 'to equal', 'text/plain; charset=UTF-8'); 49 | done(); 50 | })); 51 | }); 52 | 53 | it('should not mess with request for image with no query string', function (done) { 54 | request({url: baseUrl + '/ancillaryChunks.png', encoding: null}, passError(done, function (response, body) { 55 | expect(body.length, 'to equal', 3711); 56 | expect(response.headers['content-type'], 'to equal', 'image/png'); 57 | done(); 58 | })); 59 | }); 60 | 61 | it('should not mess with request for image with an unsupported operation in the query string', function (done) { 62 | request({url: baseUrl + '/ancillaryChunks.png?foo=bar', encoding: null}, passError(done, function (response, body) { 63 | expect(body.length, 'to equal', 3711); 64 | expect(response.headers['content-type'], 'to equal', 'image/png'); 65 | done(); 66 | })); 67 | }); 68 | 69 | it('should run the image through pngcrush when the pngcrush CGI param is specified', function (done) { 70 | request({url: baseUrl + '/ancillaryChunks.png?pngcrush=-rem+alla', encoding: null}, passError(done, function (response, body) { 71 | expect(response.statusCode, 'to equal', 200); 72 | expect(response.headers['content-type'], 'to equal', 'image/png'); 73 | expect(body.length, 'to be less than', 3711); 74 | expect(body.length, 'to be greater than', 0); 75 | getImageMetadataFromBuffer(body, passError(done, function (metadata) { 76 | expect(metadata.format, 'to equal', 'PNG'); 77 | expect(metadata.size.width, 'to equal', 400); 78 | expect(metadata.size.height, 'to equal', 20); 79 | done(); 80 | })); 81 | })); 82 | }); 83 | 84 | it('should run the image through pngquant when the pngquant CGI param is specified', function (done) { 85 | request({url: baseUrl + '/purplealpha24bit.png?pngquant', encoding: null}, passError(done, function (response, body) { 86 | expect(response.statusCode, 'to equal', 200); 87 | expect(response.headers['content-type'], 'to equal', 'image/png'); 88 | expect(body.length, 'to be less than', 8285); 89 | expect(body.length, 'to be greater than', 0); 90 | getImageMetadataFromBuffer(body, passError(done, function (metadata) { 91 | expect(metadata.format, 'to equal', 'PNG'); 92 | expect(metadata.size.width, 'to equal', 100); 93 | expect(metadata.size.height, 'to equal', 100); 94 | done(); 95 | })); 96 | })); 97 | }); 98 | 99 | it('should run the image through jpegtran when the jpegtran CGI param is specified', function (done) { 100 | request({url: baseUrl + '/turtle.jpg?jpegtran=-grayscale,-flip,horizontal', encoding: null}, passError(done, function (response, body) { 101 | expect(response.statusCode, 'to equal', 200); 102 | expect(response.headers['content-type'], 'to equal', 'image/jpeg'); 103 | expect(body.length, 'to be less than', 105836); 104 | expect(body.length, 'to be greater than', 0); 105 | getImageMetadataFromBuffer(body, passError(done, function (metadata) { 106 | expect(metadata.format, 'to equal', 'JPEG'); 107 | expect(metadata.size.width, 'to equal', 481); 108 | expect(metadata.size.height, 'to equal', 424); 109 | expect(metadata['Channel Depths'].Gray, 'to equal', '8 bits'); 110 | done(); 111 | })); 112 | })); 113 | }); 114 | 115 | it('should run the image through graphicsmagick when methods exposed by the gm module are added as CGI params', function (done) { 116 | request({url: baseUrl + '/turtle.jpg?gm&resize=340,300', encoding: null}, passError(done, function (response, body) { 117 | expect(response.statusCode, 'to equal', 200); 118 | expect(response.headers['content-type'], 'to equal', 'image/jpeg'); 119 | expect(body.slice(0, 10).toString(), 'to equal', new Buffer([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]).toString()); 120 | expect(body.length, 'to be less than', 105836); 121 | expect(body.length, 'to be greater than', 0); 122 | getImageMetadataFromBuffer(body, passError(done, function (metadata) { 123 | expect(metadata.format, 'to equal', 'JPEG'); 124 | expect(metadata.size.width, 'to equal', 340); 125 | expect(metadata.size.height, 'to equal', 300); 126 | done(); 127 | })); 128 | })); 129 | }); 130 | 131 | it('should run the image through sharp when methods exposed by the sharp module are added as CGI params', function (done) { 132 | request({url: baseUrl + '/turtle.jpg?sharp&resize=340,300&png', encoding: null}, passError(done, function (response, body) { 133 | expect(response.statusCode, 'to equal', 200); 134 | expect(response.headers['content-type'], 'to equal', 'image/png'); 135 | getImageMetadataFromBuffer(body, passError(done, function (metadata) { 136 | expect(metadata.format, 'to equal', 'PNG'); 137 | expect(metadata.size.width, 'to equal', 340); 138 | expect(metadata.size.height, 'to equal', 300); 139 | done(); 140 | })); 141 | })); 142 | }); 143 | 144 | it('should run the image through svgfilter when the svgfilter parameter is specified', function (done) { 145 | request({url: baseUrl + '/dialog-information.svg?svgfilter=--runScript=addBogusElement.js,--bogusElementId=theBogusElementId'}, passError(done, function (response, svgText) { 146 | expect(response.statusCode, 'to equal', 200); 147 | expect(response.headers['content-type'], 'to equal', 'image/svg+xml'); 148 | expect(response.headers.etag, 'to be ok'); 149 | // expect(response.headers.etag, 'to match', /^"\d+-\d+-697ebc4fd42e6b09794a5d60968435a7-processimage"$/); 150 | expect(svgText, 'to match', / assetgraph --> jsdom --> contextify 14 | } 15 | }); 16 | 17 | Object.keys(gm.prototype).forEach(function (propertyName) { 18 | if (!/^_|^(?:emit|.*Listeners?|on|once|size|orientation|format|depth|color|res|filesize|identity|write|stream)$/.test(propertyName) && 19 | typeof gm.prototype[propertyName] === 'function') { 20 | isOperationByEngineNameAndName.gm[propertyName] = true; 21 | } 22 | }); 23 | 24 | try { 25 | sharp = require('sharp'); 26 | } catch (e) {} 27 | 28 | if (sharp) { 29 | isOperationByEngineNameAndName.sharp = {}; 30 | ['resize', 'extract', 'sequentialRead', 'crop', 'max', 'background', 'embed', 'flatten', 'rotate', 'flip', 'flop', 'withoutEnlargement', 'sharpen', 'interpolateWith', 'gamma', 'grayscale', 'greyscale', 'jpeg', 'png', 'webp', 'quality', 'progressive', 'withMetadata', 'compressionLevel'].forEach(function (sharpOperationName) { 31 | isOperationByEngineNameAndName.sharp[sharpOperationName] = true; 32 | }); 33 | } 34 | 35 | var engineNamesByOperationName = {}; 36 | 37 | Object.keys(isOperationByEngineNameAndName).forEach(function (engineName) { 38 | Object.keys(isOperationByEngineNameAndName[engineName]).forEach(function (operationName) { 39 | (engineNamesByOperationName[operationName] = engineNamesByOperationName[operationName] || []).push(engineName); 40 | }); 41 | }); 42 | 43 | module.exports = function getFilterInfosAndTargetContentTypeFromQueryString(queryString, options) { 44 | options = options || {}; 45 | var filters = options.filters || {}, 46 | filterInfos = [], 47 | defaultEngineName = options.defaultEngineName || (sharp && 'sharp') || 'gm', 48 | currentEngineName, 49 | operations = [], 50 | operationNames = [], 51 | usedQueryStringFragments = [], 52 | leftOverQueryStringFragments = [], 53 | targetContentType; 54 | 55 | function checkSharpOrGmOperation(operation) { 56 | if (operation.name === 'resize' && typeof options.maxOutputPixels === 'number' && operation.args.length >= 2 && operation.args[0] * operation.args[1] > options.maxOutputPixels) { 57 | // FIXME: Realizing that we're going over the limit when only one resize operand is given would require knowing the metadata. 58 | // It's a big wtf that the maxOutputPixels option is only enforced some of the time. 59 | throw new errors.OutputDimensionsExceeded('resize: Target dimensions of ' + operation.args[0] + 'x' + operation.args[1] + ' exceed maxOutputPixels (' + options.maxOutputPixels + ')'); 60 | } 61 | } 62 | 63 | function flushOperations() { 64 | if (operations.length > 0) { 65 | if (currentEngineName === 'sharp') { 66 | var sharpOperationsForThisInstance = [].concat(operations); 67 | operationNames.push('sharp'); 68 | filterInfos.push({ 69 | operationName: 'sharp', 70 | usedQueryStringFragments: operations.map(function (operation) { 71 | return operation.usedQueryStringFragment; 72 | }), 73 | create: function () { 74 | if (options.maxInputPixels) { 75 | sharpOperationsForThisInstance.unshift({name: 'limitInputPixels', args: [options.maxInputPixels]}); 76 | } 77 | return sharpOperationsForThisInstance.reduce(function (sharpInstance, operation) { 78 | checkSharpOrGmOperation(operation); 79 | return sharpInstance[operation.name].apply(sharpInstance, operation.args); 80 | }, sharp()); 81 | } 82 | }); 83 | } else if (currentEngineName === 'gm') { 84 | var gmOperationsForThisInstance = [].concat(operations); 85 | operationNames.push('gm'); 86 | filterInfos.push({ 87 | operationName: 'gm', 88 | usedQueryStringFragments: operations.map(function (operation) { 89 | return operation.usedQueryStringFragment; 90 | }), 91 | create: function () { 92 | // For some reason the gm module doesn't expose itself as a readable/writable stream, 93 | // so we need to wrap it into one: 94 | 95 | var readStream = new Stream(); 96 | readStream.readable = true; 97 | 98 | var readWriteStream = new Stream(); 99 | readWriteStream.readable = readWriteStream.writable = true; 100 | var spawned = false; 101 | 102 | readWriteStream.write = function (chunk) { 103 | if (!spawned) { 104 | spawned = true; 105 | var seenData = false, 106 | hasEnded = false, 107 | gmInstance = gm(readStream); 108 | if (options.maxInputPixels) { 109 | gmInstance.limit('pixels', options.maxInputPixels); 110 | } 111 | gmOperationsForThisInstance.reduce(function (gmInstance, gmOperation) { 112 | checkSharpOrGmOperation(gmOperation); 113 | return gmInstance[gmOperation.name].apply(gmInstance, gmOperation.args); 114 | }, gmInstance).stream(function (err, stdout, stderr) { 115 | if (err) { 116 | hasEnded = true; 117 | return readWriteStream.emit('error', err); 118 | } 119 | stdout.on('data', function (chunk) { 120 | seenData = true; 121 | readWriteStream.emit('data', chunk); 122 | }).on('end', function () { 123 | if (!hasEnded) { 124 | if (seenData) { 125 | readWriteStream.emit('end'); 126 | } else { 127 | readWriteStream.emit('error', new Error('The gm stream ended without emitting any data')); 128 | } 129 | hasEnded = true; 130 | } 131 | }); 132 | }); 133 | } 134 | readStream.emit('data', chunk); 135 | }; 136 | readWriteStream.end = function (chunk) { 137 | if (chunk) { 138 | readWriteStream.write(chunk); 139 | } 140 | readStream.emit('end'); 141 | }; 142 | return readWriteStream; 143 | } 144 | }); 145 | } else { 146 | throw new Error('Internal error'); 147 | } 148 | operations = []; 149 | } 150 | currentEngineName = undefined; 151 | } 152 | 153 | queryString.split('&').forEach(function (keyValuePair) { 154 | var matchKeyValuePair = keyValuePair.match(/^([^=]+)(?:=(.*))?/); 155 | if (matchKeyValuePair) { 156 | var operationName = decodeURIComponent(matchKeyValuePair[1]), 157 | // Split by non-URL encoded comma or plus: 158 | operationArgs = matchKeyValuePair[2] ? matchKeyValuePair[2].split(/[\+,]/).map(function (arg) { 159 | arg = decodeURIComponent(arg); 160 | if (/^\d+$/.test(arg)) { 161 | return parseInt(arg, 10); 162 | } else if (arg === 'true') { 163 | return true; 164 | } else if (arg === 'false') { 165 | return false; 166 | } else { 167 | return arg; 168 | } 169 | }) : []; 170 | 171 | if (filters[operationName]) { 172 | flushOperations(); 173 | var filterInfo = filters[operationName](operationArgs, { 174 | inputContentType: targetContentType, 175 | numPreceedingFilters: filterInfos.length 176 | }); 177 | if (filterInfo) { 178 | filterInfo.usedQueryStringFragments = [keyValuePair]; 179 | filterInfo.operationName = operationName; 180 | if (filterInfo.outputContentType) { 181 | targetContentType = filterInfo.outputContentType; 182 | } 183 | filterInfos.push(filterInfo); 184 | operationNames.push(operationName); 185 | usedQueryStringFragments.push(keyValuePair); 186 | } else { 187 | leftOverQueryStringFragments.push(keyValuePair); 188 | } 189 | } else if (isOperationByEngineNameAndName[operationName]) { 190 | usedQueryStringFragments.push(keyValuePair); 191 | flushOperations(); 192 | defaultEngineName = operationName; 193 | } else if (engineNamesByOperationName[operationName]) { 194 | // Check if at least one of the engines supporting this operation is allowed 195 | var candidateEngineNames = engineNamesByOperationName[operationName].filter(function (engineName) { 196 | return filters[engineName] !== false; 197 | }); 198 | if (candidateEngineNames.length > 0) { 199 | if (currentEngineName && !isOperationByEngineNameAndName[currentEngineName]) { 200 | flushOperations(); 201 | } 202 | 203 | if (!currentEngineName || candidateEngineNames.indexOf(currentEngineName) === -1) { 204 | if (candidateEngineNames.indexOf(defaultEngineName) !== -1) { 205 | currentEngineName = defaultEngineName; 206 | } else { 207 | currentEngineName = candidateEngineNames[0]; 208 | } 209 | } 210 | if (operationName === 'setFormat' && operationArgs.length > 0) { 211 | var targetFormat = operationArgs[0].toLowerCase(); 212 | if (targetFormat === 'jpg') { 213 | targetFormat = 'jpeg'; 214 | } 215 | targetContentType = 'image/' + targetFormat; 216 | } else if (operationName === 'jpeg' || operationName === 'png' || operationName === 'webp') { 217 | targetContentType = 'image/' + operationName; 218 | } 219 | operations.push({name: operationName, args: operationArgs, usedQueryStringFragment: keyValuePair}); 220 | usedQueryStringFragments.push(keyValuePair); 221 | } 222 | } else { 223 | var operationNameLowerCase = operationName.toLowerCase(), 224 | FilterConstructor = filterConstructorByOperationName[operationNameLowerCase]; 225 | if (FilterConstructor && filters[operationNameLowerCase] !== false) { 226 | operationNames.push(operationNameLowerCase); 227 | flushOperations(); 228 | if (operationNameLowerCase === 'svgfilter' && options.rootPath && options.sourceFilePath) { 229 | operationArgs.push('--root', 'file://' + options.rootPath, '--url', 'file://' + options.sourceFilePath); 230 | } 231 | var filterInfo = { 232 | create: function () { 233 | return new FilterConstructor(operationArgs); 234 | }, 235 | operationName: operationNameLowerCase, 236 | usedQueryStringFragments: [keyValuePair] 237 | }; 238 | filterInfos.push(filterInfo); 239 | usedQueryStringFragments.push(keyValuePair); 240 | if (operationNameLowerCase === 'inkscape') { 241 | var filter = filterInfo.create(); 242 | filterInfo.create = function () { 243 | return filter; 244 | }; 245 | targetContentType = 'image/' + filter.outputFormat; 246 | } 247 | } else { 248 | leftOverQueryStringFragments.push(keyValuePair); 249 | } 250 | } 251 | } 252 | }); 253 | flushOperations(); 254 | 255 | return { 256 | targetContentType: targetContentType, 257 | operationNames: operationNames, 258 | filterInfos: filterInfos, 259 | usedQueryStringFragments: usedQueryStringFragments, 260 | leftOverQueryStringFragments: leftOverQueryStringFragments 261 | }; 262 | }; 263 | -------------------------------------------------------------------------------- /testdata/dialog-information.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | image/svg+xml 150 | 151 | Info 152 | 153 | 154 | Jakub Steiner 155 | 156 | 157 | 158 | 159 | dialog 160 | info 161 | 162 | 163 | http://jimmac.musichall.cz 164 | 165 | 166 | 167 | Garrett LeSage 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | --------------------------------------------------------------------------------