├── .gitignore ├── .jshintrc ├── LICENSE ├── README.md ├── bin ├── image_resizer.js └── templates │ ├── .buildpacks.tmpl │ ├── .env.tmpl │ ├── .gitignore.tmpl │ ├── Procfile.tmpl │ ├── README.md.tmpl │ ├── filter.js.tmpl │ ├── gulpfile.js.tmpl │ └── index.js.tmpl ├── gulpfile.js ├── index.js ├── package.json ├── src ├── config │ ├── development.js │ ├── environment_vars.js │ ├── production.js │ └── test.js ├── image.js ├── lib │ ├── dimensions.js │ └── modifiers.js ├── streams │ ├── filter.js │ ├── filters │ │ ├── blur.js │ │ ├── greyscale.js │ │ └── index.js │ ├── identify.js │ ├── index.js │ ├── optimize.js │ ├── resize.js │ ├── response.js │ └── sources │ │ ├── external.js │ │ ├── facebook.js │ │ ├── index.js │ │ ├── local.js │ │ ├── s3.js │ │ ├── twitter.js │ │ ├── vimeo.js │ │ └── youtube.js └── utils │ ├── logger.js │ └── string.js ├── test.js └── test ├── index.html ├── sample_images ├── image1.jpg ├── image2.jpg └── image3.png └── src ├── image-spec.js └── lib ├── dimensions-spec.js └── modifiers-spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .env 4 | ./filters/ 5 | ./sources/ 6 | npm-debug.log 7 | named_modifiers.json 8 | test/sample_images/dont-version* -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "bitwise": true, 6 | "camelcase": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "undef": true, 17 | "unused": true, 18 | "strict": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "jquery": true 22 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 James Andrew Nicol 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image-Resizer 2 | 3 | `image-resizer` is a [Node.js](http://nodejs.org) application that sits as a custom origin to your CDN and will resize/optimise images on-the-fly. It is Heroku ready, but can also be deployed easily to any cloud provider (has been used with success on AWS). 4 | 5 | The primary goal for this project was to abstract the need to set image dimensions during the upload and storage phase of images in a modern web application. 6 | 7 | 8 | ## Overview 9 | 10 | Building and deploying your own version of `image-resizer` is as easy as running the cli tool (`image-resizer new`), setting your [Heroku configs](#environment-variables) and firing it up! 11 | 12 | Based on Express.js `image-resizer` uses [sharp](https://github.com/lovell/sharp) under the hood to modify and optimise your images. 13 | 14 | There is also a plugin architecture that allows you to add your own image sources. Out of the box it supports: S3, Facebook, Twitter, Youtube, Vimeo (and local file system in development mode). 15 | 16 | When a new image size is requested of `image-resizer` via the CDN, it will pull down the original image from the cloud. It will then resize according to the requested dimensions, optimize according to file type and optionally filter the image. All responses are crafted with custom responses to maximise the facility of the CDN. 17 | 18 | 19 | ## Getting Started 20 | 21 | $ npm install -g image-resizer gulp 22 | $ mkdir my_fancy_image_server 23 | $ cd my_fancy_image_server 24 | $ image-resizer new 25 | $ npm install 26 | $ gulp watch 27 | 28 | This will create a new directory structure including all the necessary files needed to run `image-resizer`. The money file is `index.js` which is loads the express configuration and routes. 29 | 30 | `image-resizer` can also simply be added as a node_module to any project and the streams interfaces used standalone. `./test.js` has a good example of how the app should work running behind Express. 31 | 32 | There is a [RubyGem](https://github.com/jimmynicol/ir-helper) of helpers (both Ruby and Javascript) to assist you in building the endpoints for your `image-resizer` instance. 33 | 34 | 35 | ## Architecture 36 | 37 | The new refactored codebase now takes advantage of node streams. The [previous iteration](https://github.com/jimmynicol/image-resizer/tree/v0.0.1) was heavily based on promises but still ended up with spaghetti code to some extent. 38 | 39 | Inspired a lot by [Gulp](http://gulpjs.com) `image-resizer` passes around an Image object between each of the streams that contains information about the request and the image data (either as a buffer or stream). 40 | 41 | Images are also no longer modified and sent back to s3 for storage. The full power of the CDN is used for storing the modified images. This greatly improves performance both on the server side and client side. Google PageSpeed did not like the 302 redirects returned by an `image-resizer` instance. 42 | 43 | Also removing the need to push data to s3 helps the server processing as this can be a wildly inconsistent action. 44 | 45 | 46 | ## Plugins 47 | 48 | `image-resizer` now supports a range of custom plugins for both image sources and filters. As mentioned above a number of sources are supported out of the box but each of these can be over written as needed. 49 | 50 | The directory structure created via `$ image-resizer new` will include a plugins directory where the initialization script will pick up any scripts and insert them into the application. 51 | 52 | 53 | ## Dependencies 54 | 55 | `image-resizer` only requires a working node/npm environment and `libvips`. The necessary buildpack information to load your Heroku environment is included. 56 | 57 | 58 | ## Environment Variables 59 | 60 | Configuration of `image-resizer` is done via environment variables. This is done to be compatible with Heroku deployments. 61 | 62 | To set environment variables in your [Heroku console](https://devcenter.heroku.com/articles/config-vars). 63 | 64 | heroku config:set AWS_ACCESS_KEY_ID=abcd1234 65 | 66 | For Heroku deployment the minimum required variables are: 67 | 68 | AWS_ACCESS_KEY_ID 69 | AWS_SECRET_ACCESS_KEY 70 | AWS_REGION 71 | S3_BUCKET 72 | NODE_ENV 73 | 74 | If you choose to change your default source to be something other than `S3` then the `NODE_ENV` variable is the only required one (and whatever you need for your default source). 75 | 76 | For convenience in local and non-Heroku deployments the variables can be loaded from a `.env` file. Sensible local defaults are included in `src/config/environment_vars.js`. 77 | 78 | The available variables are as follows: 79 | 80 | ```javascript 81 | NODE_ENV: 'development', 82 | PORT: 3001, 83 | DEFAULT_SOURCE: 's3', 84 | EXCLUDE_SOURCES: null, // add comma delimited list 85 | 86 | // Restrict to named modifiers strings only 87 | NAMED_MODIFIERS_ONLY: false, 88 | 89 | // AWS keys 90 | AWS_ACCESS_KEY_ID: null, 91 | AWS_SECRET_ACCESS_KEY: null, 92 | AWS_REGION: null, 93 | S3_BUCKET: null, 94 | 95 | // Resize options 96 | RESIZE_PROCESS_ORIGINAL: true, 97 | AUTO_ORIENT: true, 98 | REMOVE_METADATA: true, 99 | 100 | // Protect original files by specifying a max image width or height - limits 101 | // max height/width in parameters 102 | MAX_IMAGE_DIMENSION: null, 103 | 104 | // Color used when padding an image with the 'pad' crop modifier. 105 | IMAGE_PADDING_COLOR: 'white', 106 | 107 | // Optimization options 108 | IMAGE_QUALITY: 80, 109 | IMAGE_PROGRESSIVE: true, 110 | 111 | // Cache expiries 112 | IMAGE_EXPIRY: 60 * 60 * 24 * 90, 113 | IMAGE_EXPIRY_SHORT: 60 * 60 * 24 * 2, 114 | JSON_EXPIRY: 60 * 60 * 24 * 30, 115 | 116 | // Logging options 117 | LOG_PREFIX: 'resizer', 118 | QUEUE_LOG: true, 119 | 120 | // Response settings 121 | CACHE_DEV_REQUESTS: false, 122 | 123 | // Twitter settings 124 | TWITTER_CONSUMER_KEY: null, 125 | TWITTER_CONSUMER_SECRET: null, 126 | TWITTER_ACCESS_TOKEN: null, 127 | TWITTER_ACCESS_TOKEN_SECRET: null, 128 | 129 | // Where are the local files kept? 130 | LOCAL_FILE_PATH: process.cwd(), 131 | 132 | // Display an image if a 404 request is encountered from a source 133 | IMAGE_404: null 134 | 135 | // Whitelist arbitrary HTTP source prefixes using EXTERNAL_SOURCE_* 136 | EXTERNAL_SOURCE_WIKIPEDIA: 'https://upload.wikimedia.org/wikipedia/' 137 | ``` 138 | 139 | 140 | ## Optimization 141 | 142 | Optimization of images is done via [sharp](https://github.com/lovell/sharp#qualityquality). The environment variables to set are: 143 | 144 | * `IMAGE_QUALITY`: 1 - 100 145 | * `IMAGE_PROGRESSIVE`: true | false 146 | 147 | You may also adjust the image quality setting per request with the `q` quality modifier described below. 148 | 149 | ## CDN 150 | 151 | While `image-resizer` will work as a standalone app, almost all of its facility is moot unless you run it behind a CDN. This has only been run behind AWS Cloudfront at this point and consequently all of the response headers are customized to work best in that environment. However other CDN's can not operate much differently, any pull requests in this regard would be most appreciated ;-) 152 | 153 | 154 | ## Usage 155 | 156 | A couple of routes are included with the default app, but the most important is the image generation one, which is as follows: 157 | 158 | `http://my.cdn.com/:modifiers/path/to/image.png[:format][:metadata]` 159 | 160 | Modifiers are a dash delimited string of the requested modifications to be made, these include: 161 | 162 | *Supported modifiers are:* 163 | * height: eg. h500 164 | * width: eg. w200 165 | * square: eg. s50 166 | * crop: eg. cfill 167 | * top: eg. y12 168 | * left: eg. x200 169 | * gravity: eg. gs, gne 170 | * filter: eg. fsepia 171 | * external: eg. efacebook 172 | * quality: eg. q90 173 | 174 | *Crop modifiers:* 175 | * fit 176 | * maintain original proportions 177 | * resize so image fits wholly into new dimensions 178 | * eg: h400-w500 - 400x600 -> 333x500 179 | * default option 180 | * fill 181 | * maintain original proportions 182 | * resize via smallest dimension, crop the largest 183 | * crop image all dimensions that dont fit 184 | * eg: h400-w500 - 400x600 -> 400x500 185 | * cut 186 | * maintain original proportions 187 | * no resize, crop to gravity or x/y 188 | * scale 189 | * do not maintain original proportions 190 | * force image to be new dimensions (squishing the image) 191 | * pad 192 | * maintain original proportions 193 | * resize so image fits wholly into new dimensions 194 | * padding added on top/bottom or left/right as needed (color is configurable) 195 | 196 | 197 | *Examples:* 198 | * `http://my.cdn.com/s50/path/to/image.png` 199 | * `http://my.cdn.com/h50/path/to/image.png` 200 | * `http://my.cdn.com/h50-w100/path/to/image.png` 201 | * `http://my.cdn.com/s50-gne/path/to/image.png` 202 | * `http://my.cdn.com/path/to/image.png` - original image request, will be optimized but not resized 203 | 204 | 205 | ## Resizing Logic 206 | 207 | It is worthy of note that this application will not scale images up, we are all about keeping images looking good. So a request for `h400` on an image of only 200px in height will not scale it up. 208 | 209 | 210 | ## S3 source 211 | 212 | By default `image-resizer` will use s3 as the image source. To access an s3 object the full path of the image within the bucket is used, minus the bucket name eg: 213 | 214 | https://s3.amazonaws.com/sample.bucket/test/image.png 215 | 216 | translates to: 217 | 218 | http://my.cdn.com/test/image.png 219 | 220 | 221 | ## External Sources 222 | 223 | It is possible to bring images in from external sources and store them behind your own CDN. This is very useful when it comes to things like Facebook or Vimeo which have very inconsistent load times. Each external source can still enable any of the modification parameters list above. 224 | 225 | In addition to the provided external sources, you can easily add your own basic external sources using `EXTERNAL_SOURCE_*` environment variables. For example, to add Wikipedia as an external source, set the following environment variable: 226 | 227 | ``` 228 | EXTERNAL_SOURCE_WIKIPEDIA: 'https://upload.wikimedia.org/wikipedia/' 229 | ``` 230 | 231 | Then you can request images beginning with the provided path using the `ewikipedia` modifier, eg: 232 | 233 | http://my.cdn.com/ewikipedia/en/7/70/Example.png 234 | 235 | translates to: 236 | 237 | https://upload.wikimedia.org/wikipedia/en/7/70/Example.png 238 | 239 | It is worth noting that Twitter requires a full set of credentials as you need to poll their API in order to return profile pics. 240 | 241 | A shorter expiry on images from social sources can also be set via `IMAGE_EXPIRY_SHORT` env var so they expiry at a faster rate than other images. 242 | 243 | It is also trivial to write new source streams via the plugins directory. Examples are in `src/streams/sources/`. 244 | 245 | ## Output format 246 | 247 | You can convert images to another image format by appending an extra extension to the image path: 248 | 249 | * `http://my.cdn.com/path/to/image.png.webp` 250 | 251 | JPEG (`.jpg`/`.jpeg`), PNG (`.png`), and WEBP (`.webp`) output formats are supported. 252 | 253 | ## Metadata requests 254 | 255 | `image-resizer` can return the image metadata as a json endpoint: 256 | 257 | * `http://my.cdn.com/path/to/image.png.json` 258 | 259 | Metadata is removed in all other image requests by default, unless the env var `REMOVE_METADATA` is set to `false`. 260 | 261 | 262 | ## Heroku Deployment 263 | 264 | Included are both a `.buildpacks` file and a `Procfile` ready for Heroku deployment. Run the following cmd in your Heroku console to enable the correct buildpacks (copied from [here](https://github.com/mcollina/heroku-buildpack-graphicsmagick)). 265 | 266 | heroku config:set BUILDPACK_URL=https://github.com/ddollar/heroku-buildpack-multi 267 | 268 | The `.buildpacks` file will then take care of the installation process. 269 | 270 | As mentioned above there is a minimum set of config vars that need to be set before `image-resizer` runs correctly. 271 | 272 | It is also of note that due to some issues with GCC, `sharp` can not be used on the older Heroku stacks. Currently it requires `cedar-14` stack. 273 | 274 | 275 | ## Local development 276 | 277 | To run `image-resizer` locally, the following will work for an OSX environment assuming you have node/npm installed - [NVM is useful](https://github.com/creationix/nvm). 278 | 279 | ```bash 280 | npm install gulp -g 281 | ./node_modules/image_resizer/node_modules/sharp/preinstall.sh 282 | npm install 283 | gulp watch 284 | ``` 285 | 286 | The gulp setup includes nodemon which runs the app nicely, restarting between code changes. `PORT` can be set in the `.env` file if you need to run on a port other than 3001. 287 | 288 | Tests can be run with: `gulp test` 289 | 290 | 291 | ## Early promise-based version of codebase 292 | 293 | *NOTE:* Completely refactored and improved, if you are looking for the older version it is tagged as [v0.0.1](https://github.com/jimmynicol/image-resizer/tree/v0.0.1). 294 | 295 | -------------------------------------------------------------------------------- /bin/image_resizer.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var program, path, fs, mkdirp, pkg, chalk, _, exec; 6 | 7 | 8 | program = require('commander'); 9 | fs = require('fs'); 10 | mkdirp = require('mkdirp'); 11 | path = require('path'); 12 | chalk = require('chalk'); 13 | pkg = require('../package.json'); 14 | _ = require('lodash'); 15 | exec = require('child_process').exec; 16 | 17 | /** 18 | File/Directory helper functions 19 | */ 20 | function write(path, str, mode) { 21 | fs.writeFileSync(path, str, { mode: mode || '0666' }); 22 | console.log(' ' + chalk.green('create') + ': ' + path); 23 | } 24 | 25 | function copy(from, to) { 26 | write(to, fs.readFileSync(from, 'utf-8')); 27 | } 28 | 29 | function mkdir(path, fn) { 30 | mkdirp.sync(path, '0755'); 31 | console.log(' ' + chalk.green('create') + ': ' + path); 32 | } 33 | 34 | function emptyDirectory(path, fn) { 35 | fs.readdir(path, function(err, files){ 36 | if (err && 'ENOENT' !== err.code) { 37 | throw err; 38 | } 39 | fn(!files || !files.length); 40 | }); 41 | } 42 | 43 | function createApplicationAt(dir){ 44 | var appName, newPkg; 45 | 46 | // Determine the app name from the directory 47 | appName = path.basename(path.resolve(dir)); 48 | 49 | console.log('\n' + chalk.cyan('Creating new ') + chalk.cyan.bold('image-resizer') + chalk.cyan(' app!')); 50 | console.log(); 51 | 52 | // create a new package.json 53 | newPkg = { 54 | name: appName, 55 | version: '1.0.0', 56 | main: 'index.js', 57 | description: 'My awesome image resizing service!', 58 | engines: { 59 | 'node': pkg.engines.node 60 | }, 61 | dependencies: { 62 | 'image-resizer': '~' + pkg.version, 63 | 'express': pkg.dependencies.express, 64 | 'lodash': pkg.dependencies.lodash, 65 | 'chalk': pkg.dependencies.chalk, 66 | 'sharp': pkg.dependencies.sharp 67 | }, 68 | devDependencies: pkg.devDependencies 69 | }; 70 | 71 | write(dir + '/package.json', JSON.stringify(newPkg, null, 2)); 72 | 73 | // create index.js 74 | var indexTmpl = fs.readFileSync(__dirname + '/./templates/index.js.tmpl'); 75 | write(dir + '/index.js', _.template(indexTmpl, {})); 76 | 77 | // create the gulpfile 78 | copy(__dirname + '/./templates/gulpfile.js.tmpl', dir + '/gulpfile.js'); 79 | 80 | // create .env 81 | var envTmpl = fs.readFileSync(__dirname + '/./templates/.env.tmpl'); 82 | write(dir + '/.env', _.template(envTmpl, {cwd: process.cwd()})); 83 | 84 | // create .gitignore 85 | copy(__dirname + '/./templates/.gitignore.tmpl', dir + '/.gitignore'); 86 | 87 | // create .jshintrc 88 | copy(__dirname + '/../.jshintrc', dir + '/.jshintrc'); 89 | 90 | // create Heroku files 91 | copy(__dirname + '/./templates/.buildpacks.tmpl', dir + '/.buildpacks'); 92 | copy(__dirname + '/./templates/Procfile.tmpl', dir + '/Procfile'); 93 | 94 | // create a README 95 | copy(__dirname + '/./templates/README.md.tmpl', dir + '/README.md'); 96 | 97 | // create plugin folders 98 | // - sources 99 | // - filters 100 | mkdir(dir + '/plugins/sources'); 101 | mkdir(dir + '/plugins/filters'); 102 | 103 | 104 | console.log(); 105 | console.log(chalk.green(' now install your dependencies') + ':'); 106 | console.log(' $ npm install'); 107 | console.log(); 108 | console.log(chalk.green(' then to run the app locally') + ':'); 109 | console.log(' $ gulp watch'); 110 | console.log(); 111 | 112 | exec('vips --version', function (err, stdout, stderr) { 113 | if (err || stderr) { 114 | console.log(chalk.yellow(' looks like vips is also missing, run the following to install') + ':'); 115 | console.log(' $ ./node_modules/image_resizer/node_modules/sharp/preinstall.sh'); 116 | console.log(); 117 | } 118 | 119 | console.log(chalk.yellow(' to get up and running on Heroku') + ':'); 120 | console.log(' https://devcenter.heroku.com/articles/getting-started-with-nodejs#introduction'); 121 | console.log(); 122 | }); 123 | } 124 | 125 | /** 126 | Create the program and list the possible commands 127 | */ 128 | program.version(pkg.version); 129 | program.option('-f, --force', 'force app build in an non-empty directory'); 130 | program.command('new') 131 | .description('Create new clean image-resizer app') 132 | .action( function () { 133 | var path = '.'; 134 | emptyDirectory(path, function(empty) { 135 | if (empty || program.force){ 136 | createApplicationAt(path); 137 | } 138 | else { 139 | console.log( 140 | chalk.red('\n The current directory is not empty, please use the force (-f) option to proceed.\n') 141 | ); 142 | } 143 | }); 144 | }); 145 | program.command('filter ') 146 | .description('Create new filter stream') 147 | .action( function (filterName) { 148 | copy(__dirname + '/./templates/filter.js.tmpl', './plugins/filters/' + filterName + '.js'); 149 | }); 150 | program.parse(process.argv); 151 | -------------------------------------------------------------------------------- /bin/templates/.buildpacks.tmpl: -------------------------------------------------------------------------------- 1 | https://github.com/mcollina/heroku-buildpack-graphicsmagick 2 | https://github.com/alex88/heroku-buildpack-vips.git 3 | https://github.com/heroku/heroku-buildpack-nodejs -------------------------------------------------------------------------------- /bin/templates/.env.tmpl: -------------------------------------------------------------------------------- 1 | AWS_ACCESS_KEY_ID= 2 | AWS_SECRET_ACCESS_KEY= 3 | AWS_REGION=us-east-1 4 | S3_BUCKET= 5 | PORT=3003 6 | LOCAL_FILE_PATH=<%= cwd %>/node_modules/image-resizer -------------------------------------------------------------------------------- /bin/templates/.gitignore.tmpl: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .env 4 | npm-debug.log -------------------------------------------------------------------------------- /bin/templates/Procfile.tmpl: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /bin/templates/README.md.tmpl: -------------------------------------------------------------------------------- 1 | # A Heroku ready image resizing service. 2 | 3 | Welcome to easy image resizing using the `image-resizer` service, a Heroku ready app using Node.js as a scalable backend to your platform. 4 | 5 | For more detailed instructions take a look at the [parent repo](https://github.com/jimmynicol/image-resizer). Any question please file them on [Github](https://github.com/jimmynicol/image-resizer/issues). -------------------------------------------------------------------------------- /bin/templates/filter.js.tmpl: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function(image, callback){ 5 | 6 | // apply the filter and pass the stream 7 | // eg: r.sepia().stream(callback); 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /bin/templates/gulpfile.js.tmpl: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'), 4 | nodemon = require('gulp-nodemon'), 5 | mocha = require('gulp-mocha'), 6 | jshint = require('gulp-jshint'), 7 | stylish = require('jshint-stylish'), 8 | util = require('gulp-util'), 9 | _ = require('lodash'); 10 | 11 | 12 | gulp.task('lint', function () { 13 | gulp.src(['plugins/**/*.js', 'index.js', 'gulpfile.js']) 14 | .pipe(jshint('.jshintrc')) 15 | .pipe(jshint.reporter(stylish)); 16 | }); 17 | gulp.task('lint:watch', ['lint'], function(){ 18 | gulp.watch( 19 | ['plugins/**/*.js', 'index.js'], 20 | function(event){ 21 | util.log('file changed:', util.colors.green(event.path)); 22 | gulp.src(event.path) 23 | .pipe(jshint('.jshintrc')) 24 | .pipe(jshint.reporter(stylish)); 25 | } 26 | ); 27 | }); 28 | 29 | gulp.task('test', function () { 30 | gulp.src(['node_modules/image_resizer/test/**/*.js']) 31 | .pipe(mocha({reporter: 'nyan'})) 32 | .on('error', function(err){ 33 | console.log(err.toString()); 34 | this.emit('end'); 35 | }); 36 | }); 37 | 38 | function env(){ 39 | var dotenv = require('dotenv'), 40 | fs = require('fs'), 41 | config = {}, 42 | file, 43 | home = process.env.HOME; 44 | 45 | // useful for storing common AWS credentials with other apps 46 | if ( fs.existsSync(home + '/.awsrc') ){ 47 | file = fs.readFileSync(home + '/.awsrc'); 48 | _.extend(config, dotenv.parse(file)); 49 | } 50 | 51 | if ( fs.existsSync('.env') ){ 52 | file = fs.readFileSync('.env'); 53 | _.extend(config, dotenv.parse(file)); 54 | } 55 | 56 | // print out the env vars 57 | _.each(config, function(value, key){ 58 | util.log('Env:', key, util.colors.cyan(value)); 59 | }); 60 | 61 | return config; 62 | } 63 | 64 | gulp.task('watch', ['lint'], function () { 65 | nodemon({ 66 | script: 'index.js', 67 | ext: 'js', 68 | env: env(), 69 | ignore: ['node_modules/**/*.js'] 70 | }).on('restart', ['lint']); 71 | }); 72 | -------------------------------------------------------------------------------- /bin/templates/index.js.tmpl: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var express, app, ir, env, Img, streams; 4 | 5 | express = require('express'); 6 | app = express(); 7 | ir = require('image-resizer'); 8 | env = ir.env; 9 | Img = ir.img; 10 | streams = ir.streams; 11 | 12 | if (env.development) { 13 | var exec = require('child_process').exec; 14 | var chalk = require('chalk'); 15 | 16 | // check to see if vips is installed 17 | exec ('vips --version', function (err, stdout, stderr) { 18 | if (err || stderr) { 19 | console.error( 20 | chalk.red('\nMissing dependency:'), 21 | chalk.red.bold('libvips') 22 | ); 23 | console.log( 24 | chalk.cyan(' to install vips on your system run:'), 25 | chalk.bold('./node_modules/image_resizer/node_modules/sharp/preinstall.sh\n') 26 | ); 27 | } 28 | }); 29 | } 30 | 31 | app.directory = __dirname; 32 | ir.expressConfig(app); 33 | 34 | app.get('/favicon.ico', function (request, response) { 35 | response.sendStatus(404); 36 | }); 37 | 38 | /** 39 | Return the modifiers map as a documentation endpoint 40 | */ 41 | app.get('/modifiers.json', function(request, response){ 42 | response.json(ir.modifiers); 43 | }); 44 | 45 | 46 | /** 47 | Some helper endpoints when in development 48 | */ 49 | if (env.development){ 50 | // Show a test page of the image options 51 | app.get('/test-page', function(request, response){ 52 | response.render('index.html'); 53 | }); 54 | 55 | // Show the environment variables and their current values 56 | app.get('/env', function(request, response){ 57 | response.json(env); 58 | }); 59 | } 60 | 61 | 62 | /* 63 | Return an image modified to the requested parameters 64 | - request format: 65 | /:modifers/path/to/image.format:metadata 66 | eg: https://my.cdn.com/s50/sample/test.png 67 | */ 68 | app.get('/*?', function(request, response){ 69 | var image = new Img(request); 70 | 71 | image.getFile() 72 | .pipe(new streams.identify()) 73 | .pipe(new streams.resize()) 74 | .pipe(new streams.filter()) 75 | .pipe(new streams.optimize()) 76 | .pipe(streams.response(request, response)); 77 | }); 78 | 79 | 80 | /** 81 | Start the app on the listed port 82 | */ 83 | app.listen(app.get('port')); 84 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'), 4 | nodemon = require('gulp-nodemon'), 5 | mocha = require('gulp-mocha'), 6 | jshint = require('gulp-jshint'), 7 | stylish = require('jshint-stylish'), 8 | util = require('gulp-util'), 9 | bump = require('gulp-bump'), 10 | _ = require('lodash'); 11 | 12 | 13 | gulp.task('lint', function () { 14 | gulp.src(['src/**/*.js', 'index.js', 'test.js', 'gulpfile.js']) 15 | .pipe(jshint('.jshintrc')) 16 | .pipe(jshint.reporter(stylish)); 17 | }); 18 | gulp.task('lint:watch', ['lint'], function(){ 19 | gulp.watch( 20 | ['src/**/*.js', 'bin/**/*.js'], 21 | function(event){ 22 | util.log('file changed:', util.colors.green(event.path)); 23 | gulp.src(event.path) 24 | .pipe(jshint('.jshintrc')) 25 | .pipe(jshint.reporter(stylish)); 26 | } 27 | ); 28 | }); 29 | 30 | gulp.task('test', function () { 31 | gulp.src('test/**/*.js') 32 | .pipe(mocha({reporter: 'nyan'})) 33 | .on('error', function(err){ 34 | console.log(err.toString()); 35 | this.emit('end'); 36 | }); 37 | }); 38 | gulp.task('test:watch', ['lint', 'test'], function (){ 39 | gulp.watch( 40 | ['src/**/*.js', 'test/**/*.js'], 41 | ['lint', 'test'] 42 | ); 43 | }); 44 | 45 | function env(){ 46 | var dotenv = require('dotenv'), 47 | fs = require('fs'), 48 | config = {}, 49 | file, 50 | home = process.env.HOME; 51 | 52 | // useful for storing common AWS credentials with other apps 53 | if ( fs.existsSync(home + '/.awsrc') ){ 54 | file = fs.readFileSync(home + '/.awsrc'); 55 | _.extend(config, dotenv.parse(file)); 56 | } 57 | 58 | if ( fs.existsSync('.env') ){ 59 | file = fs.readFileSync('.env'); 60 | _.extend(config, dotenv.parse(file)); 61 | } 62 | 63 | // print out the env vars 64 | _.each(config, function(value, key){ 65 | util.log('Env:', key, util.colors.cyan(value)); 66 | }); 67 | 68 | return config; 69 | } 70 | 71 | // gulp.task('watch', ['lint', 'test'], function () { 72 | gulp.task('watch', ['lint'], function () { 73 | nodemon({ 74 | script: 'test.js', 75 | ext: 'js html', 76 | env: env() 77 | // }).on('restart', ['lint', 'test']); 78 | }).on('restart', ['lint']); 79 | }); 80 | 81 | gulp.task('bump:patch', function(){ 82 | gulp.src('./package.json') 83 | .pipe(bump({type: 'patch'})) 84 | .pipe(gulp.dest('./')); 85 | }); 86 | gulp.task('bump:minor', function(){ 87 | gulp.src('./package.json') 88 | .pipe(bump({type: 'minor'})) 89 | .pipe(gulp.dest('./')); 90 | }); 91 | gulp.task('bump:major', function(){ 92 | gulp.src('./package.json') 93 | .pipe(bump({type: 'major'})) 94 | .pipe(gulp.dest('./')); 95 | }); 96 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var env = require('./src/config/environment_vars'); 4 | 5 | module.exports = { 6 | 7 | img: require('./src/image'), 8 | streams: require('./src/streams'), 9 | sources: require('./src/streams/sources'), 10 | filter: require('./src/streams/filter'), 11 | modifiers: require('./src/lib/modifiers').map, 12 | 13 | env: env, 14 | expressConfig: require('./src/config/' + env.NODE_ENV) 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-resizer", 3 | "version": "1.3.0", 4 | "description": "On-the-fly image resizing and optimization using node and sharp (libvips). Heroku ready!", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "gulp test" 8 | }, 9 | "keywords": [ 10 | "image", 11 | "resize", 12 | "node", 13 | "sharp", 14 | "libvips", 15 | "heroku", 16 | "optimization" 17 | ], 18 | "author": "James Nicol (https://github.com/jimmynicol)", 19 | "license": "MIT", 20 | "repository": "git://github.com/jimmynicol/image-resizer", 21 | "bin": { 22 | "image-resizer": "./bin/image_resizer.js" 23 | }, 24 | "engines": { 25 | "node": "0.12.x" 26 | }, 27 | "dependencies": { 28 | "aws-sdk": "~2.0.0-rc9", 29 | "chalk": "~1.0.0", 30 | "commander": "^2.2.0", 31 | "concat-stream": "~1.4.5", 32 | "errorhandler": "^1.0.1", 33 | "express": "^4.9.7", 34 | "glob": "~3.2.9", 35 | "image-type": "^2.0.2", 36 | "lodash": "~2.4.1", 37 | "map-stream": "~0.1.0", 38 | "mkdirp": "^0.5.0", 39 | "morgan": "^1.0.1", 40 | "request": "~2.34.0", 41 | "sharp": "^0.11.3", 42 | "twit": "~1.1.15" 43 | }, 44 | "devDependencies": { 45 | "chai": "~1.9.0", 46 | "connect-livereload": "~0.3.2", 47 | "dotenv": "~0.2.8", 48 | "ejs": "~1.0.0", 49 | "gulp": "^3.8.10", 50 | "gulp-bump": "^0.1.8", 51 | "gulp-jshint": "~1.4.2", 52 | "gulp-mocha": "~0.4.1", 53 | "gulp-nodemon": "~1.0.0", 54 | "gulp-util": "~2.2.14", 55 | "jshint-stylish": "~0.1.5", 56 | "sandboxed-module": "^1.0.0", 57 | "sinon": "~1.8.2", 58 | "sinon-chai": "~2.5.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/config/development.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | var env, express, morgan, errorHandler; 5 | 6 | env = require('./environment_vars'); 7 | express = require('express'); 8 | morgan = require('morgan'); 9 | errorHandler = require('errorhandler'); 10 | 11 | module.exports = function(app){ 12 | 13 | app.set('views', env.LOCAL_FILE_PATH + '/test'); 14 | app.engine('html', require('ejs').renderFile); 15 | app.set('port', env.PORT || 3001); 16 | app.use(morgan('dev')); 17 | app.use(errorHandler()); 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /src/config/environment_vars.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _, vars; 4 | 5 | _ = require('lodash'); 6 | 7 | vars = { 8 | 9 | NODE_ENV: 'development', 10 | PORT: 3001, 11 | DEFAULT_SOURCE: 's3', 12 | EXCLUDE_SOURCES: null, // add comma delimited list 13 | 14 | // Restrict to named modifiers strings only 15 | NAMED_MODIFIERS_ONLY: false, 16 | 17 | // AWS keys 18 | AWS_ACCESS_KEY_ID: null, 19 | AWS_SECRET_ACCESS_KEY: null, 20 | AWS_REGION: null, 21 | S3_BUCKET: null, 22 | 23 | // Resize options 24 | RESIZE_PROCESS_ORIGINAL: true, 25 | AUTO_ORIENT: true, 26 | REMOVE_METADATA: true, 27 | 28 | // Protect original files by specifying a max image width or height - limits 29 | // max height/width in parameters 30 | MAX_IMAGE_DIMENSION: null, 31 | 32 | // Color used when padding an image with the 'pad' crop modifier. 33 | IMAGE_PADDING_COLOR: 'white', 34 | 35 | // Optimization options 36 | IMAGE_PROGRESSIVE: true, 37 | IMAGE_QUALITY: 80, 38 | 39 | // Cache expiries 40 | IMAGE_EXPIRY: 60 * 60 * 24 * 90, 41 | IMAGE_EXPIRY_SHORT: 60 * 60 * 24 * 2, 42 | JSON_EXPIRY: 60 * 60 * 24 * 30, 43 | 44 | // Logging options 45 | LOG_PREFIX: 'resizer', 46 | QUEUE_LOG: true, 47 | 48 | // Response settings 49 | CACHE_DEV_REQUESTS: false, 50 | 51 | // Twitter settings 52 | TWITTER_CONSUMER_KEY: null, 53 | TWITTER_CONSUMER_SECRET: null, 54 | TWITTER_ACCESS_TOKEN: null, 55 | TWITTER_ACCESS_TOKEN_SECRET: null, 56 | 57 | // Where are the local files kept? 58 | LOCAL_FILE_PATH: process.cwd(), 59 | 60 | // Display an image if a 404 request is encountered from a source 61 | IMAGE_404: null, 62 | 63 | // Whitelist arbitrary HTTP source prefixes using EXTERNAL_SOURCE_* 64 | EXTERNAL_SOURCE_WIKIPEDIA: 'https://upload.wikimedia.org/wikipedia/' 65 | 66 | }; 67 | 68 | _.forEach(vars, function(value, key){ 69 | var keyType = typeof vars[key]; 70 | 71 | if (_.has(process.env, key)){ 72 | vars[key] = process.env[key]; 73 | 74 | if (keyType === 'number') { 75 | vars[key] = +(vars[key]); 76 | } 77 | 78 | // cast any boolean strings to proper boolean values 79 | if (vars[key] === 'true'){ 80 | vars[key] = true; 81 | } 82 | if (vars[key] === 'false'){ 83 | vars[key] = false; 84 | } 85 | } 86 | 87 | }); 88 | 89 | // Add external sources from environment vars 90 | vars.externalSources = {}; 91 | Object.keys(vars).concat(Object.keys(process.env)).filter(function(key) { 92 | return (/^EXTERNAL_SOURCE_/).test(key); 93 | }).forEach(function(key) { 94 | vars.externalSources[key.substr('EXTERNAL_SOURCE_'.length).toLowerCase()] = process.env[key] || vars[key]; 95 | }); 96 | 97 | // A few helpers to quickly determine the environment 98 | vars.development = vars.NODE_ENV === 'development'; 99 | vars.test = vars.NODE_ENV === 'test'; 100 | vars.production = vars.NODE_ENV === 'production'; 101 | 102 | 103 | module.exports = vars; 104 | -------------------------------------------------------------------------------- /src/config/production.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | var express, morgan, errorHandler; 5 | 6 | express = require('express'); 7 | morgan = require('morgan'); 8 | errorHandler = require('errorhandler'); 9 | 10 | module.exports = function(app){ 11 | 12 | app.set('port', process.env.PORT || 3001); 13 | app.use(morgan('dev')); 14 | app.use(errorHandler()); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/config/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | var express, morgan, errorHandler; 5 | 6 | express = require('express'); 7 | morgan = require('morgan'); 8 | errorHandler = require('errorhandler'); 9 | 10 | module.exports = function(app){ 11 | 12 | app.set('port', process.env.PORT || 3001); 13 | app.use(morgan('dev')); 14 | app.use(errorHandler()); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /src/image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _, Logger, env, modifiers, stream, util, imgType; 4 | 5 | _ = require('lodash'); 6 | Logger = require('./utils/logger'); 7 | env = require('./config/environment_vars'); 8 | modifiers = require('./lib/modifiers'); 9 | stream = require('stream'); 10 | util = require('util'); 11 | imgType = require('image-type'); 12 | 13 | 14 | // Simple stream to represent an error at an early stage, for instance a 15 | // request to an excluded source. 16 | function ErrorStream(image){ 17 | stream.Readable.call(this, { objectMode : true }); 18 | this.image = image; 19 | } 20 | util.inherits(ErrorStream, stream.Readable); 21 | 22 | ErrorStream.prototype._read = function(){ 23 | this.push(this.image); 24 | this.push(null); 25 | }; 26 | 27 | 28 | function Image(request){ 29 | // placeholder for any error objects 30 | this.error = null; 31 | 32 | // set a mark for the start of the process 33 | this.mark = Date.now(); 34 | 35 | // determine the name and format (mime) of the requested image 36 | this.parseImage(request); 37 | 38 | // determine the requested modifications 39 | this.modifiers = modifiers.parse(request.path); 40 | 41 | // pull the various parts needed from the request params 42 | this.parseUrl(request); 43 | 44 | // placeholder for the buffer/stream coming from s3, will hold the image 45 | this.contents = null; 46 | 47 | // placeholder for the size of the original image 48 | this.originalContentLength = 0; 49 | 50 | // set the default expiry length, can be altered by a source file 51 | this.expiry = env.IMAGE_EXPIRY; 52 | 53 | // all logging strings will be queued here to be written on response 54 | this.log = new Logger(); 55 | } 56 | 57 | Image.validInputFormats = ['jpeg', 'jpg', 'gif', 'png', 'webp']; 58 | Image.validOutputFormats = ['jpeg', 'png', 'webp']; 59 | 60 | // Determine the name and format of the requested image 61 | Image.prototype.parseImage = function(request){ 62 | var fileStr = _.last(request.path.split('/')); 63 | var exts = fileStr.split('.').map( function (item) { 64 | return item.toLowerCase(); 65 | }); 66 | 67 | // clean out any metadata format 68 | if (exts[exts.length - 1] === 'json') { 69 | this.format = exts[exts.length - 2]; 70 | exts.pop(); 71 | fileStr = exts.join('.'); 72 | } 73 | 74 | // if path contains valid output format, remove it from path 75 | if (exts.length >= 3) { 76 | var inputFormat = exts[exts.length - 2]; 77 | var outputFormat = exts.pop(); 78 | 79 | if (_.indexOf(Image.validInputFormats, inputFormat) > -1 && 80 | _.indexOf(Image.validOutputFormats, outputFormat) > -1) { 81 | this.outputFormat = outputFormat; 82 | fileStr = exts.join('.'); 83 | } 84 | } 85 | 86 | this.image = fileStr; 87 | }; 88 | 89 | 90 | // Determine the file path for the requested image 91 | Image.prototype.parseUrl = function(request){ 92 | var parts = request.path.replace(/^\//,'').split('/'); 93 | 94 | // overwrite the image name with the parsed version so metadata requests do 95 | // not mess things up 96 | parts[parts.length - 1] = this.image; 97 | 98 | // if there is a modifier string remove it 99 | if (this.modifiers.hasModStr) { 100 | parts.shift(); 101 | } 102 | 103 | this.path = parts.join('/'); 104 | 105 | // account for any spaces in the path 106 | this.path = decodeURI(this.path); 107 | }; 108 | 109 | 110 | Image.prototype.isError = function(){ return this.error !== null; }; 111 | 112 | 113 | Image.prototype.isStream = function(){ 114 | var Stream = require('stream').Stream; 115 | return !!this.contents && this.contents instanceof Stream; 116 | }; 117 | 118 | 119 | Image.prototype.isBuffer = function(){ 120 | return !!this.contents && Buffer.isBuffer(this.contents); 121 | }; 122 | 123 | 124 | Image.prototype.getFile = function(){ 125 | var sources = require('./streams/sources'), 126 | excludes = env.EXCLUDE_SOURCES ? env.EXCLUDE_SOURCES.split(',') : [], 127 | streamType = env.DEFAULT_SOURCE, 128 | Stream = null; 129 | 130 | // look to see if the request has a specified source 131 | if (_.has(this.modifiers, 'external')){ 132 | if (_.has(sources, this.modifiers.external)){ 133 | streamType = this.modifiers.external; 134 | } else if (_.has(env.externalSources, this.modifiers.external)) { 135 | Stream = sources.external; 136 | return new Stream(this, this.modifiers.external, env.externalSources[this.modifiers.external]); 137 | } 138 | } 139 | 140 | // if this request is for an excluded source create an ErrorStream 141 | if (excludes.indexOf(streamType) > -1){ 142 | this.error = new Error(streamType + ' is an excluded source'); 143 | Stream = ErrorStream; 144 | } 145 | 146 | // if all is well find the appropriate stream 147 | else { 148 | this.log.log('new stream created!'); 149 | Stream = sources[streamType]; 150 | } 151 | 152 | return new Stream(this); 153 | }; 154 | 155 | 156 | Image.prototype.sizeReduction = function(){ 157 | var size = this.contents.length; 158 | return (this.originalContentLength - size)/1000; 159 | }; 160 | 161 | 162 | Image.prototype.sizeSaving = function(){ 163 | var oCnt = this.originalContentLength, 164 | size = this.contents.length; 165 | return ((oCnt - size)/oCnt * 100).toFixed(2); 166 | }; 167 | 168 | 169 | Image.prototype.isFormatValid = function () { 170 | if (Image.validInputFormats.indexOf(this.format) === -1) { 171 | this.error = new Error( 172 | 'The listed format (' + this.format + ') is not valid.' 173 | ); 174 | } 175 | }; 176 | 177 | // Setter/getter for image format that normalizes jpeg formats 178 | Object.defineProperty(Image.prototype, 'format', { 179 | get: function () { return this._format; }, 180 | set: function (value) { 181 | this._format = value.toLowerCase(); 182 | if (this._format === 'jpg') { this._format = 'jpeg'; } 183 | } 184 | }); 185 | 186 | // Setter/getter for image contents that determines the format from the content 187 | // of the image to be processed. 188 | Object.defineProperty(Image.prototype, 'contents', { 189 | get: function () { return this._contents; }, 190 | set: function (data) { 191 | this._contents = data; 192 | 193 | if (this.isBuffer()) { 194 | this.format = imgType(data).ext; 195 | this.isFormatValid(); 196 | } 197 | } 198 | }); 199 | 200 | 201 | module.exports = Image; 202 | -------------------------------------------------------------------------------- /src/lib/dimensions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | 5 | 6 | function gravity(g, width, height, cropWidth, cropHeight){ 7 | var x, y; 8 | 9 | // set the default x/y, same as gravity 'c' for center 10 | x = width/2 - cropWidth/2; 11 | y = height/2 - cropHeight/2; 12 | 13 | switch(g){ 14 | case 'n': 15 | y = 0; 16 | break; 17 | case 'ne': 18 | x = width - cropWidth; 19 | y = 0; 20 | break; 21 | case 'nw': 22 | x = 0; 23 | y = 0; 24 | break; 25 | case 's': 26 | y = height - cropHeight; 27 | break; 28 | case 'se': 29 | x = width - cropWidth; 30 | y = height - cropHeight; 31 | break; 32 | case 'sw': 33 | x = 0; 34 | y = height - cropHeight; 35 | break; 36 | case 'e': 37 | x = width - cropWidth; 38 | break; 39 | case 'w': 40 | x = 0; 41 | break; 42 | } 43 | 44 | // make sure we do not return numbers less than zero 45 | if (x < 0){ x = 0; } 46 | if (y < 0){ y = 0; } 47 | 48 | return { 49 | x: Math.floor(x), 50 | y: Math.floor(y) 51 | }; 52 | } 53 | exports.gravity = gravity; 54 | 55 | 56 | function xy(modifiers, width, height, cropWidth, cropHeight){ 57 | var x,y, dims; 58 | 59 | dims = gravity(modifiers.gravity, width, height, cropWidth, cropHeight); 60 | 61 | if (_.has(modifiers, 'x')){ 62 | x = modifiers.x; 63 | if (x <= width - cropWidth){ 64 | dims.x = modifiers.x; 65 | }else{ 66 | // don't ignore modifier dimension 67 | // instead, place within bounds 68 | dims.x = width - cropWidth; 69 | } 70 | } 71 | 72 | if (_.has(modifiers, 'y')){ 73 | y = modifiers.y; 74 | if (y <= height - cropHeight){ 75 | dims.y = modifiers.y; 76 | }else{ 77 | // don't ignore modifier dimension 78 | // instead, place within bounds 79 | dims.y = height - cropHeight; 80 | } 81 | } 82 | 83 | return dims; 84 | } 85 | exports.xy = xy; 86 | 87 | 88 | exports.cropFill = function(modifiers, size){ 89 | var wd, ht, 90 | newWd, newHt, 91 | cropWidth, cropHeight, 92 | crop; 93 | 94 | if (modifiers.width === null){ 95 | modifiers.width = modifiers.height; 96 | } 97 | if (modifiers.height === null){ 98 | modifiers.height = modifiers.width; 99 | } 100 | 101 | if (modifiers.width > size.width && modifiers.height <= size.height) { 102 | cropWidth = size.width; 103 | cropHeight = modifiers.height; 104 | } else if (modifiers.width <= size.width && modifiers.height > size.height) { 105 | cropWidth = modifiers.width; 106 | cropHeight = size.height; 107 | } else if (modifiers.width > size.width && modifiers.height > size.height) { 108 | cropWidth = size.width; 109 | cropHeight = size.height; 110 | } else { 111 | cropWidth = modifiers.width; 112 | cropHeight = modifiers.height; 113 | } 114 | 115 | wd = newWd = cropWidth; 116 | ht = newHt = Math.round(newWd*(size.height/size.width)); 117 | 118 | if(newHt < cropHeight) { 119 | ht = newHt = cropHeight; 120 | wd = newWd = Math.round(newHt*(size.width/size.height)); 121 | } 122 | 123 | // get the crop X/Y as defined by the gravity or x/y modifiers 124 | crop = xy(modifiers, newWd, newHt, cropWidth, cropHeight); 125 | 126 | return { 127 | resize: { 128 | width: wd, 129 | height: ht 130 | }, 131 | crop: { 132 | width: cropWidth, 133 | height: cropHeight, 134 | x: crop.x, 135 | y: crop.y 136 | } 137 | }; 138 | }; 139 | 140 | -------------------------------------------------------------------------------- /src/lib/modifiers.js: -------------------------------------------------------------------------------- 1 | /** 2 | Image modifier utilities 3 | 4 | Sample modifier strings, separated by a dash 5 | 6 | - /s50/path/to/image.png 7 | - /s50-gne/path/to/image.png 8 | - /w300-h200/path/to/image.png 9 | - /image.jpg 10 | - /path/to/image.png 11 | - /path/to/image.png.json 12 | 13 | 14 | Supported modifiers are: 15 | 16 | - height: eg. h500 17 | - width: eg. w200 18 | - square: eg. s50 19 | - crop: eg. cfill 20 | - top: eg. y12 21 | - left: eg. x200 22 | - gravity: eg. gs, gne 23 | - filter: eg. fsepia 24 | - external: eg. efacebook 25 | - quality: eg. q90 26 | 27 | Crop modifiers: 28 | fit 29 | - maintain original proportions 30 | - resize so image fits wholly into new dimensions 31 | - eg: h400-w500 - 400x600 -> 333x500 32 | - default option 33 | fill 34 | - maintain original proportions 35 | - resize via smallest dimension, crop the largest 36 | - crop image all dimensions that dont fit 37 | - eg: h400-w500 - 400x600 -> 400x500 38 | cut 39 | - maintain original proportions 40 | - no resize, crop to gravity or x/y 41 | scale 42 | - do not maintain original proportions 43 | - force image to be new dimensions (squishing the image) 44 | pad 45 | - maintain original proportions 46 | - resize so image fits wholly into new dimensions 47 | - padding added on top/bottom or left/right as needed (color is configurable) 48 | 49 | */ 50 | 'use strict'; 51 | 52 | 53 | var _, string, filters, sources, filterKeys, sourceKeys, modifierMap, 54 | modKeys, env, environment, fs, namedModifierMap; 55 | 56 | _ = require('lodash'); 57 | string = require('../utils/string'); 58 | filters = require('../streams/filters'); 59 | sources = require('../streams/sources'); 60 | filterKeys = _.keys(filters); 61 | environment = require('../config/environment_vars'); 62 | sourceKeys = _.keys(sources).concat(_.keys(environment.externalSources)); 63 | fs = require('fs'); 64 | 65 | 66 | modifierMap = [ 67 | { 68 | key: 'h', 69 | desc: 'height', 70 | type: 'integer' 71 | }, 72 | { 73 | key: 'w', 74 | desc: 'width', 75 | type: 'integer' 76 | }, 77 | { 78 | key: 's', 79 | desc: 'square', 80 | type: 'integer' 81 | }, 82 | { 83 | key: 'y', 84 | desc: 'top', 85 | type: 'integer' 86 | }, 87 | { 88 | key: 'x', 89 | desc: 'left', 90 | type: 'integer' 91 | }, 92 | { 93 | key: 'g', 94 | desc: 'gravity', 95 | type: 'string', 96 | values: ['c','n','s','e','w','ne','nw','se','sw'], 97 | default: 'c' 98 | }, 99 | { 100 | key: 'c', 101 | desc: 'crop', 102 | type: 'string', 103 | values: ['fit','fill','cut','scale','pad'], 104 | default: 'fit' 105 | }, 106 | { 107 | key: 'e', 108 | desc: 'external', 109 | type: 'string', 110 | values: sourceKeys, 111 | default: environment.DEFAULT_SOURCE 112 | }, 113 | { 114 | key: 'f', 115 | desc: 'filter', 116 | type: 'string', 117 | values: filterKeys 118 | }, 119 | { 120 | key: 'q', 121 | desc: 'quality', 122 | type: 'integer', 123 | range: [1, 100], 124 | default: environment.IMAGE_QUALITY 125 | } 126 | ]; 127 | 128 | exports.map = modifierMap; 129 | 130 | modKeys = _.map(modifierMap, function(value){ 131 | return value.key; 132 | }); 133 | 134 | 135 | function inArray(key, array){ 136 | return _.indexOf(array, key) > -1; 137 | } 138 | 139 | function getModifier(key){ 140 | var i, mod; 141 | 142 | for (i in modifierMap){ 143 | mod = modifierMap[i]; 144 | if (mod.key === key){ 145 | return mod; 146 | } 147 | } 148 | return null; 149 | } 150 | 151 | exports.mod = getModifier; 152 | 153 | // Check to see if there is a config file of named modifier aliases 154 | if (fs.existsSync(process.cwd() + '/named_modifiers.json')){ 155 | var file = fs.readFileSync(process.cwd() + '/named_modifiers.json'); 156 | namedModifierMap = JSON.parse(file); 157 | } 158 | 159 | 160 | // Take an array of modifiers and parse the keys and values into mods hash 161 | function parseModifiers(mods, modArr) { 162 | var key, value, mod; 163 | 164 | _.each(modArr, function(item){ 165 | key = item[0]; 166 | value = item.slice(1); 167 | 168 | if (inArray(key, modKeys)){ 169 | 170 | // get the modifier object that responds to the listed key 171 | mod = getModifier(key); 172 | 173 | //this is a limit enforced by sharp. the application will crash without 174 | //these checks. 175 | var dimensionLimit = 16383; 176 | 177 | switch(mod.desc){ 178 | case 'height': 179 | mods.height = string.sanitize(value); 180 | if (mods.height > dimensionLimit) { 181 | mods.height = dimensionLimit; 182 | } 183 | mods.hasModStr = true; 184 | break; 185 | case 'width': 186 | mods.width = string.sanitize(value); 187 | if (mods.width > dimensionLimit) { 188 | mods.width = dimensionLimit; 189 | } 190 | mods.hasModStr = true; 191 | break; 192 | case 'square': 193 | mods.action = 'square'; 194 | mods.height = string.sanitize(value); 195 | mods.width = string.sanitize(value); 196 | mods.hasModStr = true; 197 | break; 198 | case 'gravity': 199 | value = string.sanitize(value, 'alpha'); 200 | if (inArray(value.toLowerCase(), mod.values)){ 201 | mods.gravity = value.toLowerCase(); 202 | } 203 | mods.hasModStr = true; 204 | break; 205 | case 'top': 206 | mods.y = string.sanitize(value); 207 | mods.hasModStr = true; 208 | break; 209 | case 'left': 210 | mods.x = string.sanitize(value); 211 | mods.hasModStr = true; 212 | break; 213 | case 'crop': 214 | value = string.sanitize(value, 'alpha'); 215 | if (inArray(value.toLowerCase(), mod.values)){ 216 | mods.crop = value.toLowerCase(); 217 | } 218 | mods.hasModStr = true; 219 | break; 220 | case 'external': 221 | value = string.sanitize(value, 'alphanumeric'); 222 | if (inArray(value.toLowerCase(), mod.values)){ 223 | mods.external = value.toLowerCase(); 224 | } 225 | mods.hasModStr = true; 226 | break; 227 | case 'filter': 228 | value = string.sanitize(value, 'alpha'); 229 | if (inArray(value.toLowerCase(), mod.values)){ 230 | mods.filter = value.toLowerCase(); 231 | } 232 | mods.hasModStr = true; 233 | break; 234 | case 'quality': 235 | value = string.sanitize(value); 236 | if(!isNaN(value)) { 237 | var min = mod.range[0], 238 | max = mod.range[1]; 239 | mods.quality = Math.max(min, Math.min(max, value)); 240 | } 241 | mods.hasModStr = true; 242 | break; 243 | } 244 | 245 | } 246 | }); 247 | 248 | return mods; 249 | } 250 | 251 | /** 252 | * @param {Object} mods 253 | * @return {Object} mods with limited width /height 254 | */ 255 | var limitMaxDimension = function(mods, env){ 256 | // check to see if 257 | // a max image dimension has been specified 258 | // and limits the current dimension to that maximum 259 | var limitDimension = function(dimension, mods){ 260 | if(!env.MAX_IMAGE_DIMENSION){ 261 | return mods; 262 | } 263 | var maxDimension = parseInt(env.MAX_IMAGE_DIMENSION, 10); 264 | if(dimension in mods && mods[dimension] > 0){ 265 | mods[dimension] = Math.min(maxDimension, mods[dimension]); 266 | }else{ 267 | mods[dimension] = maxDimension; 268 | } 269 | if(mods.action === 'original'){ 270 | // override to 'resizeOriginal' type 271 | mods.action = 'resizeOriginal'; 272 | } 273 | return mods; 274 | }; 275 | 276 | // limit height and width 277 | // in the mods 278 | mods = limitDimension( 279 | 'width', 280 | limitDimension( 281 | 'height', mods 282 | ) 283 | ); 284 | return mods; 285 | }; 286 | 287 | // Exposed method to parse an incoming URL for modifiers, can add a map of 288 | // named (preset) modifiers if need be (mostly just for unit testing). Named 289 | // modifiers are usually added via config json file in root of application. 290 | exports.parse = function(requestUrl, namedMods, envOverride){ 291 | // override 'env' for testing 292 | if(typeof envOverride !== 'undefined'){ 293 | env = _.clone(envOverride); 294 | } else { 295 | env = _.clone(environment); 296 | } 297 | 298 | var segments, mods, modStr, image, gravity, crop, quality; 299 | 300 | gravity = getModifier('g'); 301 | crop = getModifier('c'); 302 | quality = getModifier('q'); 303 | segments = requestUrl.replace(/^\//,'').split('/'); 304 | modStr = _.first(segments); 305 | image = _.last(segments).toLowerCase(); 306 | namedMods = typeof namedMods === 'undefined' ? namedModifierMap : namedMods; 307 | 308 | 309 | // set the mod keys and defaults 310 | mods = { 311 | action: 'original', 312 | height: null, 313 | width: null, 314 | gravity: gravity.default, 315 | crop: crop.default, 316 | quality: quality.default, 317 | hasModStr: false 318 | }; 319 | 320 | // check the request to see if it includes a named modifier 321 | if (namedMods && !_.isEmpty(namedMods)){ 322 | if (_.has(namedMods, modStr)){ 323 | _.forEach(namedMods[modStr], function(value, key){ 324 | if (key === 'square'){ 325 | mods.action = 'square'; 326 | mods.height = value; 327 | mods.width = value; 328 | } else { 329 | mods[key] = value; 330 | } 331 | }); 332 | } 333 | } 334 | 335 | // check the request for available modifiers, unless we are restricting to 336 | // only named modifiers. 337 | if (!env.NAMED_MODIFIERS_ONLY) { 338 | mods = parseModifiers(mods, modStr.split('-')); 339 | } 340 | 341 | 342 | // check to see if this a metadata call, it trumps all other requested mods 343 | if (image.slice(-5) === '.json'){ 344 | mods.action = 'json'; 345 | return mods; 346 | } 347 | 348 | if (mods.action === 'square'){ 349 | // make sure crop is set to the default 350 | mods.crop = 'fill'; 351 | return limitMaxDimension(mods, env); 352 | } 353 | 354 | if (mods.height !== null || mods.width !== null){ 355 | mods.action = 'resize'; 356 | 357 | if (mods.crop !== crop.default){ 358 | mods.action = 'crop'; 359 | } 360 | if (mods.gravity !== gravity.default) { 361 | mods.action = 'crop'; 362 | } 363 | if (_.has(mods, 'x') || _.has(mods, 'y')) { 364 | mods.action = 'crop'; 365 | } 366 | } 367 | 368 | return limitMaxDimension(mods, env); 369 | }; 370 | -------------------------------------------------------------------------------- /src/streams/filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var map, filters, _; 4 | 5 | map = require('map-stream'); 6 | filters = require('./filters'); 7 | _ = require('lodash'); 8 | 9 | 10 | module.exports = function(){ 11 | 12 | return map(function(image, callback){ 13 | 14 | // pass through if there is an error 15 | if (image.isError()){ 16 | return callback(null, image); 17 | } 18 | 19 | // let this pass through if we are requesting the metadata as JSON 20 | if (image.modifiers.action === 'json'){ 21 | image.log.log('filter: json metadata call'); 22 | return callback(null, image); 23 | } 24 | 25 | var filter = image.modifiers.filter; 26 | 27 | // don't attempt to process a filter if no appropriate modifier is set 28 | if (typeof filter === 'undefined'){ 29 | image.log.log('filter:', image.log.colors.bold('none requested')); 30 | return callback(null, image); 31 | } 32 | 33 | image.log.time('filter:'+ filter); 34 | 35 | // run the appropriate filter 36 | filters[image.modifiers.filter](image, function(err, data){ 37 | image.log.timeEnd('filter:'+ filter); 38 | 39 | if (err) { 40 | image.log.error('filter error', err); 41 | image.error = new Error(err); 42 | } else { 43 | image.contents = data; 44 | } 45 | 46 | callback(null, image); 47 | }); 48 | 49 | }); 50 | 51 | }; 52 | -------------------------------------------------------------------------------- /src/streams/filters/blur.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sharp = require('sharp'); 4 | 5 | 6 | module.exports = function(image, callback){ 7 | 8 | // create the sharp object 9 | var r = sharp(image.contents); 10 | 11 | // apply the filter and pass on the stream 12 | r.blur(10).toBuffer(callback); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/streams/filters/greyscale.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sharp = require('sharp'); 4 | 5 | 6 | module.exports = function(image, callback){ 7 | 8 | // create the sharp object 9 | var r = sharp(image.contents); 10 | 11 | // apply the filter and pass on the stream 12 | r.gamma().greyscale().toBuffer(callback); 13 | 14 | }; 15 | -------------------------------------------------------------------------------- /src/streams/filters/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path, fs, cwd, dir, files, modules, pluginDir; 4 | 5 | path = require('path'); 6 | fs = require('fs'); 7 | cwd = process.cwd(); 8 | dir = __dirname.split('/').slice(-1)[0]; 9 | pluginDir = [cwd, 'plugins', dir].join('/'); 10 | modules = {}; 11 | 12 | 13 | // get all the files from this directory 14 | files = require('glob').sync(__dirname + '/*.js'); 15 | for (var i=0; i < files.length; i++){ 16 | var mod = path.basename(files[i], '.js'); 17 | if ( mod !== 'index' ){ 18 | modules[mod] = require(files[i]); 19 | } 20 | } 21 | 22 | // get all the files from the current working directory and override the local 23 | // ones with any custom plugins 24 | if (fs.existsSync(pluginDir)){ 25 | files = require('glob').sync(pluginDir + '/*.js'); 26 | for (var i=0; i < files.length; i++){ 27 | var mod = path.basename(files[i], '.js'); 28 | if ( mod !== 'index' ){ 29 | modules[mod] = require(files[i]); 30 | } 31 | } 32 | } 33 | 34 | 35 | module.exports = modules; 36 | -------------------------------------------------------------------------------- /src/streams/identify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sharp = require('sharp'); 4 | var map = require('map-stream'); 5 | 6 | 7 | module.exports = function(){ 8 | 9 | return map( function(image, callback){ 10 | 11 | if ( image.isError() ){ 12 | return callback(null, image); 13 | } 14 | 15 | if ( image.modifiers.action !== 'json' ){ 16 | image.log.log('identify:', image.log.colors.bold('no identify')); 17 | return callback(null, image); 18 | } 19 | 20 | var handleResponse = function (err, data) { 21 | image.log.timeEnd('identify'); 22 | 23 | if (err) { 24 | image.log.error('identify error', err); 25 | image.error = new Error(err); 26 | } 27 | else { 28 | image.contents = data; 29 | } 30 | 31 | callback(null, image); 32 | }; 33 | 34 | image.log.time('identify'); 35 | 36 | sharp(image.contents).metadata(handleResponse); 37 | }); 38 | 39 | }; 40 | -------------------------------------------------------------------------------- /src/streams/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var modules = require('glob').sync(__dirname + '/*.js'); 5 | var utils = require('../utils/string'); 6 | var streams = {}; 7 | 8 | 9 | for (var i=0; i < modules.length; i++){ 10 | var stream = path.basename(modules[i], '.js'); 11 | if ( stream !== 'index' ){ 12 | streams[utils.camelCase(stream)] = require(modules[i]); 13 | } 14 | } 15 | 16 | module.exports = streams; 17 | -------------------------------------------------------------------------------- /src/streams/optimize.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sharp = require('sharp'); 4 | var env = require('../config/environment_vars'); 5 | var map = require('map-stream'); 6 | 7 | 8 | module.exports = function () { 9 | 10 | return map( function (image, callback) { 11 | 12 | // pass through if there is an error 13 | if (image.isError()) { 14 | return callback(null, image); 15 | } 16 | 17 | // let this pass through if we are requesting the metadata as JSON 18 | if (image.modifiers.action === 'json'){ 19 | image.log.log('optimize: json metadata call'); 20 | return callback(null, image); 21 | } 22 | 23 | image.log.time('optimize-sharp:' + image.format); 24 | 25 | var r = sharp(image.contents); 26 | 27 | if (env.IMAGE_PROGRESSIVE) { 28 | r.progressive(); 29 | } 30 | 31 | // set the output quality 32 | if (image.modifiers.quality < 100) { 33 | r.quality(image.modifiers.quality); 34 | } 35 | 36 | // if a specific output format is specified, set it 37 | if (image.outputFormat) { 38 | r.toFormat(image.outputFormat); 39 | } 40 | 41 | // write out the optimised image to buffer and pass it on 42 | r.toBuffer( function (err, buffer) { 43 | if (err) { 44 | image.log.error('optimize error', err); 45 | image.error = new Error(err); 46 | } 47 | else { 48 | image.contents = buffer; 49 | } 50 | 51 | image.log.timeEnd('optimize-sharp:' + image.format); 52 | callback(null, image); 53 | }); 54 | }); 55 | 56 | }; -------------------------------------------------------------------------------- /src/streams/resize.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sharp = require('sharp'); 4 | var map = require('map-stream'); 5 | var env = require('../config/environment_vars'); 6 | var dims = require('../lib/dimensions'); 7 | 8 | 9 | module.exports = function () { 10 | 11 | return map( function(image, callback) { 12 | 13 | // do nothing if there is an error on the image object 14 | if (image.isError()){ 15 | return callback(null, image); 16 | } 17 | 18 | // let this pass through if we are requesting the metadata as JSON 19 | if (image.modifiers.action === 'json'){ 20 | image.log.log('resize: json metadata call'); 21 | return callback(null, image); 22 | } 23 | 24 | if (image.modifiers.action === 'original' && env.RESIZE_PROCESS_ORIGINAL === false){ 25 | image.log.log('resize: original no resize'); 26 | return callback(null, image); 27 | } 28 | 29 | image.log.time('resize'); 30 | 31 | var resizeResponse = function (err, buffer) { 32 | if (err) { 33 | image.log.error('resize error', err); 34 | image.error = new Error(err); 35 | } 36 | else { 37 | image.contents = buffer; 38 | } 39 | 40 | image.log.timeEnd('resize'); 41 | callback(null, image); 42 | }; 43 | 44 | var r = sharp(image.contents); 45 | 46 | // never enlarge an image beyond its original size, unless we're padding 47 | // the image, as even though this can count as an "enlargement" the padded 48 | // result can be reasonably generated in most cases. 49 | if (image.modifiers.action !== 'crop' && image.modifiers.crop !== 'pad') { 50 | r.withoutEnlargement(); 51 | } 52 | 53 | // if allowed auto rotate images, very helpful for photos off of an iphone 54 | // which are landscape by default and the metadata tells them what to show. 55 | if (env.AUTO_ORIENT) { 56 | r.rotate(); 57 | } 58 | 59 | // by default we remove the metadata from resized images, setting the env 60 | // var to false can retain it. 61 | if (!env.REMOVE_METADATA) { 62 | r.withMetadata(); 63 | } 64 | 65 | var d, wd, ht; 66 | 67 | switch(image.modifiers.action){ 68 | case 'original' : 69 | r.toBuffer(resizeResponse); 70 | break; 71 | 72 | case 'resize': 73 | r.resize(image.modifiers.width, image.modifiers.height); 74 | r.max(); 75 | r.toBuffer(resizeResponse); 76 | break; 77 | 78 | case 'square': 79 | r.metadata(function(err, metadata){ 80 | if (err){ 81 | image.error = new Error(err); 82 | callback(null, image); 83 | return; 84 | } 85 | 86 | d = dims.cropFill(image.modifiers, metadata); 87 | 88 | // resize then crop the image 89 | r.resize( 90 | d.resize.width, 91 | d.resize.height 92 | ).extract( 93 | d.crop.y, 94 | d.crop.x, 95 | d.crop.width, 96 | d.crop.height 97 | ); 98 | 99 | r.toBuffer(resizeResponse); 100 | }); 101 | 102 | break; 103 | 104 | case 'crop': 105 | r.metadata(function(err, size){ 106 | if (err){ 107 | image.error = new Error(err); 108 | callback(null, image); 109 | return; 110 | } 111 | 112 | switch(image.modifiers.crop){ 113 | case 'fit': 114 | r.resize(image.modifiers.width, image.modifiers.height); 115 | r.max(); 116 | break; 117 | case 'fill': 118 | d = dims.cropFill(image.modifiers, size); 119 | 120 | r.resize( 121 | d.resize.width, 122 | d.resize.height 123 | ).extract( 124 | d.crop.y, 125 | d.crop.x, 126 | d.crop.width, 127 | d.crop.height 128 | ); 129 | break; 130 | case 'cut': 131 | wd = image.modifiers.width || image.modifiers.height; 132 | ht = image.modifiers.height || image.modifiers.width; 133 | 134 | d = dims.gravity( 135 | image.modifiers.gravity, 136 | size.width, 137 | size.height, 138 | wd, 139 | ht 140 | ); 141 | r.extract(d.y, d.x, wd, ht); 142 | break; 143 | case 'scale': 144 | // TODO: deal with scale 145 | r.resize(image.modifiers.width, image.modifiers.height); 146 | break; 147 | case 'pad': 148 | r.resize( 149 | image.modifiers.width, 150 | image.modifiers.height 151 | ).background(env.IMAGE_PADDING_COLOR || 'white').embed(); 152 | } 153 | 154 | r.toBuffer(resizeResponse); 155 | }); 156 | 157 | break; 158 | } 159 | }); 160 | 161 | }; 162 | -------------------------------------------------------------------------------- /src/streams/response.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var stream = require('stream'); 5 | var env = require('../config/environment_vars'); 6 | var util = require('util'); 7 | 8 | 9 | function ResponseWriter(request, response){ 10 | if (!(this instanceof ResponseWriter)){ 11 | return new ResponseWriter(request, response); 12 | } 13 | 14 | this.request = request; 15 | this.response = response; 16 | 17 | stream.Writable.call(this, { objectMode : true }); 18 | } 19 | 20 | util.inherits(ResponseWriter, stream.Writable); 21 | 22 | 23 | ResponseWriter.prototype.expiresIn = function(maxAge){ 24 | var dt = Date.now(); 25 | dt += maxAge * 1000; 26 | 27 | return (new Date(dt)).toGMTString(); 28 | }; 29 | 30 | 31 | ResponseWriter.prototype.shouldCacheResponse = function(){ 32 | if (env.development){ 33 | if (env.CACHE_DEV_REQUESTS){ 34 | return true; 35 | } else { 36 | return false; 37 | } 38 | } 39 | 40 | return true; 41 | }; 42 | 43 | 44 | ResponseWriter.prototype._write = function(image){ 45 | if (image.isError()){ 46 | image.log.error(image.error.message); 47 | image.log.flush(); 48 | var statusCode = image.error.statusCode || 500; 49 | 50 | if (statusCode === 404 && env.IMAGE_404) { 51 | this.response.status(404); 52 | fs.createReadStream(env.IMAGE_404).pipe(this.response); 53 | } 54 | else { 55 | this.response.status(statusCode).end(); 56 | } 57 | 58 | return; 59 | } 60 | 61 | if (image.modifiers.action === 'json'){ 62 | if (this.shouldCacheResponse()){ 63 | this.response.set({ 64 | 'Cache-Control': 'public', 65 | 'Expires': this.expiresIn(env.JSON_EXPIRY), 66 | 'Last-Modified': (new Date(1000)).toGMTString(), 67 | 'Vary': 'Accept-Encoding' 68 | }); 69 | } 70 | 71 | this.response.status(200).json(image.contents); 72 | image.log.flush(); 73 | 74 | return this.end(); 75 | } 76 | 77 | if (this.shouldCacheResponse()){ 78 | this.response.set({ 79 | 'Cache-Control': 'public', 80 | 'Expires': this.expiresIn(image.expiry), 81 | 'Last-Modified': (new Date(1000)).toGMTString(), 82 | 'Vary': 'Accept-Encoding' 83 | }); 84 | } 85 | 86 | this.response.type(image.format); 87 | 88 | if (image.isStream()){ 89 | image.contents.pipe(this.response); 90 | } 91 | 92 | else { 93 | image.log.log( 94 | 'original image size:', 95 | image.log.colors.grey( 96 | (image.originalContentLength/1000).toString() + 'kb' 97 | ) 98 | ); 99 | image.log.log( 100 | 'size saving:', 101 | image.log.colors.grey(image.sizeSaving() + '%') 102 | ); 103 | 104 | // as a debugging step print a checksum for the modified image, so we can 105 | // track to see if the image is replicated effectively between requests 106 | if (env.development){ 107 | var crypto = require('crypto'), 108 | shasum = crypto.createHash('sha1'); 109 | shasum.update(image.contents); 110 | image.log.log('checksum', shasum.digest('hex')); 111 | } 112 | 113 | this.response.status(200).send(image.contents); 114 | } 115 | 116 | // flush the log messages and close the connection 117 | image.log.flush(); 118 | this.end(); 119 | }; 120 | 121 | 122 | module.exports = ResponseWriter; 123 | -------------------------------------------------------------------------------- /src/streams/sources/external.js: -------------------------------------------------------------------------------- 1 | // Fetches an image from an external URL 2 | 3 | 'use strict'; 4 | 5 | var stream, util, request; 6 | 7 | stream = require('stream'); 8 | util = require('util'); 9 | request = require('request'); 10 | 11 | function contentLength(bufs){ 12 | return bufs.reduce(function(sum, buf){ 13 | return sum + buf.length; 14 | }, 0); 15 | } 16 | 17 | function External(image, key, prefix){ 18 | /* jshint validthis:true */ 19 | if (!(this instanceof External)){ 20 | return new External(image, key, prefix); 21 | } 22 | stream.Readable.call(this, { objectMode : true }); 23 | this.image = image; 24 | this.ended = false; 25 | this.key = key; 26 | this.prefix = prefix; 27 | } 28 | 29 | util.inherits(External, stream.Readable); 30 | 31 | External.prototype._read = function(){ 32 | var _this = this, 33 | url, 34 | imgStream, 35 | bufs = []; 36 | 37 | if ( this.ended ){ return; } 38 | 39 | // pass through if there is an error on the image object 40 | if (this.image.isError()){ 41 | this.ended = true; 42 | this.push(this.image); 43 | return this.push(null); 44 | } 45 | 46 | url = this.prefix + '/' + this.image.path; 47 | 48 | this.image.log.time(this.key); 49 | 50 | imgStream = request.get(url); 51 | imgStream.on('data', function(d){ bufs.push(d); }); 52 | imgStream.on('error', function(err){ 53 | _this.image.error = new Error(err); 54 | }); 55 | imgStream.on('response', function(response) { 56 | if (response.statusCode !== 200) { 57 | _this.image.error = new Error('Error ' + response.statusCode + ':'); 58 | } 59 | }); 60 | imgStream.on('end', function(){ 61 | _this.image.log.timeEnd(_this.key); 62 | if(_this.image.isError()) { 63 | _this.image.error.message += Buffer.concat(bufs); 64 | } else { 65 | _this.image.contents = Buffer.concat(bufs); 66 | _this.image.originalContentLength = contentLength(bufs); 67 | } 68 | _this.ended = true; 69 | _this.push(_this.image); 70 | _this.push(null); 71 | }); 72 | 73 | }; 74 | 75 | 76 | module.exports = External; -------------------------------------------------------------------------------- /src/streams/sources/facebook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stream, util, request, env; 4 | 5 | stream = require('stream'); 6 | util = require('util'); 7 | request = require('request'); 8 | env = require('../../config/environment_vars'); 9 | 10 | // function contentLength(bufs){ 11 | // return bufs.reduce(function(sum, buf){ 12 | // return sum + buf.length; 13 | // }, 0); 14 | // } 15 | 16 | function Facebook(image){ 17 | /* jshint validthis:true */ 18 | if (!(this instanceof Facebook)){ 19 | return new Facebook(image); 20 | } 21 | stream.Readable.call(this, { objectMode : true }); 22 | this.image = image; 23 | this.ended = false; 24 | 25 | // set the expiry value to the shorter value 26 | this.image.expiry = env.IMAGE_EXPIRY_SHORT; 27 | } 28 | 29 | util.inherits(Facebook, stream.Readable); 30 | 31 | Facebook.prototype._read = function(){ 32 | var _this = this, 33 | url; 34 | 35 | if ( this.ended ){ return; } 36 | 37 | // pass through if there is an error on the image object 38 | if (this.image.isError()){ 39 | this.ended = true; 40 | this.push(this.image); 41 | return this.push(null); 42 | } 43 | 44 | var fbUid = this.image.image.split('.').slice(0,-1).join('.'); 45 | 46 | url = 'https://graph.facebook.com/' + fbUid + '/picture?type=large'; 47 | 48 | this.image.log.time('facebook'); 49 | 50 | var opts = { 51 | url: url, 52 | encoding: null 53 | }; 54 | 55 | request(opts, function (err, response, body) { 56 | _this.image.log.timeEnd('facebook'); 57 | 58 | if (err) { 59 | _this.image.error = err; 60 | } 61 | else { 62 | if (response.statusCode === 200) { 63 | _this.image.contents = body; 64 | _this.image.originalContentLength = body.length; 65 | _this.ended = true; 66 | } 67 | else { 68 | _this.image.error = new Error('Facebook user image not found'); 69 | _this.image.error.statusCode = 404; 70 | } 71 | } 72 | 73 | _this.push(_this.image); 74 | _this.push(null); 75 | }); 76 | 77 | }; 78 | 79 | 80 | module.exports = Facebook; 81 | -------------------------------------------------------------------------------- /src/streams/sources/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path, fs, cwd, dir, files, modules, pluginDir; 4 | 5 | path = require('path'); 6 | fs = require('fs'); 7 | cwd = process.cwd(); 8 | dir = __dirname.split('/').slice(-1)[0]; 9 | pluginDir = [cwd, 'plugins', dir].join('/'); 10 | modules = {}; 11 | 12 | 13 | // get all the files from this directory 14 | files = require('glob').sync(__dirname + '/*.js'); 15 | for (var i=0; i < files.length; i++){ 16 | var mod = path.basename(files[i], '.js'); 17 | if (mod !== 'index'){ 18 | modules[mod] = require(files[i]); 19 | } 20 | } 21 | 22 | // get all the files from the current working directory and override the local 23 | // ones with any custom plugins 24 | if (fs.existsSync(pluginDir)){ 25 | files = require('glob').sync(pluginDir + '/*.js'); 26 | for (var i=0; i < files.length; i++){ 27 | var mod = path.basename(files[i], '.js'); 28 | if ( mod !== 'index' ){ 29 | modules[mod] = require(files[i]); 30 | } 31 | } 32 | } 33 | 34 | 35 | module.exports = modules; 36 | -------------------------------------------------------------------------------- /src/streams/sources/local.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var env, fs, stream, util; 4 | 5 | env = require('../../config/environment_vars'); 6 | fs = require('fs'); 7 | stream = require('stream'); 8 | util = require('util'); 9 | 10 | 11 | function Local(image){ 12 | /* jshint validthis:true */ 13 | if (!(this instanceof Local)){ 14 | return new Local(image); 15 | } 16 | stream.Readable.call(this, { objectMode : true }); 17 | this.image = image; 18 | this.path = image.path.replace(/^elocal/i,''); 19 | this.filePath = env.LOCAL_FILE_PATH + '/' + this.path; 20 | this.ended = false; 21 | } 22 | 23 | util.inherits(Local, stream.Readable); 24 | 25 | Local.prototype._read = function(){ 26 | var _this = this; 27 | 28 | if ( this.ended ){ return; } 29 | 30 | // pass through if there is an error on the image object 31 | if (this.image.isError()){ 32 | this.ended = true; 33 | this.push(this.image); 34 | return this.push(null); 35 | } 36 | 37 | this.image.log.time('local filesystem'); 38 | 39 | fs.readFile(this.filePath, function(err, data){ 40 | _this.image.log.timeEnd('local filesystem'); 41 | 42 | // if there is an error store it on the image object and pass it along 43 | if (err) { 44 | _this.image.error = err; 45 | 46 | if (err.code === 'ENOENT') { 47 | _this.image.error.statusCode = 404; 48 | } 49 | } 50 | 51 | // if not store the image buffer 52 | else { 53 | _this.image.contents = data; 54 | _this.image.originalContentLength = data.length; 55 | } 56 | 57 | _this.ended = true; 58 | _this.push(_this.image); 59 | _this.push(null); 60 | }); 61 | }; 62 | 63 | 64 | module.exports = Local; -------------------------------------------------------------------------------- /src/streams/sources/s3.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var env, s3, stream, util, client, bucket; 4 | 5 | env = require('../../config/environment_vars'); 6 | s3 = require('aws-sdk').S3; 7 | stream = require('stream'); 8 | util = require('util'); 9 | 10 | try { 11 | // create an AWS S3 client with the config data 12 | client = new s3({ 13 | accessKeyId: env.AWS_ACCESS_KEY_ID, 14 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY, 15 | region: env.AWS_REGION 16 | }); 17 | bucket = env.S3_BUCKET; 18 | } catch(e) { 19 | 20 | } 21 | 22 | 23 | function s3Stream(image){ 24 | /* jshint validthis:true */ 25 | if (!(this instanceof s3Stream)){ 26 | return new s3Stream(image); 27 | } 28 | stream.Readable.call(this, { objectMode : true }); 29 | this.image = image; 30 | this.ended = false; 31 | } 32 | 33 | util.inherits(s3Stream, stream.Readable); 34 | 35 | s3Stream.prototype._read = function(){ 36 | var _this = this; 37 | 38 | if ( this.ended ){ return; } 39 | 40 | // pass through if there is an error on the image object 41 | if (this.image.isError()){ 42 | this.ended = true; 43 | this.push(this.image); 44 | return this.push(null); 45 | } 46 | 47 | // Set the AWS options 48 | var awsOptions = { 49 | Bucket: bucket, 50 | Key: this.image.path.replace(/^\//,'') 51 | }; 52 | 53 | this.image.log.time('s3'); 54 | 55 | client.getObject(awsOptions, function(err, data){ 56 | _this.image.log.timeEnd('s3'); 57 | 58 | // if there is an error store it on the image object and pass it along 59 | if (err) { 60 | _this.image.error = err; 61 | } 62 | 63 | // if not store the image buffer 64 | else { 65 | _this.image.contents = data.Body; 66 | _this.image.originalContentLength = data.Body.length; 67 | } 68 | 69 | _this.ended = true; 70 | _this.push(_this.image); 71 | _this.push(null); 72 | }); 73 | }; 74 | 75 | 76 | module.exports = s3Stream; 77 | -------------------------------------------------------------------------------- /src/streams/sources/twitter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stream, util, env, Twit, t, request, _; 4 | 5 | stream = require('stream'); 6 | util = require('util'); 7 | env = require('../../config/environment_vars'); 8 | Twit = require('twit'); 9 | request = require('request'); 10 | _ = require('lodash'); 11 | 12 | /* jshint camelcase:false */ 13 | try { 14 | t = new Twit({ 15 | consumer_key: env.TWITTER_CONSUMER_KEY, 16 | consumer_secret: env.TWITTER_CONSUMER_SECRET, 17 | access_token: env.TWITTER_ACCESS_TOKEN, 18 | access_token_secret: env.TWITTER_ACCESS_TOKEN_SECRET 19 | }); 20 | } catch(e){ 21 | 22 | } 23 | 24 | 25 | function Twitter(image){ 26 | /* jshint validthis:true */ 27 | if (!(this instanceof Twitter)){ 28 | return new Twitter(image); 29 | } 30 | stream.Readable.call(this, { objectMode : true }); 31 | this.image = image; 32 | this.ended = false; 33 | 34 | // set the expiry value to the shorter value 35 | this.image.expiry = env.IMAGE_EXPIRY_SHORT; 36 | } 37 | 38 | util.inherits(Twitter, stream.Readable); 39 | 40 | Twitter.prototype._read = function(){ 41 | var _this = this, 42 | profileId, queryString; 43 | 44 | if ( this.ended ){ return; } 45 | 46 | // pass through if there is an error on the image object 47 | if (this.image.isError()){ 48 | this.ended = true; 49 | this.push(this.image); 50 | return this.push(null); 51 | } 52 | 53 | // pass through the stream with an error if the twit library didnt start 54 | if (!t){ 55 | this.image.error = new Error('Need valid twitter credentials'); 56 | this.push(this.image); 57 | return this.push(null); 58 | } 59 | 60 | var endStream = function(){ 61 | _this.ended = true; 62 | _this.push(_this.image); 63 | _this.push(null); 64 | }; 65 | 66 | this.image.log.time('twitter'); 67 | 68 | profileId = this.image.image.split('.')[0]; 69 | 70 | if (_.isNaN(profileId * 1)){ 71 | queryString = {screen_name: profileId}; 72 | } else { 73 | queryString = {user_id: profileId}; 74 | } 75 | 76 | t.get('users/show', queryString, function(err, data){ 77 | if (err){ 78 | _this.image.error = new Error(err); 79 | endStream(); 80 | } 81 | else { 82 | /* jshint camelcase:false */ 83 | var imageUrl = data.profile_image_url 84 | .replace('_normal', '') 85 | .replace('_bigger', '') 86 | .replace('_mini', ''); 87 | 88 | var opts = { 89 | url: imageUrl, 90 | encoding: null 91 | }; 92 | 93 | request(opts, function (err, response, body) { 94 | _this.image.log.timeEnd('twitter'); 95 | 96 | if (err) { 97 | _this.image.error = err; 98 | } 99 | else { 100 | if (response.statusCode === 200) { 101 | _this.image.contents = body; 102 | _this.image.originalContentLength = body.length; 103 | _this.ended = true; 104 | } 105 | else { 106 | _this.image.error = new Error('Twitter user image not found'); 107 | _this.image.error.statusCode = 404; 108 | } 109 | } 110 | 111 | _this.push(_this.image); 112 | _this.push(null); 113 | }); 114 | 115 | } 116 | }); 117 | 118 | }; 119 | 120 | 121 | module.exports = Twitter; 122 | -------------------------------------------------------------------------------- /src/streams/sources/vimeo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stream = require('stream'); 4 | var util = require('util'); 5 | var request = require('request'); 6 | var env = require('../../config/environment_vars'); 7 | 8 | 9 | function Vimeo(image){ 10 | /* jshint validthis:true */ 11 | if (!(this instanceof Vimeo)){ 12 | return new Vimeo(image); 13 | } 14 | stream.Readable.call(this, { objectMode : true }); 15 | this.image = image; 16 | this.ended = false; 17 | 18 | // set the expiry value to the shorter value 19 | this.image.expiry = env.IMAGE_EXPIRY_SHORT; 20 | } 21 | 22 | util.inherits(Vimeo, stream.Readable); 23 | 24 | Vimeo.prototype._read = function(){ 25 | var _this = this, 26 | url, videoId; 27 | 28 | if ( this.ended ){ return; } 29 | 30 | // pass through if there is an error on the image object 31 | if (this.image.isError()){ 32 | this.ended = true; 33 | this.push(this.image); 34 | return this.push(null); 35 | } 36 | 37 | var endStream = function(){ 38 | _this.ended = true; 39 | _this.push(_this.image); 40 | _this.push(null); 41 | }; 42 | 43 | this.image.log.time('vimeo'); 44 | videoId = this.image.image.split('.')[0]; 45 | url = 'http://vimeo.com/api/v2/video/' + videoId + '.json'; 46 | 47 | request(url, function(err, response, body){ 48 | if (err){ 49 | _this.image.error = new Error(err); 50 | endStream(); 51 | } 52 | else { 53 | var json = JSON.parse(body); 54 | 55 | /* jshint camelcase:false */ 56 | var imageUrl = json[0].thumbnail_large; 57 | imageUrl = imageUrl.replace('_640.jpg', ''); 58 | 59 | var opts = { 60 | url: imageUrl, 61 | encoding: null 62 | }; 63 | 64 | request(opts, function (err, response, body) { 65 | _this.image.log.timeEnd('vimeo'); 66 | 67 | if (err) { 68 | _this.image.error = err; 69 | } 70 | else { 71 | if (response.statusCode === 200) { 72 | _this.image.contents = body; 73 | _this.image.originalContentLength = body.length; 74 | _this.ended = true; 75 | } 76 | else { 77 | _this.image.error = new Error('Vimeo image not found'); 78 | _this.image.error.statusCode = 404; 79 | } 80 | } 81 | 82 | _this.push(_this.image); 83 | _this.push(null); 84 | }); 85 | 86 | } 87 | }); 88 | 89 | }; 90 | 91 | 92 | module.exports = Vimeo; 93 | -------------------------------------------------------------------------------- /src/streams/sources/youtube.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stream, util, request, env; 4 | 5 | stream = require('stream'); 6 | util = require('util'); 7 | request = require('request'); 8 | env = require('../../config/environment_vars'); 9 | 10 | 11 | function Youtube(image){ 12 | /* jshint validthis:true */ 13 | if (!(this instanceof Youtube)){ 14 | return new Youtube(image); 15 | } 16 | stream.Readable.call(this, { objectMode : true }); 17 | this.image = image; 18 | this.ended = false; 19 | 20 | // set the expiry value to the shorter value 21 | this.image.expiry = env.IMAGE_EXPIRY_SHORT; 22 | } 23 | 24 | util.inherits(Youtube, stream.Readable); 25 | 26 | Youtube.prototype._read = function(){ 27 | var _this = this, 28 | url, videoId; 29 | 30 | if ( this.ended ){ return; } 31 | 32 | // pass through if there is an error on the image object 33 | if (this.image.isError()){ 34 | this.ended = true; 35 | this.push(this.image); 36 | return this.push(null); 37 | } 38 | 39 | videoId = this.image.image.split('.')[0]; 40 | url = 'http://img.youtube.com/vi/' + videoId + '/hqdefault.jpg'; 41 | 42 | this.image.log.time('youtube'); 43 | 44 | var opts = { 45 | url: url, 46 | encoding: null 47 | }; 48 | 49 | request(opts, function (err, response, body) { 50 | _this.image.log.timeEnd('youtube'); 51 | 52 | if (err) { 53 | _this.image.error = err; 54 | } 55 | else { 56 | if (response.statusCode === 200) { 57 | _this.image.contents = body; 58 | _this.image.originalContentLength = body.length; 59 | _this.ended = true; 60 | } 61 | else { 62 | _this.image.error = new Error('Youtube image not found'); 63 | _this.image.error.statusCode = 404; 64 | } 65 | } 66 | 67 | _this.push(_this.image); 68 | _this.push(null); 69 | }); 70 | 71 | }; 72 | 73 | 74 | module.exports = Youtube; 75 | 76 | // http://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api 77 | 78 | // you can also get json data about a Youtube vid like this: 79 | // - http://gdata.youtube.com/feeds/api/videos/lK1vPu6U2B0?v=2&alt=jsonc 80 | -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var env, chalk, _, slice, prefix, queueLog, args; 4 | 5 | env = require('../config/environment_vars'); 6 | chalk = require('chalk'); 7 | _ = require('lodash'); 8 | slice = [].slice; 9 | prefix = env.LOG_PREFIX; 10 | queueLog = env.QUEUE_LOG; 11 | 12 | chalk.enabled = true; 13 | 14 | 15 | function Logger(){ 16 | this.queue = []; 17 | this.times = {}; 18 | this.queueLog = queueLog; 19 | } 20 | 21 | Logger.prototype.colors = chalk; 22 | 23 | Logger.prototype.log = function(){ 24 | args = slice.call(arguments); 25 | if (this.queueLog){ 26 | this.queue.push({ method: 'log', args: args }); 27 | } else { 28 | args.unshift('[' + chalk.green(prefix) + ']'); 29 | console.log.apply(console, args); 30 | } 31 | }; 32 | 33 | Logger.prototype.error = function(){ 34 | args = slice.call(arguments); 35 | if (this.queueLog){ 36 | this.queue.push({ method: 'error', args: args }); 37 | } else { 38 | args.unshift('[' + chalk.green(prefix) + ']'); 39 | console.error.apply(console, args); 40 | } 41 | }; 42 | 43 | Logger.prototype.time = function(key){ 44 | if (this.queueLog){ 45 | this.times[key] = Date.now(); 46 | } else { 47 | key = '[' + chalk.green(prefix) + '] ' + chalk.cyan(key); 48 | console.time.call(console, key); 49 | } 50 | }; 51 | 52 | Logger.prototype.timeEnd = function(key){ 53 | if (this.queueLog){ 54 | var time = Date.now() - this.times[key]; 55 | this.queue.push({ method: 'time', key: key, time: time }); 56 | } else { 57 | key = '[' + chalk.green(prefix) + '] ' + chalk.cyan(key); 58 | console.timeEnd.call(console, key); 59 | } 60 | }; 61 | 62 | Logger.prototype.flush = function(){ 63 | if (this.queue.length === 0){ 64 | return; 65 | } 66 | 67 | console.log(''); 68 | _.each(this.queue, function(item){ 69 | var log = ''; 70 | log += '[' + chalk.green(prefix) + '] '; 71 | switch(item.method){ 72 | case 'log': 73 | _.each(item.args, function(arg){ 74 | log += arg.toString() + ' '; 75 | }); 76 | break; 77 | case 'error': 78 | _.each(item.args, function(arg){ 79 | log += chalk.red(arg.toString()) + ' '; 80 | }); 81 | break; 82 | case 'time': 83 | log += chalk.cyan( 84 | item.key + ' - ' + chalk.bold(item.time.toString()) + 'ms' 85 | ); 86 | break; 87 | } 88 | console.log(log); 89 | }); 90 | 91 | }; 92 | 93 | 94 | module.exports = Logger; -------------------------------------------------------------------------------- /src/utils/string.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | exports.sanitize = function(value, type) { 5 | if (typeof type === 'undefined') { 6 | type = 'number'; 7 | } 8 | switch (type) { 9 | case 'number': 10 | return value.toString().replace(/[^0-9]/, '') * 1; 11 | case 'alphanumeric': 12 | return value.replace(/[^a-z0-9]/i, ''); 13 | case 'alpha': 14 | return value.replace(/[^a-z]/i, ''); 15 | default: 16 | return value.replace(/[^0-9]/, ''); 17 | } 18 | }; 19 | 20 | 21 | exports.camelCase = function(input){ 22 | return input.toLowerCase() 23 | .replace(/_(.)/g, function(match, letter){ 24 | return letter.toUpperCase(); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the working implementation of image-resizer used for 3 | * development purposes. It is a very close example of what is generated by 4 | * the cli script when provisioning a new instance. `image-resizer new` 5 | */ 6 | 7 | 'use strict'; 8 | 9 | var express = require('express'), 10 | app = express(), 11 | ir = require('./index'), 12 | env = ir.env, 13 | Img = ir.img, 14 | streams = ir.streams, 15 | chalk = require('chalk'), 16 | exec = require('child_process').exec; 17 | 18 | // check to see if vips is installed 19 | exec ('vips --version', function (err, stdout, stderr) { 20 | if (err || stderr) { 21 | console.error( 22 | chalk.red('\nMissing dependency:'), 23 | chalk.red.bold('libvips') 24 | ); 25 | 26 | console.log( 27 | chalk.cyan(' to install vips on your system run:'), 28 | chalk.bold('./node_modules/sharp/preinstall.sh\n') 29 | ); 30 | } 31 | }); 32 | 33 | app.directory = __dirname; 34 | ir.expressConfig(app); 35 | 36 | app.get('/favicon.ico', function (request, response) { 37 | response.sendStatus(404); 38 | }); 39 | 40 | /** 41 | * Return the modifiers map as a documentation endpoint 42 | */ 43 | app.get('/modifiers.json', function(request, response){ 44 | response.json(ir.modifiers); 45 | }); 46 | 47 | 48 | /** 49 | * Some helper endpoints when in development 50 | */ 51 | if (env.development){ 52 | // Show a test page of the image options 53 | app.get('/test-page', function(request, response){ 54 | response.render('index.html'); 55 | }); 56 | 57 | // Show the environment variables and their current values 58 | app.get('/env', function(request, response){ 59 | response.status(200).json(env); 60 | }); 61 | } 62 | 63 | /** 64 | * Return an image modified to the requested parameters 65 | * - request format: 66 | * /:modifers/path/to/image.format:metadata 67 | * eg: https://doapv6pcsx1wa.cloudfront.net/s50/sample/test.png 68 | */ 69 | app.get('/*?', function(request, response){ 70 | var image = new Img(request); 71 | 72 | var stream = image.getFile().pipe(new streams.identify()); 73 | stream = stream.pipe(new streams.resize()); 74 | stream = stream.pipe(new streams.filter()); 75 | stream = stream.pipe(new streams.optimize()); 76 | 77 | stream.pipe(streams.response(request, response)); 78 | }); 79 | 80 | 81 | /** 82 | Start the app on the listed port 83 | */ 84 | app.listen(app.get('port')); 85 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Image Resizer Test Page 6 | 7 | 25 | 26 | 27 | 28 | 29 |

image-resizer
test page

30 | 31 |

Square

32 |
33 | 34 |

s200

35 |
36 |
37 | 38 |

s200-gne

39 |
40 |
41 | 42 |

s200-gsw

43 |
44 | 45 |

Crop

46 | 47 |
48 | 49 |

w200-cfit

50 |
51 | 52 |
53 | 54 |

w200-h200-cpad

55 |
56 | 57 |
58 | 59 |

w200-cfill

60 |
61 | 62 |
63 | 64 |

w200-ccut-gne

65 |
66 | 67 |

Non-Square Crop Fill

68 | 69 |
70 | 71 |

w200-h150-cfill

72 |
73 | 74 |
75 | 76 |

w150-h200-cfill

77 |
78 | 79 |

Height

80 | 81 |
82 | 83 |

h100

84 |
85 | 86 |
87 | 88 |

h200

89 |
90 | 91 |

Width

92 | 93 |
94 | 95 |

w100

96 |
97 | 98 |
99 | 100 |

w200

101 |
102 | 103 | 104 |

External Sources

105 | 106 |
107 | 108 |

s100-efacebook

109 |
110 | 111 |
112 | 113 |

h200-etwitter

114 |
115 | 116 |
117 | 118 |

w300-evimeo

119 |
120 | 121 |
122 | 123 |

h200-eyoutube

124 |
125 | 126 |
127 | 128 |

h200-ewikipedia

129 |
130 | 131 |

Filters

132 | 133 |
134 | 135 |

w200-fgreyscale

136 |
137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /test/sample_images/image1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmynicol/image-resizer/ae6c7b36d822b0a92d1ee382383b4c29eab0b8c8/test/sample_images/image1.jpg -------------------------------------------------------------------------------- /test/sample_images/image2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmynicol/image-resizer/ae6c7b36d822b0a92d1ee382383b4c29eab0b8c8/test/sample_images/image2.jpg -------------------------------------------------------------------------------- /test/sample_images/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimmynicol/image-resizer/ae6c7b36d822b0a92d1ee382383b4c29eab0b8c8/test/sample_images/image3.png -------------------------------------------------------------------------------- /test/src/image-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'), 4 | expect = chai.expect, 5 | path = require('path'), 6 | fs = require('fs'), 7 | Img = require('../../src/image'); 8 | 9 | chai.should(); 10 | 11 | 12 | describe('Image class', function(){ 13 | 14 | describe('#format', function () { 15 | it('should normalise the format from the request', function(){ 16 | var img = new Img({path: '/path/to/image.JPEG'}); 17 | img.format = 'JPEG' 18 | img.format.should.equal('jpeg'); 19 | }); 20 | 21 | it('should still get format from a metadata request', function(){ 22 | var img = new Img({path: '/path/to/image.jpg.json'}); 23 | img.format.should.equal('jpeg'); 24 | }); 25 | }); 26 | 27 | describe('#content', function () { 28 | it ('should set the format based on the image data', function () { 29 | var imgSrc = path.resolve(__dirname, '../sample_images/image1.jpg'); 30 | var buf = fs.readFileSync(imgSrc); 31 | var img = new Img({path: '/path/to/image.jpg'}); 32 | 33 | img.contents = buf; 34 | img.format.should.equal('jpeg'); 35 | }); 36 | }); 37 | 38 | describe('#parseImage', function(){ 39 | it('should retrieve image name from the path', function(){ 40 | var img = new Img({path: '/path/to/image.jpg'}); 41 | img.image.should.equal('image.jpg'); 42 | }); 43 | 44 | it('should retrieve image from the path with .json in title', function(){ 45 | var img = new Img({path: '/path/to/some.image.with.json.jpg'}); 46 | img.image.should.equal('some.image.with.json.jpg'); 47 | }); 48 | 49 | it('should retrieve image name from path even for metadata', function(){ 50 | var img = new Img({path: '/path/to/image.jpg.json'}); 51 | img.image.should.equal('image.jpg'); 52 | }); 53 | 54 | it('should handle image names with dashes', function(){ 55 | var dashed = '8b0ccce0-0a6c-4270-9bc0-8b6dfaabea19.jpg', 56 | img = new Img({path: '/path/to/' + dashed}); 57 | img.image.should.equal(dashed); 58 | }); 59 | 60 | it('should handle metadata for image names with dashes', function(){ 61 | var dashed = '8b0ccce0-0a6c-4270-9bc0-8b6dfaabea19.jpg', 62 | img = new Img({path: '/path/to/' + dashed + '.json'}); 63 | img.image.should.equal(dashed); 64 | }); 65 | 66 | it('should handle image names with underscores', function(){ 67 | var underscored = '8b0ccce0_0a6c_4270_9bc0_8b6dfaabea19.jpg', 68 | img = new Img({path: '/path/to/' + underscored}); 69 | img.image.should.equal(underscored); 70 | }); 71 | 72 | it('should handle image names with periods', function(){ 73 | var perioded = '8b0ccce0.0a6c.4270.9bc0.8b6dfaabea19.jpg', 74 | img = new Img({path: '/path/to/' + perioded}); 75 | img.image.should.equal(perioded); 76 | }); 77 | 78 | it('should handle metadata for image names with periods', function(){ 79 | var perioded = '8b0ccce0.0a6c.4270.9bc0.8b6dfaabea19.jpg', 80 | img = new Img({path: '/path/to/' + perioded + '.json'}); 81 | img.image.should.equal(perioded); 82 | }); 83 | 84 | describe('#outputFormat', function () { 85 | it('should exclude second output format from image path', function(){ 86 | var image = 'image.jpg', 87 | img = new Img({path: '/path/to/' + image + '.webp'}); 88 | img.outputFormat.should.equal('webp'); 89 | img.image.should.equal(image); 90 | img.path.should.equal('path/to/' + image); 91 | }); 92 | 93 | it('should still get output format from perioded file name', function(){ 94 | var image = '8b0ccce0.0a6c.4270.9bc0.8b6dfaabea19.jpg', 95 | img = new Img({path: '/path/to/' + image + '.webp'}); 96 | img.outputFormat.should.equal('webp'); 97 | img.image.should.equal(image); 98 | img.path.should.equal('path/to/' + image); 99 | }); 100 | 101 | }); 102 | }); 103 | 104 | 105 | describe('#parseUrl', function(){ 106 | it('should return a clean path', function(){ 107 | var img = new Img({path: '/path/to/image.jpg.json'}); 108 | img.path.should.equal('path/to/image.jpg'); 109 | }); 110 | it('should return path even with modifiers', function(){ 111 | var img = new Img({path: '/s50-gne/path/to/image.jpg'}); 112 | img.path.should.equal('path/to/image.jpg'); 113 | }); 114 | it('should return path when only the source is specified', function(){ 115 | var img = new Img({path: '/elocal/path/to/image.jpg'}); 116 | img.path.should.equal('path/to/image.jpg'); 117 | }); 118 | }); 119 | 120 | 121 | describe('local formats', function(){ 122 | it('should recognise a local source', function(){ 123 | var localPath = '/elocal/path/to/image.png', 124 | img = new Img({path: localPath}); 125 | img.modifiers.external.should.equal('local'); 126 | }); 127 | }); 128 | 129 | 130 | // describe('bad formats', function(){ 131 | // it('should set error if the format is not valid', function(){ 132 | // var img = new Img({path: '/path/to/image.tiff'}); 133 | // img.error.message.should.eq(Img.formatErrorText); 134 | // }); 135 | // }); 136 | 137 | 138 | it('should respond in an error state', function(){ 139 | var img = new Img({path: '/path/to/image.jpg'}); 140 | img.error = new Error('sample error'); 141 | img.isError().should.be.true; 142 | }); 143 | 144 | }); 145 | -------------------------------------------------------------------------------- /test/src/lib/dimensions-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'), 4 | expect = chai.expect, 5 | dim = require('../../../src/lib/dimensions'); 6 | 7 | chai.should(); 8 | 9 | 10 | describe('Dimensions module', function(){ 11 | 12 | describe('#gravity', function(){ 13 | var gravity = ['c', 600, 400, 100, 100]; 14 | 15 | it('should return correct values for center gravity', function(){ 16 | var g = dim.gravity.apply(null, gravity); 17 | g.x.should.equal(250); 18 | g.y.should.equal(150); 19 | }); 20 | 21 | it('should return correct values for north gravity', function(){ 22 | gravity[0] = 'n'; 23 | var g = dim.gravity.apply(null, gravity); 24 | g.x.should.equal(250); 25 | g.y.should.equal(0); 26 | }); 27 | 28 | it('should return correct values for northeast gravity', function(){ 29 | gravity[0] = 'ne'; 30 | var g = dim.gravity.apply(null, gravity); 31 | g.x.should.equal(500); 32 | g.y.should.equal(0); 33 | }); 34 | 35 | it('should return correct values for northwest gravity', function(){ 36 | gravity[0] = 'nw'; 37 | var g = dim.gravity.apply(null, gravity); 38 | g.x.should.equal(0); 39 | g.y.should.equal(0); 40 | }); 41 | 42 | it('should return correct values for south gravity', function(){ 43 | gravity[0] = 's'; 44 | var g = dim.gravity.apply(null, gravity); 45 | g.x.should.equal(250); 46 | g.y.should.equal(300); 47 | }); 48 | 49 | it('should return correct values for southeast gravity', function(){ 50 | gravity[0] = 'se'; 51 | var g = dim.gravity.apply(null, gravity); 52 | g.x.should.equal(500); 53 | g.y.should.equal(300); 54 | }); 55 | 56 | it('should return correct values for southwest gravity', function(){ 57 | gravity[0] = 'sw'; 58 | var g = dim.gravity.apply(null, gravity); 59 | g.x.should.equal(0); 60 | g.y.should.equal(300); 61 | }); 62 | 63 | it('should return correct values for east gravity', function(){ 64 | gravity[0] = 'e'; 65 | var g = dim.gravity.apply(null, gravity); 66 | g.x.should.equal(500); 67 | g.y.should.equal(150); 68 | }); 69 | 70 | it('should return correct values for west gravity', function(){ 71 | gravity[0] = 'w'; 72 | var g = dim.gravity.apply(null, gravity); 73 | g.x.should.equal(0); 74 | g.y.should.equal(150); 75 | }); 76 | }); 77 | 78 | 79 | describe('#cropFill', function(){ 80 | var modifiers = { gravity: 'c', height: 50, width: 50 }, 81 | size = { height: 400, width: 600 }; 82 | 83 | it('should return correct values for default gravity', function(){ 84 | var s = dim.cropFill(modifiers, size); 85 | s.resize.height.should.equal(50); 86 | s.crop.x.should.equal(Math.floor(((50/400 * 600) - 50)/2)); 87 | }); 88 | 89 | it('should return correct values for northeast gravity', function(){ 90 | modifiers.gravity = 'ne'; 91 | var s = dim.cropFill(modifiers, size); 92 | s.crop.x.should.equal(25); 93 | s.crop.y.should.equal(0); 94 | }); 95 | 96 | it('should return correct values for southeast gravity', function(){ 97 | modifiers.gravity = 'se'; 98 | var s = dim.cropFill(modifiers, size); 99 | s.crop.x.should.equal(25); 100 | s.crop.y.should.equal(0); 101 | }); 102 | 103 | it('should crop the largest dimension', function(){ 104 | var mods = { gravity: 'c', height: 40, width: 50 }; 105 | var s = dim.cropFill(mods, size); 106 | s.crop.height.should.equal(40); 107 | s.crop.width.should.equal(50); 108 | }); 109 | }); 110 | 111 | 112 | describe('#xy', function(){ 113 | var modifiers = { gravity: 'se', height: 50, width: 50, x: 10, y:15 }, 114 | size = { height: 400, width: 600 }; 115 | 116 | it('should use the x/y values instead of defined gravity', function(){ 117 | var s = dim.xy(modifiers, size.width, size.height, modifiers.width, modifiers.height); 118 | s.x.should.equal(modifiers.x); 119 | s.y.should.equal(modifiers.y); 120 | }); 121 | 122 | it('should not exceed bounds on x value', function(){ 123 | modifiers.width = 90; 124 | modifiers.x = 700; 125 | modifiers.y = 40; 126 | var s = dim.xy(modifiers, size.width, size.height, modifiers.width, modifiers.height); 127 | s.x.should.equal(510); 128 | s.y.should.equal(40); 129 | s.x.should.not.equal(modifiers.x); 130 | s.y.should.equal(modifiers.y); 131 | }); 132 | 133 | it('should not exceed bounds on y value', function(){ 134 | modifiers.height = 90; 135 | modifiers.x = 60; 136 | modifiers.y = 700; 137 | var s = dim.xy(modifiers, size.width, size.height, modifiers.width, modifiers.height); 138 | s.x.should.equal(60); 139 | s.y.should.equal(310); 140 | s.x.should.equal(modifiers.x); 141 | s.y.should.not.equal(modifiers.y); 142 | }); 143 | 144 | 145 | 146 | }); 147 | 148 | }); -------------------------------------------------------------------------------- /test/src/lib/modifiers-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'), 4 | _ = require('lodash'), 5 | expect = chai.expect, 6 | sm = require('sandboxed-module'), 7 | env = require('../../../src/config/environment_vars'), 8 | mod = require('../../../src/lib/modifiers'); 9 | 10 | chai.should(); 11 | 12 | 13 | describe('Modifiers module', function(){ 14 | 15 | // Metadata calls 16 | describe('Metadata request', function(){ 17 | it('should recognise a metadata call', function(){ 18 | var request = '/path/to/image.png.json'; 19 | mod.parse(request).action.should.equal('json'); 20 | }); 21 | 22 | it('should disregard modifiers in a metadata call', function(){ 23 | var request = '/s50-gne/path/to/image.png.json'; 24 | mod.parse(request).action.should.equal('json'); 25 | }); 26 | }); 27 | 28 | 29 | // Original image 30 | describe('No modifiers', function(){ 31 | it('should recognise no modifiers and return original action', function(){ 32 | var request = '/path/to/image.png'; 33 | mod.parse(request).action.should.equal('original'); 34 | }); 35 | 36 | it('should not return original if there are valid modifiers', function(){ 37 | var request = '/h500/path/to/image.jpg'; 38 | mod.parse(request).action.should.not.equal('original'); 39 | request = '/h500-gne/path/to/image.jpg'; 40 | mod.parse(request).action.should.not.equal('original'); 41 | }); 42 | 43 | it("Should add in a width parameter if MAX_IMAGE_DIMENSION specified", function(){ 44 | var request = '/path/to/image.png'; 45 | // override max image width environment variable 46 | var localEnv = _.clone(env); 47 | localEnv.MAX_IMAGE_DIMENSION = '500'; 48 | mod.parse(request, undefined, localEnv).width.should.equal(500); 49 | mod.parse(request, undefined, localEnv).height.should.equal(500); 50 | }); 51 | }); 52 | 53 | 54 | // Gravity 55 | describe('Gravity', function(){ 56 | it('should read gravity as a modifier string', function(){ 57 | var request = '/s50-gne/path/to/image.jpg'; 58 | mod.parse(request).gravity.should.equal('ne'); 59 | }); 60 | 61 | it('gravity should not be case sensitive', function(){ 62 | var request = '/s50-gNE/path/to/image.jpg'; 63 | mod.parse(request).gravity.should.equal('ne'); 64 | }); 65 | 66 | it('should not accept a non-valid gravity value', function(){ 67 | var request = '/s50-gnorth/path/to/image.jpg'; 68 | mod.parse(request).gravity.should.not.equal('north'); 69 | }); 70 | 71 | it('should set the action to square', function(){ 72 | var request = '/s50-gne/path/to/image.jpg'; 73 | mod.parse(request).action.should.equal('square'); 74 | }); 75 | 76 | it('should set the action to crop', function(){ 77 | var request = '/h400-w600-gse/path/to/image.jpg'; 78 | mod.parse(request).action.should.equal('crop'); 79 | }); 80 | it('should limit the parameter width to the MAX_IMAGE_DIMENSION if set', function(){ 81 | var request = '/h400-w600-gse/path/to/image.jpg'; 82 | var localEnv = _.clone(env); 83 | localEnv.MAX_IMAGE_DIMENSION = '500'; 84 | mod.parse(request, undefined, localEnv).width.should.equal(500); 85 | }); 86 | it('should set the width to original parameter width if less than the MAX_IMAGE_DIMENSION', function(){ 87 | var request = '/h400-w600-gse/path/to/image.jpg'; 88 | var localEnv = _.clone(env); 89 | localEnv.MAX_IMAGE_DIMENSION = '700'; 90 | mod.parse(request, undefined, localEnv).width.should.equal(600); 91 | }); 92 | }); 93 | 94 | 95 | // Square 96 | describe('Square', function(){ 97 | it('should set action to square', function(){ 98 | var request = '/s500/path/to/image.jpg'; 99 | mod.parse(request).action.should.equal('square'); 100 | }); 101 | 102 | it('should set the height and width correctly', function(){ 103 | var request = '/s500/path/to/image.jpg'; 104 | mod.parse(request).height.should.equal(500); 105 | mod.parse(request).width.should.equal(500); 106 | }); 107 | it('should not allow a crop value other than the fill', function(){ 108 | var request = '/s500-gne-cfill/image.jpg'; 109 | mod.parse(request).crop.should.equal('fill'); 110 | }); 111 | }); 112 | 113 | 114 | // Height 115 | describe('Height requests', function(){ 116 | it('should set the action to resize', function(){ 117 | var request = '/h400/path/to/image.png'; 118 | mod.parse(request).action.should.equal('resize'); 119 | }); 120 | it('should set the height and leave the width as null', function(){ 121 | var request = '/h400/image.png', 122 | p = mod.parse(request); 123 | expect(p.height).to.equal(400); 124 | expect(p.width).to.be.null; 125 | }); 126 | }); 127 | 128 | 129 | // Width 130 | describe('Width requests', function(){ 131 | it('should set the action to resize', function(){ 132 | var request = '/w400/path/to/image.png'; 133 | mod.parse(request).action.should.equal('resize'); 134 | }); 135 | it('should set the width and leave the height as null', function(){ 136 | var request = '/w400/image.png', 137 | p = mod.parse(request); 138 | expect(p.width).to.equal(400); 139 | expect(p.height).to.be.null; 140 | }); 141 | }); 142 | 143 | 144 | describe('Named modifiers', function(){ 145 | var nm = { 146 | "small-avatar": { 147 | "square": 60 148 | }, 149 | "large-avatar": { 150 | "square": 120 151 | }, 152 | "gallery": { 153 | "height": 400, 154 | "width": 600 155 | }, 156 | "thumb": { 157 | "gravity": "ne", 158 | "square": 50, 159 | "external": "local" 160 | } 161 | }; 162 | 163 | it('should read a thumbnail named config and set accordingly', function(){ 164 | var request = '/thumb/path/to/image.png', 165 | tn = nm.thumb; 166 | 167 | mod.parse(request, nm).gravity.should.equal(tn.gravity); 168 | mod.parse(request, nm).height.should.equal(tn.square); 169 | mod.parse(request, nm).width.should.equal(tn.square); 170 | }); 171 | 172 | it('should read a gallery named config and set accordingly', function(){ 173 | var request = '/gallery/path/to/image.png', 174 | tn = nm.gallery; 175 | 176 | mod.parse(request, nm).height.should.equal(tn.height); 177 | mod.parse(request, nm).width.should.equal(tn.width); 178 | }); 179 | 180 | }); 181 | 182 | // Quality 183 | describe('Quality requests', function(){ 184 | it('should leave the action as original', function(){ 185 | var request = '/q90/path/to/image.png'; 186 | mod.parse(request).action.should.equal('original'); 187 | }); 188 | it('should set the quality', function(){ 189 | var request = '/q90/image.png', 190 | p = mod.parse(request); 191 | expect(p.quality).to.equal(90); 192 | }); 193 | it('should clamp out of range quality values', function(){ 194 | var request, p; 195 | 196 | request = '/q101/image.png'; 197 | p = mod.parse(request); 198 | expect(p.quality).to.equal(100); 199 | 200 | request = '/q0/image.png'; 201 | p = mod.parse(request); 202 | expect(p.quality).to.equal(1); 203 | }); 204 | it('should use environment for default quality value', function(){ 205 | var request = '/image.png', 206 | p = mod.parse(request); 207 | expect(p.quality).to.equal(env.IMAGE_QUALITY); 208 | }); 209 | it('should ignore invalid quality value', function(){ 210 | var request = '/qinvalid/image.png', 211 | p = mod.parse(request); 212 | expect(p.quality).to.equal(env.IMAGE_QUALITY); 213 | }); 214 | }); 215 | 216 | }); --------------------------------------------------------------------------------