├── .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 |
--------------------------------------------------------------------------------