├── .gitignore ├── Procfile ├── test ├── fixtures │ ├── test.png │ ├── server-key.pem │ └── server-cert.pem └── test.js ├── bin └── image-proxy ├── app.json ├── .travis.yml ├── package.json ├── LICENSE ├── CHANGELOG.md ├── README.md └── lib └── image-proxy.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/image-proxy 2 | -------------------------------------------------------------------------------- /test/fixtures/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpmckinney/image-proxy/HEAD/test/fixtures/test.png -------------------------------------------------------------------------------- /bin/image-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var app = require('../lib/image-proxy')() 4 | , port = process.env.PORT || 5000; 5 | 6 | app.listen(port, function () { 7 | console.log('Listening on ' + port); 8 | }); 9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-proxy", 3 | "repository": "https://github.com/jpmckinney/image-proxy", 4 | "description": "A simple image proxy optimized for headshots", 5 | "keywords": ["image", "proxy"], 6 | "addons": [], 7 | "env": { 8 | "NODE_ENV": "production" 9 | }, 10 | "buildpacks": [ 11 | { 12 | "url": "https://github.com/ello/heroku-buildpack-imagemagick" 13 | }, 14 | { 15 | "url": "https://github.com/heroku/heroku-buildpack-nodejs" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | node_js: 7 | - "0.11" 8 | - "0.10" 9 | env: 10 | # express 3.0 was released late 2012 11 | # gm 1.2.0 added extent and gravity 12 | # mime 1.1.0 added extension 13 | - MODULES="express@~3 gm@1.2.0 mime@2.0.5" 14 | - MODULES="" 15 | install: 16 | - npm install $MODULES 17 | - npm install 18 | after_script: 19 | # @see https://github.com/cainus/node-coveralls#istanbul 20 | - DELAY=100 WHITELIST=localhost istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage 21 | -------------------------------------------------------------------------------- /test/fixtures/server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQChmEZ7tgXWNkfH5aijyyAljv+zaEVRh+CmQWNbgRf1WF8gNlU+ 3 | Tu+OtZreqAN+9gSLRsjcXWzBL0AUXC4l07niheQ/imLt9G1npugPwMcWL56lOLT/ 4 | robFzbhjUE6fItLwQzP51Me0Bnj9SnWugvd+jwjbe8GxjyjzcgAjTG56bQIDAQAB 5 | AoGAd19C6g5731N30T5hRqY+GCC72a90TZc/p/Fz0Vva8/4VP3mDnSS4qMaVIlgh 6 | RP++OZjPtqI5PbiG8MNrv7vZe0UXlV7oZE0IA+jomUXsplbwMFf6pkrqdyHi+cbm 7 | rBudhmKeLUgNA6peMGVA83C5g2SMqU5kB+tWzZT7Rs9rsyECQQDWpXxZgULqbFZv 8 | wjpIDGWjOpQZrv123bJ9TQ+VoskCu4vlyDJqDJPwnscl8NnzpFJriDARn0WrB2sd 9 | 8GCX1yEpAkEAwLo/MYG5elkNRsE5/vINSIo04Gu6tP/Sd7EBtHYAPHUPjs/MhhVX 10 | tMIGtACheHMwjGRPyr8pboEp2LEap4GjpQJBALNsy+CJ0+TfwPVU96EIc+GZcvlx 11 | NMErGyvwwclEtSDKo2vmCHZrozLtlu1ZQueOgbMPuZbRe8w2vEzfhe8HTtkCQAYy 12 | NrPlwsvPLyEWN0IeEBVD9D0+2WrWSrL0auSdYpaPAOgLgDzTVNWH42VIG+jeczIg 13 | S3xuNuvJlUnVL9Ew1s0CQQCly+gduXtvOYip1/Stm/65kT7d8ICQgjh0XSPw/kUC 14 | llVMQY3z1iFCaj/z0Csr0t0kJ534bH7GP3LOoNruV0p9 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /test/fixtures/server-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICcTCCAdoCCQDTgzSLdDTF0TANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJV 3 | UzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYDVQQKEwZKb3llbnQxEDAO 4 | BgNVBAsTB05vZGUuanMxDzANBgNVBAMTBmFnZW50MjEgMB4GCSqGSIb3DQEJARYR 5 | cnlAdGlueWNsb3Vkcy5vcmcwHhcNMTMwODAxMTExOTAwWhcNNDAxMjE2MTExOTAw 6 | WjB9MQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYD 7 | VQQKEwZKb3llbnQxEDAOBgNVBAsTB05vZGUuanMxDzANBgNVBAMTBmFnZW50MjEg 8 | MB4GCSqGSIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcwgZ8wDQYJKoZIhvcNAQEB 9 | BQADgY0AMIGJAoGBAKGYRnu2BdY2R8flqKPLICWO/7NoRVGH4KZBY1uBF/VYXyA2 10 | VT5O7461mt6oA372BItGyNxdbMEvQBRcLiXTueKF5D+KYu30bWem6A/AxxYvnqU4 11 | tP+uhsXNuGNQTp8i0vBDM/nUx7QGeP1Kda6C936PCNt7wbGPKPNyACNMbnptAgMB 12 | AAEwDQYJKoZIhvcNAQEFBQADgYEATzjDAPocPA2Jm8wrLBW+fOC478wMo9gT3Y3N 13 | ZU6fnF2dEPFLNETCMtDxnKhi4hnBpaiZ0fu0oaR1cSDRIVtlyW4azNjny4495C0F 14 | JLuP5P5pz+rJe+ImKw+mO1ARA9fUAL3VN6/kVXY/EspwWJcLbJ5jdsDmkRbV52hX 15 | Th4jkAI= 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-proxy", 3 | "version": "0.0.7", 4 | "description": "A simple image proxy optimized for headshots", 5 | "keywords": [ 6 | "image", 7 | "proxy" 8 | ], 9 | "homepage": "https://github.com/jpmckinney/image-proxy", 10 | "bugs": "https://github.com/jpmckinney/image-proxy/issues", 11 | "license": "MIT", 12 | "author": "James McKinney", 13 | "main": "./lib/image-proxy", 14 | "bin": "./bin/image-proxy", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/jpmckinney/image-proxy.git" 18 | }, 19 | "scripts": { 20 | "start": "./bin/image-proxy", 21 | "test": "DELAY=100 WHITELIST=localhost mocha" 22 | }, 23 | "dependencies": { 24 | "express": ">=3", 25 | "gm": ">=1.2.0", 26 | "mime": ">=2.0.5" 27 | }, 28 | "devDependencies": { 29 | "coveralls": "~2.11", 30 | "istanbul": "0.3.5", 31 | "mocha": "~1", 32 | "supertest": "~0.11" 33 | }, 34 | "engines": { 35 | "node": ">=0.6.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 James McKinney 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. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | * Fix transcoding to another file format [#21](https://github.com/jpmckinney/image-proxy/pull/22), thanks @equivalentideas 6 | 7 | ## 0.0.7 (2015-09-22) 8 | 9 | * Set a `Accept: */*` header [#15](https://github.com/jpmckinney/image-proxy/pull/15) 10 | 11 | ## 0.0.6 (2015-04-16) 12 | 13 | * [Set colorspace to RGB before resizing](http://www.imagemagick.org/Usage/resize/#resize_colorspace), thanks @lizconlan 14 | 15 | ## 0.0.5 (2015-03-30) 16 | 17 | * Add transcoding support 18 | 19 | ## 0.0.4 (2015-03-11) 20 | 21 | * Set a `User-Agent` header 22 | 23 | ## 0.0.3 (2014-01-16) 24 | 25 | * Handles relative redirects 26 | * Fix bug in handling timeouts [#9](https://github.com/jpmckinney/image-proxy/issues/9) 27 | * Don't use socket pooling to avoid `maxSockets` limit [#10](https://github.com/jpmckinney/image-proxy/issues/10) 28 | 29 | ## 0.0.2 (2014-10-06) 30 | 31 | * Fix parsing of `WHITELIST` environment variable [#7](https://github.com/jpmckinney/image-proxy/pull/7) 32 | * Upgrade to Express >=3 33 | * Rename and move files 34 | * Add tests 35 | 36 | ## 0.0.1 (Unpublished) 37 | 38 | First release 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Proxy 2 | 3 | [![NPM version](https://badge.fury.io/js/image-proxy.svg)](https://badge.fury.io/js/image-proxy) 4 | [![Build Status](https://secure.travis-ci.org/jpmckinney/image-proxy.png)](https://travis-ci.org/jpmckinney/image-proxy) 5 | [![Dependency Status](https://david-dm.org/jpmckinney/image-proxy.svg)](https://david-dm.org/jpmckinney/image-proxy) 6 | [![Coverage Status](https://coveralls.io/repos/jpmckinney/image-proxy/badge.png)](https://coveralls.io/r/jpmckinney/image-proxy) 7 | 8 | A simple Express app for proxying and manipulating images, specifically headshots. 9 | 10 | The code is just over 100 lines, making it easy to tailor to your needs. 11 | 12 | ## Getting Started 13 | 14 | First, [install the dependencies](https://github.com/aheckmann/gm#getting-started) for the `gm` package. 15 | 16 | npm install 17 | npm start 18 | curl -I http://localhost:5000/http%3A%2F%2F1.gravatar.com%2Favatar%2F60f641dfbb4215f1f6d6c059eebf1848/240/80.jpg 19 | 20 | The URL structure is `/:url/:width/:height.:extension?`. The `:url` parameter must be escaped/encoded. If the remote image's width or height is greater than the given `:width` or `:height`, it will be resized, maintaining aspect ratio, and cropped. If smaller, it will be padded with white pixels. If an optional `:extension` parameter is provided, the image will be transcoded to the corresponding file format. The equivalent ImageMagick command for the example URL above is: 21 | 22 | convert input.jpg -thumbnail 240x80^> -gravity center -extent 240x80 output.jpg 23 | 24 | The `Cache-Control` header sets a `max-age` of one year. 25 | 26 | ## Features 27 | 28 | Image proxy: 29 | 30 | * Supports HTTP and HTTPS 31 | * Follows 301 and 302 redirects 32 | * Sets a maximum timeout for the remote server 33 | * Handles complex MIME types like `image/jpeg; charset=utf-8` 34 | * Optional whitelisting using regular expressions 35 | 36 | Image manipulation: 37 | 38 | * Accepts a custom width and height, up to 1000x1000 39 | * Resizes, centers and crops the image 40 | * Optionally transcodes to another file format 41 | 42 | HTTP server: 43 | 44 | * No query string parameters (preferred by CloudFront) 45 | * Adds a Cache-Control header 46 | 47 | If you need more features, see [node-imageable](https://github.com/sdepold/node-imageable) and [node-imageable-server](https://github.com/dawanda/node-imageable-server). 48 | 49 | ### Environment variables 50 | 51 | * `DELAY`: The timeout delay in milliseconds, after which the proxy will respond with a HTTP 504 Gateway Timeout server error. Default: `5000` 52 | * `WHITELIST`: A comma-separated list of domains to whitelist, e.g. `.gov,facebook.com`, which will be transformed into the regular expressions `/\.gov$/` and `/facebook\.com$/`. 53 | * `PORT`: If running the server, changes the port on which it listens. Default: `5000` 54 | 55 | ## Deployment 56 | 57 | ### Heroku 58 | 59 | [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 60 | 61 | git clone https://github.com/jpmckinney/image-proxy.git 62 | heroku apps:create 63 | heroku config:set NODE_ENV=production 64 | git push heroku master 65 | heroku apps:open 66 | 67 | ### AWS CloudFront 68 | 69 | Create a distribution and set the "Origin Domain Name" to the domain name of your Heroku app. 70 | 71 | ## Testing 72 | 73 | npm test 74 | 75 | ## Acknowledgements 76 | 77 | This project is inspired by [node-connect-image-proxy](https://github.com/mysociety/node-connect-image-proxy). 78 | 79 | Copyright (c) 2013 James McKinney, released under the MIT license 80 | -------------------------------------------------------------------------------- /lib/image-proxy.js: -------------------------------------------------------------------------------- 1 | // @see https://devcenter.heroku.com/articles/nodejs#write-your-app 2 | 3 | var express = require('express') 4 | , fs = require('fs') // node 5 | , gm = require('gm') 6 | , http = require('http') // node 7 | , https = require('https') // node 8 | , mime = require('mime') 9 | , url = require('url') // node 10 | // @see http://aaronheckmann.posterous.com/graphicsmagick-on-heroku-with-nodejs 11 | , app = express() 12 | , imageMagick = gm.subClass({imageMagick: true}) 13 | , whitelist = process.env.WHITELIST || [] // [/\.gov$/, /google\.com$/] 14 | , delay = parseInt(process.env.DELAY) || 5000 15 | , mimeTypes = [ 16 | 'image/gif', 17 | 'image/jpeg', 18 | 'image/png', 19 | // Common typos 20 | 'image/jpg', 21 | ]; 22 | 23 | module.exports = function () { 24 | 25 | app.get('/:url/:width/:height.:extension?', function (req, res, next) { 26 | var width = req.params.width 27 | , height = req.params.height 28 | , extension = req.params.extension 29 | , retrieve = function (remote) { 30 | // @see http://nodejs.org/api/url.html#url_url 31 | var options = url.parse(remote); 32 | // @see https://github.com/substack/hyperquest 33 | options.agent = false; 34 | if (options.protocol !== 'http:' && options.protocol !== 'https:') { 35 | return res.status(404).send('Expected URI scheme to be HTTP or HTTPS'); 36 | } 37 | if (!options.hostname) { 38 | return res.status(404).send('Expected URI host to be non-empty'); 39 | } 40 | options.headers = {'User-Agent': 'image-proxy/0.0.7', 'Accept': '*/*'}; 41 | 42 | var agent = options.protocol === 'http:' ? http : https 43 | , timeout = false 44 | // @see http://nodejs.org/api/http.html#http_http_get_options_callback 45 | , request = agent.get(options, function (response) { 46 | if (timeout) { 47 | // Status code 504 already sent. 48 | return; 49 | } 50 | 51 | // @see http://nodejs.org/api/http.html#http_response_statuscode 52 | if ((response.statusCode === 301 || response.statusCode === 302) && response.headers['location']) { 53 | var redirect = url.parse(response.headers['location']); 54 | // @see https://tools.ietf.org/html/rfc7231#section-7.1.2 55 | if (!redirect.protocol) { 56 | redirect.protocol = options.protocol; 57 | } 58 | if (!redirect.hostname) { 59 | redirect.hostname = options.hostname; 60 | } 61 | if (!redirect.port) { 62 | redirect.port = options.port; 63 | } 64 | if (!redirect.hash) { 65 | redirect.hash = options.hash; 66 | } 67 | return retrieve(url.format(redirect)); 68 | } 69 | 70 | // The image must return status code 200. 71 | if (response.statusCode !== 200) { 72 | return res.status(404).send('Expected response code 200, got ' + response.statusCode); 73 | } 74 | 75 | // The image must be a valid content type. 76 | // @see http://nodejs.org/api/http.html#http_request_headers 77 | var mimeType; 78 | if (extension) { 79 | mimeType = mime.getType(extension); 80 | } 81 | else { 82 | mimeType = (response.headers['content-type'] || '').replace(/;.*/, ''); 83 | extension = mime.getExtension(mimeType); 84 | } 85 | if (mimeTypes.indexOf(mimeType) === -1) { 86 | return res.status(404).send('Expected content type ' + mimeTypes.join(', ') + ', got ' + mimeType); 87 | } 88 | 89 | // @see https://github.com/aheckmann/gm#constructor 90 | imageMagick(response, 'image.' + extension) 91 | .colorspace('RGB') 92 | // @see http://www.imagemagick.org/Usage/thumbnails/#cut 93 | .resize(width, height + '^>') 94 | .gravity('Center') // faces are most often near the center 95 | .extent(width, height) 96 | .stream(extension, function (err, stdout, stderr) { 97 | if (err) return next(err); 98 | // Log errors in production. 99 | stderr.pipe(process.stderr); 100 | // @see http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html 101 | res.writeHead(200, { 102 | 'Content-Type': mimeType, 103 | 'Cache-Control': 'max-age=31536000, public', // 1 year 104 | }); 105 | stdout.pipe(res); 106 | }); 107 | }).on('error', next); 108 | 109 | // Timeout after five seconds. Better luck next time. 110 | request.setTimeout(delay, function () { 111 | timeout = true; // if we abort, we'll get a "socket hang up" error 112 | return res.status(504).send(); 113 | }); 114 | }; 115 | 116 | // Validate parameters. 117 | if (whitelist.length) { 118 | var parts = url.parse(req.params.url); 119 | if (parts.hostname) { 120 | var any = false, _i, _len; 121 | if (typeof whitelist === 'string') { 122 | whitelist = whitelist.split(','); 123 | } 124 | for (_i = 0, _len = whitelist.length; _i < _len; _i++) { 125 | if (typeof whitelist[_i] === 'string') { 126 | // Escape periods and add anchor. 127 | whitelist[_i] = new RegExp(whitelist[_i].replace('.', '\\.') + '$') 128 | } 129 | if (whitelist[_i].test(parts.hostname)) { 130 | any = true; 131 | break; 132 | } 133 | } 134 | if (!any) { // if none 135 | return res.status(404).send('Expected URI host to be whitelisted'); 136 | } 137 | } 138 | } 139 | if (isNaN(parseInt(width))) { 140 | return res.status(404).send('Expected width to be an integer'); 141 | } 142 | if (parseInt(width) > 1000) { 143 | return res.status(404).send('Expected width to be less than or equal to 1000'); 144 | } 145 | if (isNaN(parseInt(height))) { 146 | return res.status(404).send('Expected height to be an integer'); 147 | } 148 | if (parseInt(height) > 1000) { 149 | return res.status(404).send('Expected height to be less than or equal to 1000'); 150 | } 151 | 152 | retrieve(req.params.url); 153 | }); 154 | 155 | return app; 156 | }; 157 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var request = require('supertest') 2 | , fs = require('fs') 3 | , http = require('http') 4 | , https = require('https') 5 | , url = require('url') 6 | , png = fs.readFileSync('test/fixtures/test.png') 7 | , app = require('../lib/image-proxy')(); 8 | 9 | function server(req, res) { 10 | var path = url.parse(req.url).pathname; 11 | if (path === '/301') { 12 | res.writeHead(301, { 13 | 'Location': 'http://localhost:8080/test.png' 14 | }); 15 | res.end(); 16 | } 17 | else if (path === '/302') { 18 | res.writeHead(302, { 19 | 'Location': 'http://localhost:8080/test.png' 20 | }); 21 | res.end(); 22 | } 23 | else if (path === '/location-relative') { 24 | res.writeHead(302, { 25 | 'Location': '/test.png' 26 | }); 27 | res.end(); 28 | } 29 | else if (path === '/location-empty') { 30 | res.writeHead(302, { 31 | 'Location': '' 32 | }); 33 | res.end(); 34 | } 35 | else if (path === '/location-missing') { 36 | res.writeHead(302); 37 | res.end(); 38 | } 39 | else if (path === '/404') { 40 | res.writeHead(404); 41 | res.end(); 42 | } 43 | else if (path === '/content-type-invalid') { 44 | res.writeHead(200, { 45 | 'Content-Type': 'text/plain' 46 | }); 47 | res.end(); 48 | } 49 | else if (path === '/content-type-empty') { 50 | res.writeHead(200, { 51 | 'Content-Type': '' 52 | }); 53 | res.end(); 54 | } 55 | else if (path === '/content-type-missing') { 56 | res.writeHead(200); 57 | res.end(); 58 | } 59 | else if (path === '/timeout') { 60 | res.writeHead(200, { 61 | 'Content-Type': 'image/png' 62 | }); 63 | setTimeout(function () { 64 | res.end(png, 'binary'); 65 | }, 1000); 66 | } 67 | else if (path === '/complex.png') { 68 | res.writeHead(200, { 69 | 'Content-Type': 'image/png; charset=utf-8' 70 | }); 71 | res.end(png, 'binary'); 72 | } 73 | else if (path === '/test.png') { 74 | res.writeHead(200, { 75 | 'Content-Type': 'image/png' 76 | }); 77 | res.end(png, 'binary'); 78 | } 79 | else if (path === '/error-without-accept-header') { 80 | if (req.headers['accept']) { 81 | res.writeHead(200, { 82 | 'Content-Type': 'image/png' 83 | }); 84 | res.end(png, 'binary'); 85 | } 86 | else { 87 | res.writeHead(403); 88 | res.end(); 89 | } 90 | } 91 | else { 92 | res.writeHead(500); 93 | res.end(path); 94 | } 95 | } 96 | 97 | var options = { 98 | // https://raw.githubusercontent.com/joyent/node/master/test/fixtures/keys/agent2-cert.pem 99 | cert: fs.readFileSync('test/fixtures/server-cert.pem') 100 | // https://raw.githubusercontent.com/joyent/node/master/test/fixtures/keys/agent2-key.pem 101 | , key: fs.readFileSync('test/fixtures/server-key.pem') 102 | }; 103 | 104 | // @see https://github.com/mikeal/request/issues/418 105 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 106 | http.createServer(server).listen(8080); 107 | https.createServer(options, server).listen(8081); 108 | 109 | describe('GET /:url/:width/:height.:extension?', function () { 110 | it('fails if a host is not in the whitelist', function (done) { 111 | request(app) 112 | .get('/http%3A%2F%2Fgoogle.com/100/100') 113 | .expect('Content-Type', 'text/html; charset=utf-8') 114 | .expect(404, 'Expected URI host to be whitelisted', done); 115 | }); 116 | 117 | it('fails if width is a non-integer', function (done) { 118 | request(app) 119 | .get('/http%3A%2F%2Flocalhost:8080%2Ftest.png/noninteger/100') 120 | .expect('Content-Type', 'text/html; charset=utf-8') 121 | .expect(404, 'Expected width to be an integer', done); 122 | }); 123 | it('fails if width is too large', function (done) { 124 | request(app) 125 | .get('/http%3A%2F%2Flocalhost:8080%2Ftest.png/1001/100') 126 | .expect('Content-Type', 'text/html; charset=utf-8') 127 | .expect(404, 'Expected width to be less than or equal to 1000', done); 128 | }); 129 | it('fails if height is a non-integer', function (done) { 130 | request(app) 131 | .get('/http%3A%2F%2Flocalhost:8080%2Ftest.png/100/noninteger') 132 | .expect('Content-Type', 'text/html; charset=utf-8') 133 | .expect(404, 'Expected height to be an integer', done); 134 | }); 135 | it('fails if height is too large', function (done) { 136 | request(app) 137 | .get('/http%3A%2F%2Flocalhost:8080%2Ftest.png/100/1001') 138 | .expect('Content-Type', 'text/html; charset=utf-8') 139 | .expect(404, 'Expected height to be less than or equal to 1000', done); 140 | }); 141 | 142 | it('fails if the protocol is unsupported', function (done) { 143 | request(app) 144 | .get('/ftp%3A%2F%2Flocalhost:8080/100/100') 145 | .expect('Content-Type', 'text/html; charset=utf-8') 146 | .expect(404, 'Expected URI scheme to be HTTP or HTTPS', done); 147 | }); 148 | it('fails if the host is empty', function (done) { 149 | request(app) 150 | .get('/http%3A%2F%2F%2Fpath/100/100') 151 | .expect('Content-Type', 'text/html; charset=utf-8') 152 | .expect(404, 'Expected URI host to be non-empty', done); 153 | }); 154 | 155 | it('follows 301 redirects', function (done) { 156 | request(app) 157 | .get('/http%3A%2F%2Flocalhost:8080%2F301/100/100') 158 | .expect('Content-Type', 'image/png') 159 | .expect('Cache-Control', 'max-age=31536000, public') 160 | .expect(200, done); 161 | }); 162 | it('follows 302 redirects', function (done) { 163 | request(app) 164 | .get('/http%3A%2F%2Flocalhost:8080%2F302/100/100') 165 | .expect('Content-Type', 'image/png') 166 | .expect('Cache-Control', 'max-age=31536000, public') 167 | .expect(200, done); 168 | }); 169 | it('follows local redirects', function (done) { 170 | request(app) 171 | .get('/http%3A%2F%2Flocalhost:8080%2Flocation-relative/100/100') 172 | .expect('Content-Type', 'image/png') 173 | .expect('Cache-Control', 'max-age=31536000, public') 174 | .expect(200, done); 175 | }); 176 | it('fails if the Location header is empty', function (done) { 177 | request(app) 178 | .get('/http%3A%2F%2Flocalhost:8080%2Flocation-empty/100/100') 179 | .expect('Content-Type', 'text/html; charset=utf-8') 180 | .expect(404, 'Expected response code 200, got 302', done); 181 | }); 182 | it('fails if the Location header is missing', function (done) { 183 | request(app) 184 | .get('/http%3A%2F%2Flocalhost:8080%2Flocation-missing/100/100') 185 | .expect('Content-Type', 'text/html; charset=utf-8') 186 | .expect(404, 'Expected response code 200, got 302', done); 187 | }); 188 | 189 | it('fails if the status code is not 200', function (done) { 190 | request(app) 191 | .get('/http%3A%2F%2Flocalhost:8080%2F404/100/100') 192 | .expect('Content-Type', 'text/html; charset=utf-8') 193 | .expect(404, 'Expected response code 200, got 404', done); 194 | }); 195 | 196 | it('fails if the content type is invalid', function (done) { 197 | request(app) 198 | .get('/http%3A%2F%2Flocalhost:8080%2Fcontent-type-invalid/100/100') 199 | .expect('Content-Type', 'text/html; charset=utf-8') 200 | .expect(404, 'Expected content type image/gif, image/jpeg, image/png, image/jpg, got text/plain', done); 201 | }); 202 | it('fails if the content type is empty', function (done) { 203 | request(app) 204 | .get('/http%3A%2F%2Flocalhost:8080%2Fcontent-type-empty/100/100') 205 | .expect('Content-Type', 'text/html; charset=utf-8') 206 | .expect(404, 'Expected content type image/gif, image/jpeg, image/png, image/jpg, got ', done); 207 | }); 208 | it('fails if the content type is missing', function (done) { 209 | request(app) 210 | .get('/http%3A%2F%2Flocalhost:8080%2Fcontent-type-missing/100/100') 211 | .expect('Content-Type', 'text/html; charset=utf-8') 212 | .expect(404, 'Expected content type image/gif, image/jpeg, image/png, image/jpg, got ', done); 213 | }); 214 | it('parses a complex content type', function (done) { 215 | request(app) 216 | .get('/http%3A%2F%2Flocalhost:8080%2Fcomplex.png/100/100') 217 | .expect('Content-Type', 'image/png') 218 | .expect(200, done); 219 | }); 220 | it('returns the requested content type', function (done) { 221 | request(app) 222 | .get('/http%3A%2F%2Flocalhost:8080%2Fcomplex.png/100/100.jpg') 223 | .expect('Content-Type', 'image/jpeg') 224 | .expect(200, done); 225 | }); 226 | 227 | it('timesout if the request takes too long', function (done) { 228 | request(app) 229 | .get('/http%3A%2F%2Flocalhost:8080%2Ftimeout/100/100') 230 | .expect(504, function () { 231 | setTimeout(done, 1000); 232 | }); 233 | }); 234 | 235 | it('supports HTTP', function (done) { 236 | request(app) 237 | .get('/http%3A%2F%2Flocalhost:8080%2Ftest.png/100/100') 238 | .expect('Content-Type', 'image/png') 239 | .expect('Cache-Control', 'max-age=31536000, public') 240 | .expect(200, done); 241 | }); 242 | it('supports HTTPS', function (done) { 243 | request(app) 244 | .get('/https%3A%2F%2Flocalhost:8081%2Ftest.png/100/100') 245 | .expect('Content-Type', 'image/png') 246 | .expect('Cache-Control', 'max-age=31536000, public') 247 | .expect(200, done); 248 | }); 249 | 250 | it('sends an accept header in the request', function (done) { 251 | request(app) 252 | .get('/https%3A%2F%2Flocalhost:8081%2Ferror-without-accept-header/100/100') 253 | .expect(200, done); 254 | }); 255 | }); 256 | --------------------------------------------------------------------------------