├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .nvmrc ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── errors.js ├── parseQueryParameters.js └── parseQueryParameters.spec.js ├── functions ├── getImage │ ├── index.js │ └── index.spec.js ├── resizeImage │ ├── index.js │ └── index.spec.js └── uploadImage │ ├── index.js │ └── index.spec.js ├── index.js ├── package.json └── serverless.yml /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "extends": "airbnb" 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | # This is a (Bug Report / Feature Proposal) 10 | 11 | ## Description 12 | 13 | For bug reports: 14 | * What went wrong? 15 | * What did you expect should have happened? 16 | * What was the config you used? 17 | * What stacktrace or error message from your provider did you see? 18 | 19 | For feature proposals: 20 | * What is the use case that should be solved. The more detail you describe this in the easier it is to understand for us. 21 | * If there is additional config how would it look 22 | 23 | Similar or dependent issues: 24 | * #12345 25 | 26 | ## Additional Data 27 | 28 | * ***Serverless Framework Version you're using***: 29 | * ***Operating System***: 30 | * ***Stack Trace***: 31 | * ***Provider Error messages***: 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## What did you implement: 8 | 9 | Closes #XXXXX 10 | 11 | 14 | 15 | ## How did you implement it: 16 | 17 | 20 | 21 | ## How can we verify it: 22 | 23 | 34 | 35 | ## Todos: 36 | 37 | - [ ] Write tests 38 | - [ ] Write documentation 39 | - [ ] Fix linting errors 40 | - [ ] Make sure code coverage hasn't dropped 41 | - [ ] Provide verification config / commands / resources 42 | - [ ] Enable "Allow edits from maintainers" for this PR 43 | - [ ] Update the messages below 44 | 45 | ***Is this ready for review?:*** NO 46 | ***Is it a breaking change?:*** NO 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *pass.key 3 | CACHE/ 4 | .sass-cache/ 5 | db.sqlite3 6 | 7 | static/dist 8 | 9 | # begin node gitignore 10 | # Logs 11 | logs 12 | *.log 13 | 14 | # keys and other stuff not to be kept on github 15 | secrets.json 16 | aws.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | 23 | .DS_Store 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # elasticbeanstalk 38 | .eb 39 | .elasticbeanstalk 40 | 41 | 42 | # Compiled binary addons (http://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directory 46 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 47 | node_modules 48 | bower_components 49 | 50 | # angular gitignore 51 | 52 | /build/ 53 | /benchpress-build/ 54 | .DS_Store 55 | gen_docs.disable 56 | test.disable 57 | regression/temp*.html 58 | performance/temp*.html 59 | .idea/workspace.xml 60 | *~ 61 | *.swp 62 | angular.js.tmproj 63 | /node_modules/ 64 | bower_components/ 65 | angular.xcodeproj 66 | .idea 67 | *.iml 68 | .agignore 69 | .lvimrc 70 | libpeerconnection.log 71 | npm-debug.log 72 | /tmp/ 73 | /scripts/bower/bower-* 74 | 75 | static/dist 76 | webpack-assets.json 77 | 78 | .serverless/ 79 | .env 80 | 81 | config.json 82 | 83 | # Elastic Beanstalk Files 84 | .elasticbeanstalk/* 85 | !.elasticbeanstalk/*.cfg.yml 86 | !.elasticbeanstalk/*.global.yml 87 | dist/ 88 | knexfile.js -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.10.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | # node_js: travis will use the version specified in .nvmrc 3 | cache: 4 | directories: 5 | - node_modules 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2017] [Nicholas Gubbins] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Serverless-Image-Resizer 2 | ======================== 3 | [![Build Status][travis-image]][travis-url] [![dependencies Status][david-dep-image]][david-dep-url] [![devDependencies Status][david-devDep-image]][david-devDep-url] [![NPM version][npm-image]][npm-url] [![Twitter URL][twitter-image]][twitter-url] 4 | 5 | Serverless-Image-Resizer is an image processing service that runs on AWS Lambda and S3. 6 | 7 | # Summary 8 | 9 | Put simply, Serverless-Image-Resizer works by requesting an image file from S3 and applying image 10 | processing functions to that image. Image processing functions are sent as query parameters in the 11 | request URL. Serverless-Image-Resizer first checks to see if the requested image (including effects) 12 | is stored in S3. If it is, then the cached version is returned. If it is not, then the 13 | processing functions are applied to the original image, and the resulting image is cached in S3 and 14 | sent back to the requester. 15 | 16 | ## Example 17 | 18 | The original image on the left has been vertically resized to 300 px and has had a blur of radius 0 19 | and sigma 3 applied to create the image on the right. The URL to perform this effect would be 20 | `https://API-URL.com/path/to/image.jpg?h=300&b=0x3`. 21 | 22 | | Original | Edited | 23 | | --- | --- | 24 | | | | 25 | 26 | # Setup 27 | 28 | ## AWS and Serverless 29 | 30 | This project relies on AWS + The [Serverless Framework](https://github.com/serverless/serverless) 31 | to deploy and manage your service. If it is not already, install serverless globally: 32 | 33 | ```sh 34 | $ npm install -g serverless 35 | ``` 36 | 37 | You will need an AWS account to deploy this service. If you do not already have one, sign up at 38 | https://aws.amazon.com 39 | 40 | You will need AWS credentials to programmatically deploy your service from the commandline. Follow 41 | the [Serverless AWS Credentials documentation](https://serverless.com/framework/docs/providers/aws/guide/credentials/) 42 | to get setup. 43 | 44 | ## Code 45 | 46 | There are two ways to get the project code, choose from one of the options: 47 | 1. Clone the project and deploy from that project directory 48 | 2. npm install the module and incorporate it into your own project 49 | 50 | ### Clone 51 | 52 | ```sh 53 | $ git clone https://github.com/nicholasgubbins/Serverless-Image-Resizer.git && cd Serverless-Image-Resizer 54 | $ git checkout $(git describe --tags `git rev-list --tags --max-count=1`) # checkout latest release 55 | $ npm i # or $ yarn 56 | ``` 57 | 58 | ### npm 59 | 60 | In your project directory, npm install the node module and the browserify serverless plugin: 61 | 62 | ```sh 63 | $ npm install --save serverless-image-resizer 64 | $ npm install --save-dev serverless-plugin-browserify 65 | ``` 66 | 67 | You can change where the function handlers live by editing `functions.FUNCTION_NAME.handler` in 68 | `serverless.yml`, but using the paths that are there now, you would use `serverless-image-resizer` 69 | by creating the files below: 70 | 71 | ```js 72 | // in functions/resizeImage/index.js 73 | const { resizeImage } = require('serverless-image-resizer'); 74 | 75 | module.exports.handler = resizeImage.handler; 76 | 77 | 78 | // in functions/getImage/index.js 79 | const { getImage } = require('serverless-image-resizer'); 80 | 81 | module.exports.handler = getImage.handler; 82 | ``` 83 | 84 | You will also need to copy [`serverless.yml`](https://raw.githubusercontent.com/nicholasgubbins/Serverless-Image-Resizer/master/serverless.yml) 85 | to the top level of your project directory. 86 | 87 | ## Rename the AWS Region and S3 bucket 88 | 89 | In `serverless.yml` change `provider.region` to the AWS Region your S3 bucket exists in, and where 90 | you want your Lambda Function and API Gateway endpoints to exist. Also change 91 | `provider.environment.BUCKET` to be the name of your S3 bucket. 92 | 93 | ## Deploy the service 94 | 95 | Using serverless, deploy the service from the top level of the project: 96 | 97 | ```sh 98 | $ sls deploy 99 | ``` 100 | 101 | ## API Gateway Binary Support 102 | 103 | Currently, there is no way to configure Binary Support using serverless (related [serverless issue](https://github.com/serverless/serverless/issues/2797)). 104 | For now we can set this manually using the AWS Console: 105 | 1. Open the AWS Console 106 | 2. Click the API Gateway Service 107 | 3. Click on your service in the left sidebar 108 | 4. Click "Binary Support" 109 | 5. Click the "Edit" button on the right side of the page 110 | 5. Add `*/*` to the text input and click "Save" 111 | 6. Click on your service in the left sidebar 112 | 7. The "Actions" dropdown button should have an orange dot next to it, click on the "Actions" button. 113 | 8. Click on "Deploy API" in the dropdown menu 114 | 9. Select the "dev" service (or another service if you have configured one) 115 | 10. Click the "Deploy" button 116 | 117 | ## You're done! 118 | 119 | Once you've reach this point your service is ready to use. 120 | 121 | You can run `$ sls info` to print out the details about your service. You should see one "endpoint" 122 | that has a `GET` method. Copy this URL and paste it into your browser. Replace `{proxy+}` with a 123 | path to one of your images in S3 (omitting the `BUCKET_NAME` defined in `serverless.yml`). For 124 | example: 125 | 126 | ``` 127 | https://LAMBDA-ID.execute-api.eu-west-1.amazonaws.com/dev/path/to/image.png 128 | ``` 129 | 130 | # Usage 131 | 132 | Serverless-Image-Resizer supports the following query params: 133 | 134 | | Parameter | Description | Format | Example | 135 | | --- | --- | --- | --- | 136 | | w | width | number | `?w=150` | 137 | | h | height | number | `?h=200` | 138 | | w&h | crop | number | `?w=150&h=200` | 139 | | f | filter | string | `?q=Point` | 140 | | q | quality | number | `?q=2` | 141 | | m | max | number | `?m=3` | 142 | | b | blur | numberxnumber | `?b=0x7` | 143 | 144 | For example: 145 | 146 | ``` 147 | https://LAMBDA-ID.execute-api.eu-west-1.amazonaws.com/dev/path/to/image.png?w=100&h=200&b=0x3 148 | ``` 149 | 150 | # Development 151 | 152 | AWS Lambda 153 | [supports node 6.10.2](http://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html) 154 | so that should be used during development. If you have [`nvm`](https://github.com/creationix/nvm) 155 | installed you can run `$ nvm use` to use the version in the `.nvmrc` file. 156 | 157 | ## Linting 158 | 159 | This project uses the [eslint-config-airbnb](https://www.npmjs.com/package/eslint-config-airbnb) 160 | linting configuration. To run eslint execute the lint command: 161 | 162 | ```sh 163 | $ npm run lint 164 | ``` 165 | 166 | ## Testing 167 | 168 | Tests are written and executed using [Jest](https://facebook.github.io/jest/). To write a test, 169 | create a `FILE_NAME.spec.js` file and Jest will automatically run it when you execute the test 170 | command: 171 | 172 | ```sh 173 | $ npm test 174 | $ npm run test:watch # to test as you develop 175 | $ npm run test:coverage # to test code coverage 176 | ``` 177 | 178 | Note that `npm run test:coverage` will create a `coverage` folder that is gitignored. 179 | 180 | [david-dep-image]: https://david-dm.org/nicholasgubbins/serverless-image-resizer/status.svg 181 | [david-dep-url]: https://david-dm.org/nicholasgubbins/serverless-image-resizer 182 | [david-devDep-image]: https://david-dm.org/nicholasgubbins/serverless-image-resizer/dev-status.svg 183 | [david-devDep-url]: https://david-dm.org/nicholasgubbins/serverless-image-resizer?type=dev 184 | [npm-image]: https://badge.fury.io/js/serverless-image-resizer.svg 185 | [npm-url]: https://npmjs.org/package/serverless-image-resizer 186 | [travis-image]: https://travis-ci.org/nicholasgubbins/Serverless-Image-Resizer.svg?branch=master 187 | [travis-url]: https://travis-ci.org/nicholasgubbins/Serverless-Image-Resizer 188 | [twitter-image]: https://img.shields.io/twitter/url/https/github.com/nicholasgubbins/serverless-image-resizer.svg?style=social 189 | [twitter-url]: https://twitter.com/intent/tweet?text=Make%20your%20own%20serverless%20image%20processing%20API%20on%20AWS%20with%20this%20node%20package!%20https://github.com/nicholasgubbins/serverless-image-resizer 190 | -------------------------------------------------------------------------------- /bin/errors.js: -------------------------------------------------------------------------------- 1 | exports.NOT_FOUND = { 2 | statusCode: 404, 3 | body: '{"message": "Not found"}', 4 | }; 5 | exports.SOMETHING_WRONG = { 6 | statusCode: 500, 7 | body: '{"message": "Something went wrong"}', 8 | }; 9 | -------------------------------------------------------------------------------- /bin/parseQueryParameters.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function parseQueryParameters(query) { 4 | const format = {}; 5 | if ((query.w && parseInt(query.w) !== NaN) || (query.h && parseInt(query.h) !== NaN)) { 6 | format.gravity = 'Center'; 7 | format.resize = {}; 8 | if (query.h && query.h.length > 4) { 9 | const test_height_first = query.h.slice(0, query.h.length / 2); 10 | const test_height_second = query.h.slice((query.h.length / 2)); 11 | if (test_height_first == test_height_second) { 12 | query.h = test_height_first; 13 | } 14 | } 15 | if (query.f) format.filter = 'Point'; 16 | format.resize.width = (query.w && parseInt(query.w) !== NaN) ? parseInt(query.w) : null; 17 | format.resize.height = (query.h && parseInt(query.h) !== NaN) ? parseInt(query.h) : null; 18 | if (format.resize.height !== null && format.resize.width !== null) format.crop = { width: format.resize.width, height: format.resize.height }; 19 | } 20 | if (query.f) format.filter = query.f; 21 | if (query.q && parseInt(query.q) !== NaN) format.quality = parseInt(query.q); 22 | if (query.m && parseInt(query.m) !== NaN) format.max = parseInt(query.m); 23 | if (query.b && query.b.match(/[0-9]{1,}x[0-9]{1,}/g)) format.blur = query.b.split('x'); 24 | return format; 25 | } 26 | 27 | module.exports = parseQueryParameters; 28 | -------------------------------------------------------------------------------- /bin/parseQueryParameters.spec.js: -------------------------------------------------------------------------------- 1 | const pqp = require('./parseQueryParameters'); 2 | 3 | // todo: test bad input to show params are ignored 4 | 5 | describe('parseQueryParameters', () => { 6 | test('should return `{}` for no query params', () => { 7 | const actual = pqp({}); // todo: support passing in `undefined`? 8 | const expected = {}; 9 | expect(actual).toEqual(expected); 10 | }); 11 | 12 | // query.w 13 | // && h 14 | // && f 15 | test('should parse query.w', () => { 16 | const actual = pqp({ w: 100 }); 17 | const expected = { 18 | gravity: 'Center', 19 | resize: { 20 | height: null, 21 | width: 100, 22 | }, 23 | }; 24 | expect(actual).toEqual(expected); 25 | }); 26 | 27 | // todo: overwritten code? if (query.f) format.filter = 'Point'; 28 | test('should parse query.w & f', () => { 29 | const actual = pqp({ w: 100, f: 'test' }); 30 | const expected = { 31 | gravity: 'Center', 32 | resize: { 33 | height: null, 34 | width: 100, 35 | }, 36 | filter: 'test', 37 | }; 38 | expect(actual).toEqual(expected); 39 | }); 40 | 41 | // query.h 42 | // && w 43 | // && f 44 | test('should parse query.h', () => { 45 | const actual = pqp({ h: 100 }); 46 | const expected = { 47 | gravity: 'Center', 48 | resize: { 49 | height: 100, 50 | width: null, 51 | }, 52 | }; 53 | expect(actual).toEqual(expected); 54 | }); 55 | 56 | // todo: test query.h is array? 57 | 58 | // query.h & query.w 59 | test('should parse query.h', () => { 60 | const actual = pqp({ h: 100, w: 200 }); 61 | const expected = { 62 | gravity: 'Center', 63 | resize: { 64 | height: 100, 65 | width: 200, 66 | }, 67 | crop: { 68 | height: 100, 69 | width: 200, 70 | }, 71 | }; 72 | expect(actual).toEqual(expected); 73 | }); 74 | 75 | // query.f 76 | test('should parse query.f', () => { 77 | const actual = pqp({ f: 'test' }); 78 | const expected = { filter: 'test' }; 79 | expect(actual).toEqual(expected); 80 | }); 81 | 82 | // query.q 83 | test('should parse query.q', () => { 84 | const actual = pqp({ q: 3 }); 85 | const expected = { quality: 3 }; 86 | expect(actual).toEqual(expected); 87 | }); 88 | 89 | // query.m 90 | test('should parse query.m', () => { 91 | const actual = pqp({ m: 3 }); 92 | const expected = { max: 3 }; 93 | expect(actual).toEqual(expected); 94 | }); 95 | 96 | // query.b 97 | test('should parse query.b', () => { 98 | const actual = pqp({ b: '123x456' }); 99 | const expected = { blur: ['123', '456'] }; 100 | expect(actual).toEqual(expected); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /functions/getImage/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const AWS = require('aws-sdk'); 4 | 5 | const s3 = new AWS.S3(); 6 | 7 | const parseQueryParameters = require('../../bin/parseQueryParameters'); 8 | const Errors = require('../../bin/errors'); 9 | 10 | function checkS3(key) { 11 | return new Promise((resolve, reject) => { 12 | s3.headObject({ Bucket: process.env.BUCKET, Key: key }, (err, metadata) => { 13 | if (err && ['NotFound', 'Forbidden'].indexOf(err.code) > -1) return resolve(); 14 | else if (err) { 15 | const e = Object.assign({}, Errors.SOMETHING_WRONG, { err }); 16 | return reject(e); 17 | } 18 | return resolve(metadata); 19 | }); 20 | }); 21 | } 22 | 23 | function getS3(key) { 24 | return new Promise((resolve, reject) => { 25 | s3.getObject({ Bucket: process.env.BUCKET, Key: key }, (err, data) => { 26 | if (err && err.code == 'NotFound') return reject(Errors.NOT_FOUND); 27 | else if (err) { 28 | const e = Object.assign({}, Errors.SOMETHING_WRONG, { err }); 29 | return reject(e); 30 | } 31 | const content_type = data.ContentType; 32 | const image = new Buffer(data.Body).toString('base64'); 33 | return resolve({ 34 | statusCode: 200, 35 | headers: { 'Content-Type': content_type }, 36 | body: image, 37 | isBase64Encoded: true, 38 | }); 39 | }); 40 | }); 41 | } 42 | 43 | 44 | function stripQueryParams(query) { 45 | query = query || {}; 46 | const return_query = {}; 47 | Object.keys(query).filter(k => ['w', 'h', 'f', 'q', 'm', 'b'].indexOf(k) > -1).sort().forEach(k => return_query[k] = query[k]); 48 | return return_query; 49 | } 50 | 51 | function generateKey(image_path, query) { 52 | let key = image_path; 53 | const keys = Object.keys(query); 54 | if (query && keys.length > 0) { 55 | key += '?'; 56 | keys.sort().forEach((k, i) => { 57 | key += `${k}=${query[k]}`; 58 | if (i !== keys.length - 1) key += '&'; 59 | }); 60 | } 61 | if (key[0] == '/') key = key.substring(1); 62 | return key; 63 | } 64 | 65 | 66 | function resize(data) { 67 | const lambda = new AWS.Lambda({ region: process.env.region }); 68 | return new Promise((resolve, reject) => lambda.invoke({ 69 | Payload: JSON.stringify(data), 70 | FunctionName: process.env.RESIZE_LAMBDA, 71 | }, (err, result) => ((err) ? reject(err) : 72 | (result.FunctionError) ? reject({ statusCode: 502, body: result.Payload }) 73 | : resolve(result)))); 74 | } 75 | 76 | 77 | function processImage(image_path, query, destination_path) { 78 | image_path = (image_path[0] == '/') ? image_path.substring(1) : image_path; 79 | return checkS3(image_path) 80 | .then((metadata) => { 81 | if (!metadata) throw Errors.NOT_FOUND; 82 | console.log('s3 base', image_path, 'exists but we need to process it into', destination_path); 83 | const lambda_data = { 84 | mime_type: metadata.ContentType, 85 | resize_options: parseQueryParameters(query), 86 | asset: image_path, 87 | destination: destination_path, 88 | bucket: process.env.BUCKET, 89 | storage_class: 'REDUCED_REDUNDANCY', 90 | }; 91 | console.log(JSON.stringify(lambda_data)); 92 | 93 | return resize(lambda_data); 94 | }) 95 | .then(() => getS3(destination_path)); 96 | } 97 | 98 | module.exports.handler = (event, context, callback) => { 99 | const query = stripQueryParams(event.queryStringParameters); 100 | const key = generateKey(event.path, query); 101 | console.log(key); 102 | return checkS3(key) 103 | .then((metadata) => { 104 | if (metadata) return getS3(key).then(data => callback(null, data)); 105 | else if (Object.keys(query).length > 0) return processImage(event.path, event.queryStringParameters, key).then(data => callback(null, data)); 106 | return callback(null, Errors.NOT_FOUND); 107 | }) 108 | .catch((e) => { 109 | console.log(e); 110 | console.log(e.stack); 111 | callback(null, e); 112 | }); 113 | }; 114 | 115 | module.exports.stripQueryParams = stripQueryParams; 116 | module.exports.generateKey = generateKey; 117 | -------------------------------------------------------------------------------- /functions/getImage/index.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateKey, 3 | stripQueryParams, 4 | } = require('./index.js'); 5 | 6 | 7 | describe('getImage', () => { 8 | describe('generateKey', () => { 9 | test('should strip first slash', () => { 10 | const actual = generateKey('/path/to/image', {}); 11 | const expected = 'path/to/image'; 12 | expect(actual).toEqual(expected); 13 | }); 14 | 15 | test('should strip first slash', () => { 16 | const imagePath = 'path/to/image'; 17 | const query = { 18 | w: 100, 19 | h: 200, 20 | f: 'filter', 21 | }; 22 | const actual = generateKey(imagePath, query); 23 | const expected = 'path/to/image?f=filter&h=200&w=100'; 24 | expect(actual).toEqual(expected); 25 | }); 26 | }); 27 | 28 | describe('stripQueryParams', () => { 29 | test('should filter out supported query params', () => { 30 | const query = { 31 | a: 'filter this', 32 | b: 'keep this', 33 | c: 'filter this', 34 | f: 'keep this', 35 | h: 'keep this', 36 | m: 'keep this', 37 | q: 'keep this', 38 | w: 'keep this', 39 | z: 'filter this', 40 | }; 41 | const actual = stripQueryParams(query); 42 | const expected = { 43 | b: 'keep this', 44 | f: 'keep this', 45 | h: 'keep this', 46 | m: 'keep this', 47 | q: 'keep this', 48 | w: 'keep this', 49 | }; 50 | expect(actual).toEqual(expected); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /functions/resizeImage/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const gm = require('gm').subClass({ imageMagick: true }); 4 | const Promise = require('bluebird'); 5 | const AWS = require('aws-sdk'); 6 | 7 | const s3 = new AWS.S3(); 8 | const S3Stream = require('s3-upload-stream')(s3); 9 | 10 | 11 | const DEFAULT_TIME_OUT = 15000; 12 | 13 | 14 | function gmCreator(asset, bucket, resize_options) { // function to create a GM process 15 | return new Promise((resolve, reject) => { 16 | try { 17 | const func = gm(s3.getObject({ Bucket: bucket, Key: asset }).createReadStream()); 18 | func.options({ timeout: resize_options.timeout || DEFAULT_TIME_OUT }); 19 | if (resize_options.quality) func.quality(resize_options.quality); 20 | if (resize_options.resize && resize_options.crop) func.resize(resize_options.resize.width, resize_options.resize.height, '^'); 21 | else if (resize_options.resize) func.resize(resize_options.resize.width, resize_options.resize.height); 22 | if (resize_options.filter) func.filter(resize_options.filter); 23 | if (resize_options.strip) func.strip(); 24 | if (resize_options.gravity) func.gravity(resize_options.gravity); 25 | if (resize_options.crop) func.crop(resize_options.crop.width, resize_options.crop.height, 0, 0); 26 | if (resize_options.max) func.resize(resize_options.max); 27 | if (resize_options.compress) func.compress(resize_options.compress); 28 | if (resize_options.blur) { func.blur(resize_options.blur[0], resize_options.blur[1]); } 29 | return resolve(func); 30 | } catch (err) { 31 | return reject({ statusCode: 500, body: `{"message":"${err.message}"}` }); 32 | } 33 | }); 34 | } 35 | 36 | function uploadToS3(destination, bucket, mime_type, storage_class, gm) { 37 | return new Promise((resolve, reject) => { 38 | try { 39 | const target_stream = S3Stream.upload({ 40 | Bucket: bucket, 41 | Key: destination, 42 | ContentType: mime_type, 43 | StorageClass: storage_class, 44 | }); 45 | const file_type = mime_type.substring(mime_type.indexOf('/') + 1); 46 | gm.stream(file_type, (err, stdout, stderr) => { 47 | if (err) { 48 | return reject({ statusCode: 500, body: `{"message":"${err.message}"}` }); 49 | } 50 | 51 | stdout.on('error', err => reject({ statusCode: 500, body: `{"message":"${err.message}"}` })); 52 | stderr.on('data', data => reject({ statusCode: 500, body: `{"message":"${data}"}` })); 53 | stdout.pipe(target_stream) 54 | .on('uploaded', done => resolve()) 55 | .on('error', err => reject({ statusCode: 500, body: `{"message":"${err.message}"}` })); 56 | }); 57 | } catch (err) { 58 | return reject({ statusCode: 500, body: `{"message":"${err.message}"}` }); 59 | } 60 | }); 61 | } 62 | 63 | 64 | const EVENT_PARAMS = { 65 | asset: { type: 'string', required: true }, 66 | destination: { type: 'string', required: true }, 67 | bucket: { type: 'string', required: true }, 68 | resize_options: { type: 'object', required: true }, 69 | mime_type: { type: 'string', required: true }, 70 | storage_class: { type: 'string', default: 'STANDARD' }, 71 | }; 72 | const GM_KEYS = ['timeout', 73 | 'quality', 74 | 'resize', 75 | 'crop', 76 | 'resize', 77 | 'filter', 78 | 'strip', 79 | 'gravity', 80 | 'crop', 81 | 'max', 82 | 'compress', 83 | 'blur']; 84 | 85 | function formatEvent(event) { 86 | return new Promise((resolve, reject) => { 87 | event = (!event) ? {} : event; 88 | const job = {}; 89 | Object.keys(EVENT_PARAMS).forEach((k) => { 90 | if (!event[k] && EVENT_PARAMS[k].required) return reject({ statusCode: 400, body: `{"message": "${k} required"}` }); 91 | else if (typeof event[k] !== EVENT_PARAMS[k].type) return reject({ statusCode: 400, body: `{"message": "${k} should be of type ${EVENT_PARAMS[k].type}"}` }); 92 | job[k] = event[k] || EVENT_PARAMS.default; 93 | }); 94 | Object.keys(job.resize_options).forEach((k) => { if (GM_KEYS.indexOf(k) == -1) { delete job.resize_options[k]; } }); 95 | return resolve(job); 96 | }); 97 | } 98 | 99 | 100 | module.exports.handler = (event, context, callback) => formatEvent(event) 101 | .then(job => gmCreator(job.asset, job.bucket, job.resize_options) 102 | .then(gm => uploadToS3(job.destination, job.bucket, job.mime_type, job.storage_class, gm)) 103 | .then(() => context.succeed({ success: true }))) 104 | .catch((e) => { 105 | context.fail(JSON.stringify(e)); 106 | }); 107 | 108 | module.exports.formatEvent = formatEvent; 109 | 110 | -------------------------------------------------------------------------------- /functions/resizeImage/index.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | formatEvent, 3 | } = require('./index.js'); 4 | 5 | 6 | describe('getImage', () => { 7 | describe('formatEvent', () => { 8 | // todo: if event is `{}` promise goes unresolved 9 | // todo: JSON stringify responses 10 | // todo: 'crop' twice in GM_KEYS 11 | 12 | test('should reject event without required params', (done) => { 13 | const expected = { 14 | statusCode: 400, 15 | body: '{"message": "asset required"}', 16 | }; 17 | formatEvent().catch((e) => { 18 | const { statusCode, body } = expected; 19 | expect(e.statusCode).toEqual(statusCode); 20 | expect(e.body).toEqual(body); 21 | done(); 22 | }); 23 | }); 24 | 25 | test('should reject event with incorrect type', (done) => { 26 | const event = { 27 | asset: 1234, 28 | }; 29 | const expected = { 30 | statusCode: 400, 31 | body: '{"message": "asset should be of type string"}', 32 | }; 33 | formatEvent(event).catch((e) => { 34 | const { statusCode, body } = expected; 35 | expect(e.statusCode).toEqual(statusCode); 36 | expect(e.body).toEqual(body); 37 | done(); 38 | }); 39 | }); 40 | 41 | test('should resolve a formatted event', (done) => { 42 | const event = { 43 | asset: 'asset', 44 | destination: 'destination', 45 | bucket: 'bucket', 46 | resize_options: { 47 | crop: 'crop', 48 | resize: 'resize', 49 | filter: 'filter', 50 | }, 51 | mime_type: 'mime_type', 52 | storage_class: 'storage_class', 53 | }; 54 | const expected = Object.assign({}, event); 55 | formatEvent(event).then((actual) => { 56 | expect(actual).toEqual(expected); 57 | done(); 58 | }); 59 | }); 60 | 61 | test('should delete invalid resize_options', (done) => { 62 | const event = { 63 | asset: 'asset', 64 | destination: 'destination', 65 | bucket: 'bucket', 66 | resize_options: { 67 | removeMe: 'asdf', 68 | crop: 'crop', 69 | resize: 'resize', 70 | removeMeToo: 'xyz', 71 | filter: 'filter', 72 | }, 73 | mime_type: 'mime_type', 74 | storage_class: 'storage_class', 75 | }; 76 | 77 | // todo: Object.assign or spread 78 | const expected = { 79 | asset: 'asset', 80 | destination: 'destination', 81 | bucket: 'bucket', 82 | resize_options: { 83 | removeMe: 'asdf', 84 | crop: 'crop', 85 | resize: 'resize', 86 | removeMeToo: 'xyz', 87 | filter: 'filter', 88 | }, 89 | mime_type: 'mime_type', 90 | storage_class: 'storage_class', 91 | }; 92 | delete expected.resize_options.removeMe; 93 | delete expected.resize_options.removeMeToo; 94 | 95 | formatEvent(event).then((actual) => { 96 | expect(actual).toEqual(expected); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /functions/uploadImage/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const AWS = require('aws-sdk'); 4 | 5 | const s3 = new AWS.S3(); 6 | 7 | const v4 = require('uuid').v4; 8 | 9 | function generateUploadParams(content_type, extension, key){ 10 | return { 11 | Bucket: process.env.S3_BUCKET, 12 | Key: `${process.env.NODE_ENV}/${(key) ? key : v4()}${extension}`, 13 | ContentType: content_type, 14 | ACL: 'public-read' 15 | } 16 | } 17 | 18 | module.exports.handler = (event, context, callback) => { 19 | let query = (event.queryStringParameters) ? event.queryStringParameters : event; 20 | let extension = /^\.\w*$/.test(query.extension) ? query.extension : (typeof query.extension != 'undefined') ? `.${query.extension}` : ".undefined"; 21 | let key = (query.key) ? query.key : null; 22 | let content_type = query.content_type; 23 | if (!content_type) return callback(null, {statusCode:400, body: JSON.stringify({message:'please provide a content_type and extension'})}); 24 | s3.getSignedUrl('putObject', generateUploadParams(content_type, extension, key), function (error, url) { 25 | if(error) return callback(null, {statusCode:error.statusCode || 500, body:JSON.stringify({message:error.message})}); 26 | else callback(null, {statusCode:200, body:JSON.stringify({url:url})}); 27 | }); 28 | } 29 | 30 | module.exports.generateUploadParams = generateUploadParams; -------------------------------------------------------------------------------- /functions/uploadImage/index.spec.js: -------------------------------------------------------------------------------- 1 | const { 2 | generateUploadParams, 3 | } = require('./index.js'); 4 | 5 | 6 | describe('uploadImage', () => { 7 | describe('generateUploadParams', () => { 8 | // todo: if event is `{}` promise goes unresolved 9 | // todo: JSON stringify responses 10 | // todo: 'crop' twice in GM_KEYS 11 | 12 | test('should generate correct upload parameters', (done) => { 13 | const expected = { 14 | Bucket: process.env.S3_BUCKET, 15 | Key: `${process.env.NODE_ENV}/test.jpg`, 16 | ContentType: 'image/jpeg', 17 | ACL: 'public-read' 18 | }; 19 | const actual = generateUploadParams('image/jpeg', '.jpg', 'test'); 20 | expect(actual).toEqual(expected); 21 | done(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const getImage = require('./functions/getImage'); 2 | const resizeImage = require('./functions/resizeImage'); 3 | 4 | module.exports = { 5 | getImage, 6 | resizeImage, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-image-resizer", 3 | "version": "0.1.1", 4 | "description": "Serverless Image Resizer", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "./node_modules/.bin/eslint .", 8 | "test": "./node_modules/.bin/jest", 9 | "test:watch": "./node_modules/.bin/jest --watch", 10 | "test:coverage": "./node_modules/.bin/jest --coverage" 11 | }, 12 | "author": "Nick Gubbins && Marcus Molchany ", 13 | "license": "MIT", 14 | "repository" : { 15 | "type" : "git", 16 | "url" : "https://github.com/nicholasgubbins/Serverless-Image-Resizer" 17 | }, 18 | "dependencies": { 19 | "aws-sdk": "^2.22.0", 20 | "bluebird": "^3.3.4", 21 | "gm": "^1.21.1", 22 | "s3-upload-stream": "^1.0.7" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.16.0", 26 | "babel-plugin-transform-runtime": "^6.15.0", 27 | "babel-preset-es2015": "^6.9.0", 28 | "babel-preset-stage-0": "^6.5.0", 29 | "eslint": "^4.4.1", 30 | "eslint-config-airbnb": "^15.1.0", 31 | "eslint-plugin-import": "^2.7.0", 32 | "eslint-plugin-jsx-a11y": "^5.1.1", 33 | "eslint-plugin-react": "^7.1.0", 34 | "jest": "^20.0.4", 35 | "serverless-plugin-browserify": "^1.1.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: Image-Resizer 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs6.10 6 | 7 | memorySize: 1024 # optional, default is 1024 8 | timeout: 100 # optional, default is 6 9 | 10 | stage: dev 11 | region: eu-west-1 12 | 13 | role: BucketAccess 14 | 15 | environment: 16 | BUCKET: "sample-bucket" 17 | SLS_DEBUG: "*" 18 | RESIZE_LAMBDA: ${self:provider.stage}-resizeImage 19 | 20 | 21 | package: 22 | individually: true 23 | # exclude: 24 | # - bin/** 25 | # - functions/** 26 | 27 | plugins: 28 | - serverless-plugin-browserify 29 | 30 | functions: 31 | getImage: 32 | name: ${self:service}-${self:provider.stage}-getImage 33 | handler: functions/getImage/index.handler 34 | events: 35 | - http: GET {proxy+} 36 | package: 37 | include: 38 | - functions/getImage/** 39 | resizeImage: 40 | name: ${self:provider.environment.RESIZE_LAMBDA} 41 | handler: functions/resizeImage/index.handler 42 | package: 43 | include: 44 | - functions/resizeImage/** 45 | uploadImage: 46 | name: ${self:service}-${self:provider.stage}-uploadImage 47 | handler: functions/uploadImage/index.handler 48 | events: 49 | - http: POST /upload 50 | package: 51 | include: 52 | - functions/uploadImage/** 53 | 54 | 55 | 56 | # you can add CloudFormation resource templates here 57 | 58 | resources: 59 | Resources: 60 | BucketAccess: 61 | Type: AWS::IAM::Role 62 | Properties: 63 | RoleName: ${self:provider.environment.BUCKET}-S3-BUCKET-ACCESS-${self:service} 64 | AssumeRolePolicyDocument: 65 | Version: "2012-10-17" 66 | Statement: 67 | - Effect: Allow 68 | Principal: 69 | Service: 70 | - lambda.amazonaws.com 71 | Action: sts:AssumeRole 72 | Policies: 73 | - PolicyName: ${self:provider.environment.BUCKET}-access-bucket-${self:service} 74 | PolicyDocument: 75 | Version: "2012-10-17" 76 | Statement: 77 | - Effect: Allow 78 | Action: 79 | - "s3:*" 80 | Resource: 81 | - "Fn::Join": ["", ["arn:aws:s3:::", "${self:provider.environment.BUCKET}/*"]] 82 | - Effect: Allow 83 | Action: 84 | - "lambda:InvokeFunction" 85 | Resource: 86 | - "Fn::Join": ["", ["arn:aws:lambda:", {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"}, ":function:${self:provider.environment.RESIZE_LAMBDA}"]] 87 | --------------------------------------------------------------------------------