├── bin ├── .npmignore ├── linux │ ├── cjpeg │ ├── gifsicle │ ├── pngquant │ └── jpegoptim ├── darwin │ ├── cjpeg │ ├── gifsicle │ ├── jpegoptim │ └── pngquant └── configtest ├── .mdlrc ├── .eslintignore ├── .coveralls.yml ├── .npmignore ├── test ├── fixture │ ├── fixture.gif │ ├── fixture.jpeg │ ├── fixture.jpg │ ├── fixture.png │ └── events │ │ ├── s3_test_event.json │ │ ├── s3_put_file.json │ │ ├── s3_put_directory.json │ │ ├── sns_test_event.json │ │ ├── sns_s3_put_directory.json │ │ └── sns_s3_put_file.json ├── reduce-png.js ├── e2e-gif.js ├── reduce-jpeg.js ├── reduce-gif.js ├── reduce-jpeg-jpegoptim.js ├── resize-png.js ├── resize-gif.js ├── backup.js ├── reduce.js ├── optimizer.js ├── resize-jpeg.js ├── resize.js ├── event-parser.js ├── e2e-png.js ├── e2e-jpeg-jpegoptim.js ├── e2e-jpeg.js ├── image-data.js └── s3-file-system.js ├── .gitignore ├── .editorconfig ├── policies └── s3-bucket-full-access.json ├── .travis.yml ├── .codeclimate.yml ├── lib ├── optimizer │ ├── Gifsicle.js │ ├── Pngquant.js │ ├── Mozjpeg.js │ ├── JpegOptim.js │ └── Optimizer.js ├── EventParser.js ├── ReadableImageStream.js ├── Config.js ├── WritableImageStream.js ├── ImageArchiver.js ├── StreamChain.js ├── S3FileSystem.js ├── ImageReducer.js ├── ImageResizer.js ├── ImageData.js └── ImageProcessor.js ├── scripts ├── update-command.js ├── deploy-command.js ├── common.js └── layers.json ├── Makefile ├── config.json.sample ├── LICENSE ├── doc ├── LAYERS.md └── DIRECTORY.md ├── layers ├── build-and-publish.sh └── Dockerfile ├── index.js ├── package.json ├── .eslintrc └── README.md /bin/.npmignore: -------------------------------------------------------------------------------- 1 | darwin/ 2 | configtest -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | tables: false 2 | code_blocks: false 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | node_modules/**/*.js 3 | coverage 4 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | repo_token: AE6hocd30hnW3LKREYSybzke4vDVBSomH 3 | -------------------------------------------------------------------------------- /bin/linux/cjpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/linux/cjpeg -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output/ 3 | coverage/ 4 | test/ 5 | claudia.json 6 | config.json.sample -------------------------------------------------------------------------------- /bin/darwin/cjpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/darwin/cjpeg -------------------------------------------------------------------------------- /bin/linux/gifsicle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/linux/gifsicle -------------------------------------------------------------------------------- /bin/linux/pngquant: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/linux/pngquant -------------------------------------------------------------------------------- /bin/darwin/gifsicle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/darwin/gifsicle -------------------------------------------------------------------------------- /bin/darwin/jpegoptim: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/darwin/jpegoptim -------------------------------------------------------------------------------- /bin/darwin/pngquant: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/darwin/pngquant -------------------------------------------------------------------------------- /bin/linux/jpegoptim: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/bin/linux/jpegoptim -------------------------------------------------------------------------------- /test/fixture/fixture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/test/fixture/fixture.gif -------------------------------------------------------------------------------- /test/fixture/fixture.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/test/fixture/fixture.jpeg -------------------------------------------------------------------------------- /test/fixture/fixture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/test/fixture/fixture.jpg -------------------------------------------------------------------------------- /test/fixture/fixture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysugimoto/aws-lambda-image/HEAD/test/fixture/fixture.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | aws-lambda-image.zip 3 | aws-lambda-image-layer.zip 4 | build/ 5 | config.json 6 | npm-debug.log 7 | *.sw? 8 | .idea/ 9 | coverage 10 | .nyc_output 11 | claudia.json 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /policies/s3-bucket-full-access.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "s3:*" 8 | ], 9 | "Resource": [ 10 | "*" 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8.10' 4 | - '10.15' 5 | - '12.13' 6 | addons: 7 | apt: 8 | packages: 9 | - libjpeg62 10 | after_success: 11 | - './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' 12 | -------------------------------------------------------------------------------- /test/fixture/events/s3_test_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "Service": "Amazon S3", 3 | "Event": "s3:TestEvent", 4 | "Time": "2017-02-24T09:17:22.334Z", 5 | "Bucket": "mybucket", 6 | "RequestId": "08BA42D492A002B0", 7 | "HostId": "ilNmIfYcYf1MNWeoIvXUqRbkfl861Sr8xaPMu9L5cze9SPD1ThnlIxU1iTwYe8gEZicPzvQflmA=" 8 | } -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - javascript 8 | eslint: 9 | enabled: true 10 | fixme: 11 | enabled: true 12 | markdownlint: 13 | enabled: true 14 | ratings: 15 | paths: 16 | - "bin/configtest" 17 | - "**.js" 18 | - "**.md" 19 | exclude_paths: 20 | - test/ 21 | -------------------------------------------------------------------------------- /lib/optimizer/Gifsicle.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Optimizer = require("./Optimizer"); 4 | 5 | class Gifsicle extends Optimizer { 6 | /** 7 | * Gifsicle optimizer 8 | * 9 | * @constructor 10 | * @extends Optimizer 11 | * @param Array|undefined args 12 | */ 13 | constructor(args) { 14 | super(); 15 | 16 | this.command = this.findBin("gifsicle"); 17 | this.args = args || ["--optimize"]; 18 | } 19 | } 20 | 21 | module.exports = Gifsicle; 22 | -------------------------------------------------------------------------------- /scripts/update-command.js: -------------------------------------------------------------------------------- 1 | const { 2 | readPackageConfig, 3 | getDedicatedLayerArn, 4 | getRuntimeVersion 5 | } = require('./common'); 6 | 7 | const { runtime, profile, region } = readPackageConfig(); 8 | 9 | const claudiaCommand = [ 10 | "claudia", 11 | "update", 12 | `--profile ${profile}` 13 | ]; 14 | 15 | // if runtime is upper than nodejs10.x, need Layer 16 | if (getRuntimeVersion(runtime) >= 10) { 17 | claudiaCommand.push(`--layers ${getDedicatedLayerArn(region)}`); 18 | } 19 | 20 | process.stdout.write(claudiaCommand.join(" ")); 21 | -------------------------------------------------------------------------------- /lib/optimizer/Pngquant.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Optimizer = require("./Optimizer"); 4 | 5 | class Pngquant extends Optimizer { 6 | 7 | /** 8 | * pngquant optimizer 9 | * 10 | * @constructor 11 | * @extends Optimizer 12 | * @param Array|undefined args 13 | */ 14 | constructor(args) { 15 | super(); 16 | 17 | this.command = this.findBin("pngquant"); 18 | this.args = args || ["--speed=1", "256"]; 19 | 20 | if ( this.args.indexOf("-") === -1 ) { 21 | this.args.push("-"); 22 | } 23 | } 24 | } 25 | 26 | module.exports = Pngquant; 27 | -------------------------------------------------------------------------------- /test/reduce-png.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | const fsP = pify(fs); 9 | 10 | test("Reduce PNG", async t => { 11 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.png`); 12 | const reducer = new ImageReducer(); 13 | const image = new ImageData("fixture/fixture.png", "fixture", fixture); 14 | 15 | const reduced = await reducer.exec(image); 16 | t.true(reduced.data.length < fixture.length); 17 | }); 18 | -------------------------------------------------------------------------------- /test/e2e-gif.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | const fsP = pify(fs); 9 | 10 | test("Optimize GIF Test", async t => { 11 | const reducer = new ImageReducer(); 12 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.gif`); 13 | const image = new ImageData("fixture/fixture.gif", "fixture", fixture); 14 | 15 | const reduced = await reducer.exec(image); 16 | t.true(reduced.data.length < fixture.length); 17 | }); 18 | -------------------------------------------------------------------------------- /test/reduce-jpeg.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | const fsP = pify(fs); 9 | 10 | test("Reduce JPEG", async t => { 11 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 12 | const reducer = new ImageReducer({quality: 90}); 13 | const image = new ImageData("fixture/fixture.jpg", "fixture", fixture); 14 | 15 | const reduced = await reducer.exec(image); 16 | t.true(reduced.data.length > 0); 17 | t.true(reduced.data.length < fixture.length); 18 | }); 19 | -------------------------------------------------------------------------------- /test/reduce-gif.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | const fsP = pify(fs); 9 | 10 | test("Reduce GIF Test", async t => { 11 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.gif`); 12 | const reducer = new ImageReducer({quality: 90}); 13 | const image = new ImageData("fixture/fixture.gif", "fixture", fixture); 14 | 15 | const reduced = await reducer.exec(image); 16 | t.true(reduced.data.length > 0); 17 | t.true(reduced.data.length < fixture.length); 18 | }); 19 | -------------------------------------------------------------------------------- /lib/optimizer/Mozjpeg.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Optimizer = require("./Optimizer"); 4 | 5 | class Mozjpeg extends Optimizer { 6 | /** 7 | * MozJpeg(cjpeg) optimizer 8 | * 9 | * @constructor 10 | * @extends Optimizer 11 | * @param Number|undefined quality 12 | * @param Array|undefined args 13 | */ 14 | constructor(quality, args) { 15 | super(); 16 | 17 | this.command = this.findBin("cjpeg"); 18 | this.args = args || ["-optimize", "-progressive"]; 19 | 20 | // determine quality if supplied 21 | if ( quality ) { 22 | this.args.unshift(quality); 23 | this.args.unshift("-quality"); 24 | } 25 | } 26 | } 27 | 28 | module.exports = Mozjpeg; 29 | -------------------------------------------------------------------------------- /lib/EventParser.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function parseEvent(event) { 4 | 5 | const eventRecord = event && event.Records && event.Records[0]; 6 | if ( eventRecord ) { 7 | if ( eventRecord.eventSource === "aws:s3" && eventRecord.s3 ) { 8 | console.log( "Parsing S3 event..." ); 9 | 10 | return eventRecord.s3; 11 | } else if ( eventRecord.EventSource === "aws:sns" && eventRecord.Sns ) { 12 | console.log( "Parsing SNS message..." ); 13 | 14 | const snsEvent = JSON.parse( eventRecord.Sns.Message ); 15 | const snsEventRecord = snsEvent.Records && snsEvent.Records[0]; 16 | if ( snsEventRecord && snsEventRecord.eventSource === "aws:s3" && snsEventRecord.s3 ) { 17 | return snsEventRecord.s3; 18 | } 19 | } 20 | } 21 | 22 | return false; 23 | }; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean configtest 2 | 3 | lambda: 4 | npm install . 5 | @echo "Factory package files..." 6 | @if [ ! -d build ] ;then mkdir build; fi 7 | @cp index.js build/index.js 8 | @cp config.json build/config.json 9 | @if [ -d build/node_modules ] ;then rm -rf build/node_modules; fi 10 | @cp -R node_modules build/node_modules 11 | @cp -R lib build/ 12 | @cp -R bin build/ 13 | @rm -rf build/bin/darwin 14 | @echo "Create package archive..." 15 | @cd build && zip -rq aws-lambda-image.zip . 16 | @mv build/aws-lambda-image.zip ./ 17 | 18 | uploadlambda: lambda 19 | @if [ -z "${LAMBDA_FUNCTION_NAME}" ]; then (echo "Please export LAMBDA_FUNCTION_NAME" && exit 1); fi 20 | aws lambda update-function-code --function-name ${LAMBDA_FUNCTION_NAME} --zip-file fileb://aws-lambda-image.zip 21 | 22 | configtest: 23 | @./bin/configtest 24 | 25 | clean: 26 | @echo "clean up package files" 27 | @if [ -f aws-lambda-image.zip ]; then rm aws-lambda-image.zip; fi 28 | @rm -rf build/* 29 | -------------------------------------------------------------------------------- /lib/optimizer/JpegOptim.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Optimizer = require("./Optimizer"); 4 | 5 | class Jpegoptim extends Optimizer { 6 | /** 7 | * JpegOption optimizer 8 | * 9 | * @constructor 10 | * @extends Optimizer 11 | * @param Number|undefined quality 12 | * @param Array|undefined args 13 | */ 14 | constructor(quality, args) { 15 | super(); 16 | 17 | this.command = this.findBin("jpegoptim"); 18 | this.args = args || ["-s", "--all-progressive"]; 19 | 20 | // determine quality if supplied 21 | if ( quality ) { 22 | this.args.unshift(quality); 23 | this.args.unshift("-m"); 24 | } 25 | 26 | if ( this.args.indexOf("--stdin") === -1 ) { 27 | this.args.unshift("--stdin"); 28 | } 29 | if ( this.args.indexOf("--stdout") === -1 ) { 30 | this.args.push("--stdout"); 31 | } 32 | } 33 | } 34 | 35 | module.exports = Jpegoptim; 36 | -------------------------------------------------------------------------------- /lib/ReadableImageStream.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const stream = require("stream"); 4 | 5 | class ReadableImageStream extends stream.Readable { 6 | 7 | /** 8 | * Readable image binary stream implementation 9 | * 10 | * @constructor 11 | * @extends stream.Readable 12 | * @param Buffer data 13 | */ 14 | constructor(data) { 15 | super(); 16 | 17 | this._data = data; 18 | this._index = 0; 19 | this._size = data.length; 20 | } 21 | 22 | /** 23 | * stream.Readable interface implement 24 | * 25 | * @protected 26 | * @param Number size 27 | */ 28 | _read(size) { 29 | if ( this._index < this._size ) { 30 | this.push( 31 | this._data.slice(this._index, (this._index + size)) 32 | ); 33 | 34 | this._index += size; 35 | } 36 | 37 | if ( this._index >= this._size ) { 38 | this.push(null); 39 | } 40 | } 41 | } 42 | 43 | module.exports = ReadableImageStream; 44 | -------------------------------------------------------------------------------- /lib/Config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | class Config { 4 | /** 5 | * Task configuration getter interface 6 | * 7 | * @constructor 8 | * @param Object setting 9 | */ 10 | constructor(setting) { 11 | this.stack = setting || {}; 12 | } 13 | 14 | /** 15 | * Get the config value 16 | * 17 | * @public 18 | * @param String key 19 | * @param Mixed def 20 | * @return Mixed 21 | */ 22 | get(key, def) { 23 | return ( key in this.stack ) ? this.stack[key] : def; 24 | } 25 | 26 | /** 27 | * Set the config value 28 | * 29 | * @public 30 | * @param String key 31 | * @param mixed value 32 | * @return Mixed 33 | */ 34 | set(key, value) { 35 | this.stack[key] = value; 36 | } 37 | 38 | /** 39 | * Check the config exists 40 | * 41 | * @public 42 | * @param String key 43 | * @return Boolean 44 | */ 45 | exists(key) { 46 | return ( key in this.stack ); 47 | } 48 | } 49 | 50 | module.exports = Config; 51 | -------------------------------------------------------------------------------- /scripts/deploy-command.js: -------------------------------------------------------------------------------- 1 | const { 2 | readPackageConfig, 3 | getDedicatedLayerArn, 4 | getRuntimeVersion 5 | } = require('./common'); 6 | 7 | const { 8 | region = "eu-west-1", 9 | memory = "1280", 10 | timeout = "5", 11 | runtime = "nodejs10.x", 12 | profile, 13 | name, 14 | role 15 | } = readPackageConfig(); 16 | 17 | const claudiaCommand = [ 18 | "claudia", 19 | "create", 20 | "--version dev", 21 | "--handler index.handler", 22 | "--no-optional-dependencies", 23 | "--policies policies/*.json", 24 | `--profile ${profile}`, 25 | `--region ${region}`, 26 | `--timeout ${timeout}`, 27 | `--memory ${memory}`, 28 | `--runtime ${runtime}` 29 | ]; 30 | if (role) { 31 | claudiaCommand.push(`--role ${role}`); 32 | } 33 | if (name) { 34 | claudiaCommand.push(`--name ${name}`); 35 | } 36 | // if runtime is upper than nodejs10.x, need Layer 37 | if (getRuntimeVersion(runtime) >= 10) { 38 | claudiaCommand.push(`--layers ${getDedicatedLayerArn(region)}`); 39 | } 40 | 41 | process.stdout.write(claudiaCommand.join(" ")); 42 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "bucket": "your-destination-bucket", 3 | "backup": { 4 | "directory": "./original" 5 | }, 6 | "reduce": { 7 | "directory": "./reduced", 8 | "prefix": "reduced-", 9 | "quality": 90, 10 | "acl": "public-read", 11 | "cacheControl": "public, max-age=31536000" 12 | }, 13 | "resizes": [ 14 | { 15 | "size": 300, 16 | "directory": "./resized/small", 17 | "prefix": "resized-", 18 | "cacheControl": null 19 | }, 20 | { 21 | "size": 450, 22 | "directory": "./resized/medium", 23 | "suffix": "_medium" 24 | }, 25 | { 26 | "size": "600x600^", 27 | "gravity": "Center", 28 | "crop": "600x600", 29 | "directory": "./resized/cropped-to-square" 30 | }, 31 | { 32 | "size": 600, 33 | "directory": "./resized/600-jpeg", 34 | "format": "jpg", 35 | "background": "white", 36 | "changeExtension": true 37 | }, 38 | { 39 | "size": 900, 40 | "directory": "./resized/large", 41 | "quality": 90 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /lib/WritableImageStream.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const stream = require("stream"); 4 | 5 | class WritableImageStream extends stream.Writable { 6 | 7 | /** 8 | * Writable image binary stream implementation 9 | * 10 | * @constructor 11 | * @extends stream.Writable 12 | */ 13 | constructor() { 14 | super(); 15 | 16 | this._buffers = []; 17 | this._bufferLength = 0; 18 | } 19 | 20 | /** 21 | * stream.Writable interface implement 22 | * 23 | * @protected 24 | * @param Buffer chunk 25 | * @param String encoding 26 | * @param Function callback 27 | */ 28 | _write(chunk, encoding, callback) { 29 | this._buffers.push(chunk); 30 | this._bufferLength += chunk.length; 31 | 32 | callback(); 33 | } 34 | 35 | /** 36 | * Get all written buffers after finished 37 | * 38 | * @public 39 | * @return Buffer 40 | */ 41 | getBufferStack() { 42 | return Buffer.concat(this._buffers, this._bufferLength); 43 | } 44 | } 45 | 46 | module.exports = WritableImageStream; 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ysugimoto 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 | 23 | -------------------------------------------------------------------------------- /doc/LAYERS.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Layer Configuration 2 | 3 | Since node10.x runtime, no longer `ImageMagick` is bundled on runtime. Therefore we need use Lambda Layer to enable it. 4 | 5 | We provides dedicated Lambda Layer which is bundled binaries we need to run correctly, 6 | and enables it automatically with corresponds your region to deploy. 7 | 8 | Then you need to define region to deploy function in `package.json`: 9 | 10 | ```json 11 | # package.json 12 | { 13 | ... 14 | "config": { 15 | "region": "" 16 | }, 17 | ... 18 | } 19 | ``` 20 | 21 | When you run `npm run deploy` or other deploy command, pre-deployed layer will be applied automatically with your regions. 22 | 23 | ## Support regions 24 | 25 | Now we're supporting following regions: 26 | 27 | - ap-northeast-1 28 | - ap-northeast-2 29 | - ap-south-1 30 | - ap-southeast-1 31 | - ap-southeast-2 32 | - ca-central-1 33 | - eu-north-1 34 | - eu-central-1 35 | - eu-west-1 36 | - eu-west-2 37 | - eu-west-3 38 | - sa-east-1 39 | - us-east-1 40 | - us-east-2 41 | - us-west-1 42 | - us-west-2 43 | 44 | If you want to use in other regions, please contact us by creating an issue. 45 | -------------------------------------------------------------------------------- /test/fixture/events/s3_put_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventVersion": "2.0", 5 | "eventTime": "1970-01-01T00:00:00.000Z", 6 | "requestParameters": { 7 | "sourceIPAddress": "127.0.0.1" 8 | }, 9 | "s3": { 10 | "configurationId": "testConfigRule", 11 | "object": { 12 | "eTag": "0123456789abcdef0123456789abcdef", 13 | "sequencer": "0A1B2C3D4E5F678901", 14 | "key": "HappyFace.jpg", 15 | "size": 1024 16 | }, 17 | "bucket": { 18 | "arn": "arn:aws:s3:::mybucket", 19 | "name": "sourcebucket", 20 | "ownerIdentity": { 21 | "principalId": "EXAMPLE" 22 | } 23 | }, 24 | "s3SchemaVersion": "1.0" 25 | }, 26 | "responseElements": { 27 | "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH", 28 | "x-amz-request-id": "EXAMPLE123456789" 29 | }, 30 | "awsRegion": "us-east-1", 31 | "eventName": "ObjectCreated:Put", 32 | "userIdentity": { 33 | "principalId": "EXAMPLE" 34 | }, 35 | "eventSource": "aws:s3" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /test/fixture/events/s3_put_directory.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventVersion": "2.0", 5 | "eventSource": "aws:s3", 6 | "awsRegion": "eu-west-1", 7 | "eventTime": "2017-02-24T09:44:40.037Z", 8 | "eventName": "ObjectCreated:Put", 9 | "userIdentity": { 10 | "principalId": "A18ZVM2MRUILBS" 11 | }, 12 | "requestParameters": { 13 | "sourceIPAddress": "127.0.0.1" 14 | }, 15 | "responseElements": { 16 | "x-amz-request-id": "4D1DF5BEC568003C", 17 | "x-amz-id-2": "rSVQ77tB945vVVz4rtwe7xvmXXUGhQe4mK2PXYaR+R58l3A+F9mJUhXtLAF363os" 18 | }, 19 | "s3": { 20 | "s3SchemaVersion": "1.0", 21 | "configurationId": "OTU2MjNhZGYtNDNjNC00OGZlLWJlNWMtNmY5MzgyZGVkYTkw", 22 | "bucket": { 23 | "name": "mybucket", 24 | "ownerIdentity": { 25 | "principalId": "A18ZVM2MRUILBS" 26 | }, 27 | "arn": "arn:aws:s3:::mybucket" 28 | }, 29 | "object": { 30 | "key": "some/", 31 | "size": 0, 32 | "eTag": "d41d8cd98f00b204e9800998ecf8427e", 33 | "sequencer": "0058B0008800F2DDA6" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /test/reduce-jpeg-jpegoptim.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | const fsP = pify(fs); 9 | 10 | test("Reduce JPEG with JpegOptim", async t => { 11 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 12 | const reducer = new ImageReducer({jpegOptimizer: "jpegoptim"}); 13 | const image = new ImageData("fixture/fixture.jpg", "fixture", fixture); 14 | 15 | const reduced = await reducer.exec(image); 16 | t.true(reduced.data.length > 0); 17 | t.true(reduced.data.length < fixture.length); 18 | }); 19 | 20 | test("Reduce JPEG with JpegOptim and quality", async t => { 21 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 22 | const reducer = new ImageReducer({quality: 90, jpegOptimizer: "jpegoptim"}); 23 | const image = new ImageData("fixture/fixture.jpg", "fixture", fixture); 24 | 25 | const reduced = await reducer.exec(image); 26 | t.true(reduced.data.length > 0); 27 | t.true(reduced.data.length < fixture.length); 28 | }); 29 | -------------------------------------------------------------------------------- /lib/optimizer/Optimizer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const spawn = require("child_process").spawn; 4 | const path = require("path"); 5 | const fs = require("fs"); 6 | 7 | class Optimizer { 8 | 9 | /** 10 | * Optimizer Base Constructor 11 | * 12 | * @constructor 13 | */ 14 | constructor() { 15 | } 16 | 17 | /** 18 | * Create spawn process 19 | * 20 | * @public 21 | * @return ChildProcess 22 | */ 23 | spawnProcess() { 24 | const process = spawn(this.command, this.args); 25 | process.on('err', (err) => { 26 | console.log(`Child process ended with error code ${err}`); 27 | }); 28 | return process; 29 | } 30 | 31 | /** 32 | * Find executable binary path 33 | * 34 | * @protected 35 | * @param String binName 36 | * @return String 37 | * @throws Error 38 | */ 39 | findBin(binName) { 40 | const binPath = path.resolve(__dirname, "../../bin/", process.platform, binName); 41 | 42 | if ( ! fs.existsSync(binPath) ) { 43 | throw new Error("Undefined binary: " + binPath); 44 | } 45 | return binPath; 46 | } 47 | } 48 | 49 | module.exports= Optimizer; 50 | -------------------------------------------------------------------------------- /test/resize-png.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageResizer = require("../lib/ImageResizer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const bindAll = require('bind-all'); 6 | const gm = require("gm").subClass({ imageMagick: true }); 7 | const test = require("ava"); 8 | const pify = require("pify"); 9 | const fs = require("fs"); 10 | 11 | const gmP = (...args) => pify(bindAll(gm(...args))); 12 | 13 | let image; 14 | 15 | test.before(async t => { 16 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.png`); 17 | image = new ImageData("fixture/fixture.png", "fixture", fixture); 18 | }); 19 | 20 | test("Resize PNG", async t => { 21 | const resizer = new ImageResizer({size: 200}); 22 | const resized = await resizer.exec(image); 23 | const gmImage = gmP(resized.data); 24 | const out = await gmImage.size(); 25 | 26 | t.is(out.width, 200); 27 | }); 28 | 29 | test("Convert PNG to JPEG", async t => { 30 | const resizer = new ImageResizer({size: 200, format: "jpg"}); 31 | const resized = await resizer.exec(image); 32 | const gmImage = gmP(resized.data); 33 | const out = await gmImage.format(); 34 | 35 | t.is(out, "JPEG"); 36 | }); 37 | -------------------------------------------------------------------------------- /test/resize-gif.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageResizer = require("../lib/ImageResizer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const bindAll = require('bind-all'); 6 | const gm = require("gm").subClass({ imageMagick: true }); 7 | const test = require("ava"); 8 | const pify = require("pify"); 9 | const fs = require("fs"); 10 | 11 | const gmP = (...args) => pify(bindAll(gm(...args))); 12 | 13 | let image; 14 | 15 | test.before(async t => { 16 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.gif`); 17 | image = new ImageData("fixture/fixture.gif", "fixture", fixture); 18 | }); 19 | 20 | test("Resize GIF with gifsicle", async t => { 21 | const resizer = new ImageResizer({size: 200}); 22 | const resized = await resizer.exec(image); 23 | const gmImage = gmP(resized.data); 24 | const out = await gmImage.size(); 25 | 26 | t.is(out.width, 200); 27 | }); 28 | 29 | test("Convert GIF to JPEG", async t => { 30 | const resizer = new ImageResizer({size: 200, format: "jpg"}); 31 | const resized = await resizer.exec(image); 32 | const gmImage = gmP(resized.data); 33 | const out = await gmImage.format(); 34 | 35 | t.is(out, "JPEG"); 36 | }); 37 | -------------------------------------------------------------------------------- /test/backup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageArchiver = require("../lib/ImageArchiver"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | 9 | let image; 10 | 11 | test.before(async t => { 12 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.jpg`); 13 | image = new ImageData("fixture/fixture.jpg", "fixture", fixture, {}, "private"); 14 | }); 15 | 16 | test("If ACL parameter is passed while reducing use original one", async t => { 17 | const archiver = new ImageArchiver({}); 18 | const archived = await archiver.exec(image); 19 | 20 | t.is(archived.acl, 'private'); 21 | }); 22 | 23 | test("Ensuring that ACL parameter is passed while reducing", async t => { 24 | const archiver = new ImageArchiver({acl: 'public-read'}); 25 | const archived = await archiver.exec(image); 26 | 27 | t.is(archived.acl, 'public-read'); 28 | }); 29 | 30 | test("Backup adds prefix and suffix to filename", async t => { 31 | const archiver = new ImageArchiver({prefix: "a_", suffix: "_b"}); 32 | const archived = await archiver.exec(image); 33 | 34 | t.is(archived.fileName, "fixture/a_fixture_b.jpg"); 35 | }); 36 | -------------------------------------------------------------------------------- /scripts/common.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const readPackageConfig = () => { 5 | const { config } = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'))); 6 | return config; 7 | }; 8 | 9 | const getDedicatedLayerArn = region => { 10 | const layerSettingFile = path.join(__dirname, './layers.json'); 11 | if (!fs.existsSync(layerSettingFile)) { 12 | throw new Error("Layer settings file is not found. Please contact to us: https://github.com/ysugimoto/aws-lambda-image/issues/new"); 13 | } 14 | const layerSetting = JSON.parse(fs.readFileSync(layerSettingFile)); 15 | if (!(region in layerSetting)) { 16 | throw new Error( 17 | `Layer is not found on your region: ${region}. To be enable it, please contact to us with an issue: https://github.com/ysugimoto/aws-lambda-image/issues/new` 18 | ); 19 | } 20 | return layerSetting[region]; 21 | }; 22 | 23 | const getRuntimeVersion = runtime => { 24 | if (!runtime.indexOf('nodejs') === -1) { 25 | throw new Error('Invalid runtime version'); 26 | } 27 | const version = runtime.replace(/nodejs([0-9]+)\..*$/, '$1'); 28 | return parseInt(version, 10); 29 | }; 30 | 31 | module.exports = { 32 | readPackageConfig, 33 | getDedicatedLayerArn, 34 | getRuntimeVersion 35 | }; 36 | -------------------------------------------------------------------------------- /lib/ImageArchiver.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageData = require("./ImageData"); 4 | 5 | class ImageArchiver { 6 | 7 | /** 8 | * Image Archiver 9 | * Accept file types 10 | * 11 | * @constructor 12 | * @param Object option 13 | */ 14 | constructor(option) { 15 | this.option = option || {}; 16 | } 17 | 18 | /** 19 | * Execute process 20 | * 21 | * @public 22 | * @param ImageData image 23 | * @return Promise 24 | */ 25 | exec(image) { 26 | const option = this.option; 27 | 28 | return new Promise((resolve, reject) => { 29 | console.log("Backing up to: " + (option.directory || "in-place")); 30 | 31 | resolve( 32 | new ImageData( 33 | image.combineWithDirectory({ 34 | directory: option.directory, 35 | template: option.template, 36 | prefix: option.prefix, 37 | suffix: option.suffix, 38 | keepExtension: option.keepExtension 39 | }), 40 | option.bucket || image.bucketName, 41 | image.data, 42 | image.headers, 43 | option.acl || image.acl 44 | ) 45 | ); 46 | }); 47 | } 48 | } 49 | 50 | module.exports = ImageArchiver; 51 | -------------------------------------------------------------------------------- /scripts/layers.json: -------------------------------------------------------------------------------- 1 | { 2 | "ap-northeast-1": "arn:aws:lambda:ap-northeast-1:251217462751:layer:aws-lambda-image-layer:8", 3 | "ap-northeast-2": "arn:aws:lambda:ap-northeast-2:251217462751:layer:aws-lambda-image-layer:2", 4 | "ap-south-1": "arn:aws:lambda:ap-south-1:251217462751:layer:aws-lambda-image-layer:2", 5 | "ap-southeast-1": "arn:aws:lambda:ap-southeast-1:251217462751:layer:aws-lambda-image-layer:2", 6 | "ap-southeast-2": "arn:aws:lambda:ap-southeast-2:251217462751:layer:aws-lambda-image-layer:1", 7 | "ca-central-1": "arn:aws:lambda:ca-central-1:251217462751:layer:aws-lambda-image-layer:1", 8 | "eu-north-1": "arn:aws:lambda:eu-north-1:251217462751:layer:aws-lambda-image-layer:1", 9 | "eu-central-1": "arn:aws:lambda:eu-central-1:251217462751:layer:aws-lambda-image-layer:1", 10 | "eu-west-1": "arn:aws:lambda:eu-west-1:251217462751:layer:aws-lambda-image-layer:1", 11 | "eu-west-2": "arn:aws:lambda:eu-west-2:251217462751:layer:aws-lambda-image-layer:1", 12 | "eu-west-3": "arn:aws:lambda:eu-west-3:251217462751:layer:aws-lambda-image-layer:1", 13 | "sa-east-1": "arn:aws:lambda:sa-east-1:251217462751:layer:aws-lambda-image-layer:1", 14 | "us-east-1": "arn:aws:lambda:us-east-1:251217462751:layer:aws-lambda-image-layer:1", 15 | "us-east-2": "arn:aws:lambda:us-east-2:251217462751:layer:aws-lambda-image-layer:1", 16 | "us-west-1": "arn:aws:lambda:us-west-1:251217462751:layer:aws-lambda-image-layer:1", 17 | "us-west-2": "arn:aws:lambda:us-west-2:251217462751:layer:aws-lambda-image-layer:1" 18 | } 19 | -------------------------------------------------------------------------------- /test/reduce.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageReducer = require("../lib/ImageReducer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const pify = require("pify"); 7 | const fs = require("fs"); 8 | 9 | let image; 10 | 11 | test.before(async t => { 12 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.jpeg`); 13 | image = new ImageData("fixture/fixture.jpeg", "fixture", fixture, {}, "private"); 14 | }); 15 | 16 | test("If ACL parameter is passed while reducing use original one", async t => { 17 | const reducer = new ImageReducer({quality: 90}); 18 | const reduced = await reducer.exec(image); 19 | 20 | t.is(reduced.acl, 'private'); 21 | }); 22 | 23 | test("Ensuring that ACL parameter is passed while reducing", async t => { 24 | const reducer = new ImageReducer({quality: 90, acl: 'public-read'}); 25 | const reduced = await reducer.exec(image); 26 | 27 | t.is(reduced.acl, 'public-read'); 28 | }); 29 | 30 | test("Reduce adds prefix and suffix to filename", async t => { 31 | const reducer = new ImageReducer({prefix: "a_", suffix: "_b"}); 32 | const reduced = await reducer.exec(image); 33 | 34 | t.is(reduced.fileName, "fixture/a_fixture_b.jpg"); 35 | }); 36 | 37 | test("Reduce keeps original extension", async t => { 38 | const reducer = new ImageReducer({keepExtension: true}); 39 | const reduced = await reducer.exec(image); 40 | 41 | t.is(reduced.fileName, "fixture/fixture.jpeg"); 42 | }); 43 | -------------------------------------------------------------------------------- /test/fixture/events/sns_test_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "EventSource": "aws:sns", 5 | "EventVersion": "1.0", 6 | "EventSubscriptionArn": "arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer:eb4420e4-e23b-445c-82cc-c17c3aad8b83", 7 | "Sns": { 8 | "Type": "Notification", 9 | "MessageId": "f1a5d78d-9344-518f-bfb2-86a46f2641cb", 10 | "TopicArn": "arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer", 11 | "Subject": "Amazon S3 Notification", 12 | "Message": "{\"Service\":\"Amazon S3\",\"Event\":\"s3:TestEvent\",\"Time\":\"2017-02-24T09:17:22.334Z\",\"Bucket\":\"mybucket\",\"RequestId\":\"08BA42D492A002B0\",\"HostId\":\"ilNmIfYcYf1MNWeoIvXUqRbkfl861Sr8xaPMu9L5cze9SPD1ThnlIxU1iTwYe8gEZicPzvQflmA=\"}", 13 | "Timestamp": "2017-02-24T09:17:22.586Z", 14 | "SignatureVersion": "1", 15 | "Signature": "h7BUG2zHmfEreSDeMoym6XRqvT0FgBBtRlcwXdLQxWMCo1UUtnMiMn/7yonQTBvym1unjNRiemyXQoD2s7ORjPyCrgQm7UzT3WHMeEEY+9d2E/fn6N3ytSBAOjXqwjsdxfbG+yC17td5AkNeeNcD2SLpV8q4W1SWIXC9cnIEZZad686LqcPdgzXyXToFHdyh+Q1esFMjc1yYJp4GaBjUiPaSgvpICVg0G/I5fVi1P0RHux3MH8+W14n33u2wysbH5awz+WanxJJN8cNMlHIFnZxqnbil/h3sfxVOmDNLNBK2I1rjLwQ9mlwuSolw3wZnFym3ARLNEIhDKhY9w+iyBg==", 16 | "SigningCertUrl": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a046b3aafc7f4149a.pem", 17 | "UnsubscribeUrl": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer:eb4420e4-e23b-445c-82cc-c17c3aad8b83", 18 | "MessageAttributes": {} 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /test/optimizer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Pngquant = require("../lib/optimizer/Pngquant"); 4 | const Jpegoptim = require("../lib/optimizer/JpegOptim"); 5 | const Mozjpeg = require("../lib/optimizer/Mozjpeg"); 6 | const Gifsicle = require("../lib/optimizer/Gifsicle"); 7 | const test = require("ava"); 8 | 9 | test("Pngquant accepts override arguments", async t => { 10 | const pngquant = new Pngquant(["--lorem", "--ipsum"]); 11 | 12 | t.is(pngquant.args[0], "--lorem"); 13 | t.is(pngquant.args[1], "--ipsum"); 14 | // Override, but stdout argument must be exists 15 | t.is(pngquant.args[2], "-"); 16 | }); 17 | 18 | test("Jpegoptim accepts override arguments", async t => { 19 | const jpegoptim = new Jpegoptim(90, ["--lorem", "--ipsum"]); 20 | 21 | // Override, but stdin argument must be exists 22 | t.is(jpegoptim.args[0], "--stdin"); 23 | t.is(jpegoptim.args[1], "-m"); 24 | t.is(jpegoptim.args[2], 90); 25 | t.is(jpegoptim.args[3], "--lorem"); 26 | t.is(jpegoptim.args[4], "--ipsum"); 27 | t.is(jpegoptim.args[5], "--stdout"); 28 | }); 29 | 30 | test("Mozjpeg accepts override arguments", async t => { 31 | const mozjpeg = new Mozjpeg(90, ["--lorem", "--ipsum"]); 32 | 33 | // Override, but stdin argument must be exists 34 | t.is(mozjpeg.args[0], "-quality"); 35 | t.is(mozjpeg.args[1], 90); 36 | t.is(mozjpeg.args[2], "--lorem"); 37 | t.is(mozjpeg.args[3], "--ipsum"); 38 | }); 39 | 40 | test("Gifsicle accepts override arguments", async t => { 41 | const gifsicle = new Gifsicle(["--lorem", "--ipsum"]); 42 | 43 | // Override, but stdin argument must be exists 44 | t.is(gifsicle.args[0], "--lorem"); 45 | t.is(gifsicle.args[1], "--ipsum"); 46 | }); 47 | -------------------------------------------------------------------------------- /layers/build-and-publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -t aws-lambda-image-layer . 4 | docker run --rm aws-lambda-image-layer cat /tmp/aws-lambda-image-layer.zip > ./aws-lambda-image-layer.zip 5 | 6 | REGIONS=' 7 | ap-northeast-1 8 | ap-northeast-2 9 | ap-south-1 10 | ap-southeast-1 11 | ap-southeast-2 12 | ca-central-1 13 | eu-north-1 14 | eu-central-1 15 | eu-west-1 16 | eu-west-2 17 | eu-west-3 18 | sa-east-1 19 | us-east-1 20 | us-east-2 21 | us-west-1 22 | us-west-2 23 | ' 24 | 25 | LAYER_JSON=() 26 | for region in $REGIONS; do 27 | echo "deploying layer image to ${region}" 28 | version=$(aws lambda publish-layer-version \ 29 | --region $region \ 30 | --layer-name aws-lambda-image-layer \ 31 | --zip-file fileb://aws-lambda-image-layer.zip \ 32 | --description "bundled binaries layer for aws-lambda-image" \ 33 | --query Version \ 34 | --output text) 35 | [ $? -eq 0 ] || exit 1 36 | echo "version is ${version}" 37 | aws lambda add-layer-version-permission \ 38 | --region $region \ 39 | --layer-name aws-lambda-image-layer \ 40 | --statement-id aws-lambda-image-layer-sid \ 41 | --action lambda:GetLayerVersion \ 42 | --principal '*' \ 43 | --version-number ${version} 44 | [ $? -eq 0 ] || exit 1 45 | LAYER_JSON+=(" \"${region}\": \"arn:aws:lambda:${region}:251217462751:layer:aws-lambda-image-layer:${version}\"") 46 | done 47 | 48 | OUT="{" 49 | for L in "${LAYER_JSON[@]}"; do 50 | if [ "${L}" = "${LAYER_JSON[${#LAYER_JSON[*]}-1]}" ]; then 51 | OUT="${OUT}\n${L}" 52 | else 53 | OUT="${OUT}\n${L}," 54 | fi 55 | done 56 | OUT="${OUT}\n}" 57 | echo -e "$OUT" > ../scripts/layers.json 58 | -------------------------------------------------------------------------------- /test/resize-jpeg.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageResizer = require("../lib/ImageResizer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const bindAll = require('bind-all'); 6 | const gm = require("gm").subClass({ imageMagick: true }); 7 | const test = require("ava"); 8 | const pify = require("pify"); 9 | const fs = require("fs"); 10 | 11 | const gmP = (...args) => pify(bindAll(gm(...args))); 12 | 13 | let image; 14 | 15 | test.before(async t => { 16 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.jpg`); 17 | image = new ImageData("fixture/fixture.jpg", "fixture", fixture); 18 | }); 19 | 20 | test("Resize JPEG with cjpeg", async t => { 21 | const resizer = new ImageResizer({size: 200}); 22 | const resized = await resizer.exec(image); 23 | const gmImage = gmP(resized.data); 24 | const out = await gmImage.size(); 25 | 26 | t.is(out.width, 200); 27 | }); 28 | 29 | test("Resize JPEG with jpegoptim", async t => { 30 | const resizer = new ImageResizer({size: 200, jpegOptimizer: "jpegoptim"}); 31 | const resized = await resizer.exec(image); 32 | const gmImage = gmP(resized.data); 33 | const out = await gmImage.size(); 34 | 35 | t.is(out.width, 200); 36 | }); 37 | 38 | test("Convert JPEG to PNG", async t => { 39 | const resizer = new ImageResizer({size: 200, format: "png"}); 40 | const resized = await resizer.exec(image); 41 | const gmImage = gmP(resized.data); 42 | const out = await gmImage.format(); 43 | 44 | t.is(out, "PNG"); 45 | }); 46 | 47 | test("Convert JPEG to GIF", async t => { 48 | const resizer = new ImageResizer({size: 200, format: "gif"}); 49 | const resized = await resizer.exec(image); 50 | const gmImage = gmP(resized.data); 51 | const out = await gmImage.format(); 52 | 53 | t.is(out, "GIF"); 54 | }); 55 | -------------------------------------------------------------------------------- /lib/StreamChain.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const WritableStream = require("./WritableImageStream"); 4 | 5 | class StreamChain { 6 | 7 | /** 8 | * Strem Chain 9 | * Start input, pipes streams, and get output buffer 10 | * 11 | * @constructor 12 | * @param stream.Readable inputStream 13 | */ 14 | constructor(inputStream) { 15 | this.inputStream = inputStream; 16 | this.pipeProcesses = []; 17 | } 18 | 19 | /** 20 | * Static instantiate 21 | * 22 | * @public 23 | * @static 24 | * @param stream.Readable inputStream 25 | * @return StreamChain 26 | */ 27 | static make(inputStream) { 28 | return new StreamChain(inputStream); 29 | } 30 | 31 | /** 32 | * Pipes stream lists 33 | * 34 | * @public 35 | * @param Array processes 36 | * @return StreamChain this 37 | */ 38 | pipes(processes) { 39 | let index = -1; 40 | while ( processes[++index] ) { 41 | this.pipeProcesses.push(processes[index]); 42 | } 43 | 44 | return this; 45 | } 46 | 47 | /** 48 | * Run the streams 49 | * 50 | * @public 51 | * @return Promise 52 | */ 53 | run() { 54 | this.inputStream.pause(); 55 | 56 | return new Promise((resolve, reject) => { 57 | const output = new WritableStream(); 58 | let current; 59 | 60 | this.inputStream.on("error", reject); 61 | current = this.inputStream; 62 | 63 | this.pipeProcesses.forEach((optimizer) => { 64 | const proc = optimizer.spawnProcess(); 65 | current.pipe(proc.stdin); 66 | current = proc.stdout; 67 | }); 68 | 69 | current.pipe(output); 70 | output.on("error", reject); 71 | output.on("finish", () => resolve(output.getBufferStack())); 72 | this.inputStream.resume(); 73 | }); 74 | } 75 | } 76 | 77 | module.exports = StreamChain; 78 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Automatic Image resize, reduce with AWS Lambda 3 | * Lambda main handler 4 | * 5 | * @author Yoshiaki Sugimoto 6 | * @created 2015/10/29 7 | */ 8 | "use strict"; 9 | 10 | const ImageProcessor = require("./lib/ImageProcessor"); 11 | const S3FileSystem = require("./lib/S3FileSystem"); 12 | const eventParser = require("./lib/EventParser"); 13 | const Config = require("./lib/Config"); 14 | const fs = require("fs"); 15 | const path = require("path"); 16 | 17 | // Lambda Handler 18 | exports.handler = (event, context, callback) => { 19 | 20 | var eventRecord = eventParser(event); 21 | if (eventRecord) { 22 | process(eventRecord, callback); 23 | } else { 24 | console.log(JSON.stringify(event)); 25 | callback('Unsupported or invalid event'); 26 | return; 27 | } 28 | }; 29 | 30 | function process(s3Object, callback) { 31 | const configPath = path.resolve(__dirname, "config.json"); 32 | const fileSystem = new S3FileSystem(); 33 | const processor = new ImageProcessor(fileSystem, s3Object); 34 | const config = new Config( 35 | JSON.parse(fs.readFileSync(configPath, { encoding: "utf8" })) 36 | ); 37 | 38 | processor.run(config) 39 | .then((processedImages) => { 40 | const message = "OK, " + processedImages + " images were processed."; 41 | console.log(message); 42 | callback(null, message); 43 | return; 44 | }) 45 | .catch((messages) => { 46 | if ( messages === "Object was already processed." ) { 47 | console.log("Image already processed"); 48 | callback(null, "Image already processed"); 49 | return; 50 | } else if ( messages === "Empty file or directory." ) { 51 | console.log( "Image file is broken or it's a folder" ); 52 | callback( null, "Image file is broken or it's a folder" ); 53 | return; 54 | } else { 55 | callback("Error processing " + s3Object.object.key + ": " + messages); 56 | return; 57 | } 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /test/fixture/events/sns_s3_put_directory.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "EventSource": "aws:sns", 5 | "EventVersion": "1.0", 6 | "EventSubscriptionArn": "arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer:eb4420e4-e23b-445c-82cc-c17c3aad8b83", 7 | "Sns": { 8 | "Type": "Notification", 9 | "MessageId": "de55427f-9fc4-50aa-9bc3-135663aa69ca", 10 | "TopicArn": "arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer", 11 | "Subject": "Amazon S3 Notification", 12 | "Message": "{\"Records\":[{\"eventVersion\":\"2.0\",\"eventSource\":\"aws:s3\",\"awsRegion\":\"eu-west-1\",\"eventTime\":\"2017-02-24T09:17:34.646Z\",\"eventName\":\"ObjectCreated:Put\",\"userIdentity\":{\"principalId\":\"A18ZVM2MRUILBS\"},\"requestParameters\":{\"sourceIPAddress\":\"127.0.0.1\"},\"responseElements\":{\"x-amz-request-id\":\"7A389B82857B32FB\",\"x-amz-id-2\":\"fptKzxR4TGXhuUkJ45IsaYo2rug4Se/4X1ypyXaBf2Tqk6vlATs+Uo5EaOe1jNx7LtYITjXf1JA=\"},\"s3\":{\"s3SchemaVersion\":\"1.0\",\"configurationId\":\"OTU2MjNhZGYtNDNjNC00OGZlLWJlNWMtNmY5MzgyZGVkYTkw\",\"bucket\":{\"name\":\"mybucket\",\"ownerIdentity\":{\"principalId\":\"A18ZVM2MRUILBS\"},\"arn\":\"arn:aws:s3:::mybucket\"},\"object\":{\"key\":\"some/\",\"size\":0,\"eTag\":\"d41d8cd98f00b204e9800998ecf8427e\",\"sequencer\":\"0058AFFA2E998530D6\"}}}]}", 13 | "Timestamp": "2017-02-24T09:17:34.689Z", 14 | "SignatureVersion": "1", 15 | "Signature": "M349kyPwUx42Qy+55KxfOwwdQW3tmgBPPSqXWImasV9/OPHmin7NK8ZFuqo1QK6Ea3DWCaOjmZKlwdNSC33wRgQUOYs2/KKMO+V1KeCYiGSjL45yFJ7m52n0GFe44uq7/yZ6VzRZ/9+GlQkAaijidoU/iMEIUe9unpddhypuYbEpGYJyvUt1bBUi24wJLNAZHQqjw8kb2Y4RHJXNzqXIDgPggDOesG1v9HLYNkTHKhWS+pXcC0yuOPES/sGi+lsquZ1jgP3UL/8qF9kAKuIIBhtfMHkqmFhQ96j+EoGQ+K1WwVX7Z8bGXDm85LumWMc87Axe8YnsHxr5PIvm+rcfhw==", 16 | "SigningCertUrl": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a046b3aafc7f4149a.pem", 17 | "UnsubscribeUrl": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:514223584321:lambda-image-resizer:eb4420e4-e23b-445c-82cc-c17c3aad8b83", 18 | "MessageAttributes": {} 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /test/fixture/events/sns_s3_put_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "EventSource": "aws:sns", 5 | "EventVersion": "1.0", 6 | "EventSubscriptionArn": "arn:aws:sns:eu-west-1:514223584321:sale-image-lambda-resizer:55bcf9df-04d1-40ea-a2ce-fab9c9a82f6b", 7 | "Sns": { 8 | "Type": "Notification", 9 | "MessageId": "808a9964-cb23-56f1-beb8-c86d75117654", 10 | "TopicArn": "arn:aws:sns:eu-west-1:514223584321:sale-image-lambda-resizer", 11 | "Subject": "Amazon S3 Notification", 12 | "Message": "{\"Records\":[{\"s3\":{\"bucket\":{\"name\":\"sourcebucket\",\"arn\":\"arn:aws:s3:::mybucket\",\"ownerIdentity\":{\"principalId\":\"ABCDEFG12345678\"}},\"s3SchemaVersion\":\"1.0\",\"configurationId\":\"1e094c6b388a-49be-9daf-9228-a6131229\",\"object\":{\"size\":1024,\"eTag\":\"2466acb79a876d5d144368b9452dc178\",\"key\":\"HappyFace.jpg\",\"sequencer\":\"ABCDEFG12345678\"}},\"awsRegion\":\"eu-west-1\",\"eventVersion\":\"2.0\",\"responseElements\":{\"x-amz-request-id\":\"ABCDEFG12345678\",\"x-amz-id-2\":\"ABCDEFG12345678ABCDEFG12345678ABCDEFG12345678ABCDEFG12345678\"},\"eventSource\":\"aws:s3\",\"eventTime\":\"2017-01-30T22:19:30.471Z\",\"requestParameters\":{\"sourceIPAddress\":\"123.456.789.012\"},\"eventName\":\"ObjectCreated:Put\",\"userIdentity\":{\"principalId\":\"ABCDEFG12345678\"}}]}", 13 | "Timestamp": "2017-01-30T22:19:30.544Z", 14 | "SignatureVersion": "1", 15 | "Signature": "QDUbZtc4YRISr0b1dTo0iiUz3ljWTr0oeyJWvkW2kJZB6xfk+Zkhd1EQk+gBPjo9pOwo3/nqDLzLc7ano9976EgX4mFkbVlr25EgeIK32E76WGrUZsiI9D/uHi6uS8O1T1evafgN0y3t23YDPJ3YVtw98CP0y1CV7JaiRWv+d3WYTYusPotj6//docGiyq8R6GbFgzkQPA/SA/nqEy4lvGJNz2z9Q+Y/VeZsF7DxdTgyNMsEZzbbQ9uWDvKUdF6QJSlRbgbbJJBINlaD+KWXpKeXKsS8it4REO2ILaOWkoZE5kMe4agcg+pKkGBzNcmTcBzCVlBjgLcQ632hhJtgkw==", 16 | "SigningCertUrl": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a046b3aafc7f4149a.pem", 17 | "UnsubscribeUrl": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:514223584321:sale-image-lambda-resizer:55bcf9df-04d1-40ea-a2ce-fab9c9a82f6b", 18 | "MessageAttributes": {} 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /test/resize.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageResizer = require("../lib/ImageResizer"); 4 | const ImageData = require("../lib/ImageData"); 5 | const bindAll = require('bind-all'); 6 | const gm = require("gm").subClass({ imageMagick: true }); 7 | const test = require("ava"); 8 | const pify = require("pify"); 9 | const fs = require("fs"); 10 | 11 | const gmP = (...args) => pify(bindAll(gm(...args))); 12 | 13 | let image; 14 | 15 | test.before(async t => { 16 | const fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.jpg`); 17 | image = new ImageData("fixture/fixture.jpg", "fixture", fixture, {}, "private"); 18 | }); 19 | 20 | test("If ACL parameter is passed while resizing use original one", async t => { 21 | const resizer = new ImageResizer({size: 200}); 22 | const resized = await resizer.exec(image); 23 | 24 | t.is(resized.acl, 'private'); 25 | }); 26 | 27 | test("Ensuring that ACL parameter is passed while resizing", async t => { 28 | const resizer = new ImageResizer({size: 200, acl: 'public-read'}); 29 | const resized = await resizer.exec(image); 30 | 31 | t.is(resized.acl, 'public-read'); 32 | }); 33 | 34 | test("Resize doesn't add prefix and suffix to filename, it's covered by reducer", async t => { 35 | const resizer = new ImageResizer({size: 200, prefix: "a_", suffix: "_b"}); 36 | const resized = await resizer.exec(image); 37 | 38 | t.is(resized.fileName, "fixture/fixture.jpg"); 39 | }); 40 | 41 | test("Resize by default keep aspect ratio", async t => { 42 | const resizer = new ImageResizer({size: "200x200"}); 43 | const resized = await resizer.exec(image); 44 | const gmImage = gmP(resized.data); 45 | const out = await gmImage.size(); 46 | 47 | t.is(out.height, 200); 48 | t.false(out.width === 200); 49 | }); 50 | 51 | test("Resize can be forced to change aspect ratio", async t => { 52 | const resizer = new ImageResizer({size: "200x200!"}); 53 | const resized = await resizer.exec(image); 54 | const gmImage = gmP(resized.data); 55 | const out = await gmImage.size(); 56 | 57 | t.is(out.height, 200); 58 | t.is(out.width, 200); 59 | }); 60 | 61 | test("Crop allows to trim image to given size", async t => { 62 | const resizer = new ImageResizer({size: "667x1000", crop: "200x200+0+0"}); 63 | const resized = await resizer.exec(image); 64 | const gmImage = gmP(resized.data); 65 | const out = await gmImage.size(); 66 | 67 | t.is(out.height, 200); 68 | t.is(out.width, 200); 69 | }); 70 | -------------------------------------------------------------------------------- /layers/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lambci/lambda-base:build 2 | 3 | ARG GM_VERSION="1.3.31" 4 | ARG IMAGICK_VERSION="7.0.8-45" 5 | 6 | RUN yum update -y 7 | 8 | RUN yum install -y \ 9 | libpng-devel \ 10 | libjpeg-devel \ 11 | libtiff-devel \ 12 | libuuid-devel \ 13 | libopenjp2-devel \ 14 | libtiff-devel \ 15 | libwebp-devel \ 16 | libbz-devel \ 17 | gcc 18 | 19 | RUN curl -L https://github.com/ImageMagick/ImageMagick/archive/${IMAGICK_VERSION}.tar.gz -o ImageMagick-${IMAGICK_VERSION}.tar.gz && \ 20 | tar xfz ImageMagick-${IMAGICK_VERSION}.tar.gz && \ 21 | cd ImageMagick-${IMAGICK_VERSION} && \ 22 | ./configure \ 23 | --disable-dependency-tracking \ 24 | --disable-shared \ 25 | --enable-static \ 26 | --prefix=/opt \ 27 | --enable-delegate-build \ 28 | --without-modules \ 29 | --disable-docs \ 30 | --without-magick-plus-plus \ 31 | --without-perl \ 32 | --without-x \ 33 | --disable-openmp && \ 34 | make all && \ 35 | make install 36 | 37 | RUN curl https://versaweb.dl.sourceforge.net/project/graphicsmagick/graphicsmagick/${GM_VERSION}/GraphicsMagick-${GM_VERSION}.tar.xz | tar -xJ && \ 38 | cd GraphicsMagick-${GM_VERSION} && \ 39 | ./configure --prefix=/opt --enable-shared=no --enable-static=yes --with-gs-font-dir=/opt/share/fonts/default/Type1 && \ 40 | make && \ 41 | make install 42 | 43 | RUN cp /usr/lib64/liblcms2.so* /opt/lib && \ 44 | cp /usr/lib64/libtiff.so* /opt/lib && \ 45 | cp /usr/lib64/libfreetype.so* /opt/lib && \ 46 | cp /usr/lib64/libjpeg.so* /opt/lib && \ 47 | cp /usr/lib64/libpng*.so* /opt/lib && \ 48 | cp /usr/lib64/libXext.so* /opt/lib && \ 49 | cp /usr/lib64/libSM.so* /opt/lib && \ 50 | cp /usr/lib64/libICE.so* /opt/lib && \ 51 | cp /usr/lib64/libX11.so* /opt/lib && \ 52 | cp /usr/lib64/liblzma.so* /opt/lib && \ 53 | cp /usr/lib64/libxml2.so* /opt/lib && \ 54 | cp /usr/lib64/libgomp.so* /opt/lib && \ 55 | cp /usr/lib64/libjbig.so* /opt/lib && \ 56 | cp /usr/lib64/libxcb.so* /opt/lib && \ 57 | cp /usr/lib64/libXau.so* /opt/lib && \ 58 | cp /usr/lib64/libfontconfig.so* /opt/lib && \ 59 | cp /usr/lib64/libwebp.so* /opt/lib && \ 60 | cp /usr/lib64/libuuid.so /opt/lib/libuuid.so.1 && \ 61 | cp /usr/lib64/libbz2.so /opt/lib/libbz2.so.1 && \ 62 | cp /usr/lib64/libexpat.so /opt/lib/libexpat.so.1 63 | 64 | RUN mkdir -p /opt/share/fonts/default && \ 65 | cp -R /usr/share/fonts/default/Type1 /opt/share/fonts/default/Type1 66 | 67 | RUN cd /opt && \ 68 | find . ! -perm -o=r -exec chmod +400 {} \; && \ 69 | zip -yr /tmp/aws-lambda-image-layer.zip ./* 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-lambda-image", 3 | "version": "1.1.0", 4 | "description": "An AWS Lambda Function to resize/reduce images automatically", 5 | "config": { 6 | "profile": "default", 7 | "region": "ap-northeast-1", 8 | "memory": "1280", 9 | "timeout": "5", 10 | "name": "aws-lambda-image-with-dedicated-layer", 11 | "role": "lambda_basic_execution", 12 | "runtime": "nodejs10.x" 13 | }, 14 | "scripts": { 15 | "postinstall": "[ -f config.json ] || cp config.json.sample config.json", 16 | "deploy": "$(node scripts/deploy-command.js)", 17 | "add-handler": "npm run add-s3-handler", 18 | "add-s3-handler": "claudia add-s3-event-source --profile $npm_package_config_profile --bucket $npm_config_s3_bucket --events s3:ObjectCreated:* --prefix $npm_config_s3_prefix --suffix $npm_config_s3_suffix", 19 | "add-sns-handler": "claudia add-sns-event-source --profile $npm_package_config_profile --topic $npm_config_sns_topic", 20 | "release": "claudia set-version --profile $npm_package_config_profile --version production", 21 | "update": "$(node scripts/update-command.js)", 22 | "test": "nyc ava", 23 | "test-config": "./bin/configtest", 24 | "test-lambda": "claudia test-lambda --profile $npm_package_config_profile --event $npm_config_event_file", 25 | "pretest": "npm run lint", 26 | "lint": "eslint .", 27 | "destroy": "AWS_PROFILE=$npm_package_config_profile claudia destroy --profile $npm_package_config_profile" 28 | }, 29 | "keywords": [ 30 | "aws", 31 | "lambda", 32 | "image" 33 | ], 34 | "author": { 35 | "name": "Yoshiaki Sugimoto", 36 | "email": "sugimoto@wnotes.net", 37 | "web": "https://github.com/ysugimoto" 38 | }, 39 | "contributors": [ 40 | { 41 | "name": "Shogo Sensui", 42 | "email": "shogo.sensui@gmail.com", 43 | "web": "https://github.com/1000ch" 44 | }, 45 | { 46 | "name": "Kamil Dybicz", 47 | "web": "https://github.com/kdybicz" 48 | } 49 | ], 50 | "license": "MIT", 51 | "dependencies": { 52 | "aws-sdk": "^2.24.0", 53 | "gm": "^1.23.0", 54 | "image-type": "^3.0.0", 55 | "path-template": "0.0.0" 56 | }, 57 | "devDependencies": { 58 | "ava": "^2.0.0", 59 | "aws-sdk-mock": "^1.6.1", 60 | "babel-eslint": "^7.2.1", 61 | "bind-all": "^1.0.0", 62 | "claudia": "^5.0.1", 63 | "coveralls": "^3.0.2", 64 | "eslint": "^4.18.2", 65 | "nyc": "^14.1.1", 66 | "pify": "^2.3.0", 67 | "sinon": "^1.17.7" 68 | }, 69 | "directories": { 70 | "test": "test" 71 | }, 72 | "repository": { 73 | "type": "git", 74 | "url": "git://github.com/ysugimoto/aws-lambda-image.git" 75 | }, 76 | "bugs": { 77 | "url": "https://github.com/ysugimoto/aws-lambda-image/issues" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/S3FileSystem.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageData = require("./ImageData"); 4 | const aws = require("aws-sdk"); 5 | 6 | class S3FileSystem { 7 | 8 | constructor() { 9 | this.client = new aws.S3({ apiVersion: "2006-03-01" }); 10 | } 11 | 12 | /** 13 | * Get object data from S3 bucket 14 | * 15 | * @param String bucket 16 | * @param String key 17 | * @return Promise 18 | */ 19 | getObject(bucket, key, acl) { 20 | return new Promise((resolve, reject) => { 21 | console.log("Downloading: " + key); 22 | 23 | this.client.getObject({ Bucket: bucket, Key: key }).promise().then((data) => { 24 | if ( "img-processed" in data.Metadata ) { 25 | reject("Object was already processed."); 26 | } else if ( data.ContentLength <= 0 ) { 27 | reject("Empty file or directory."); 28 | } else { 29 | resolve(new ImageData( 30 | key, 31 | bucket, 32 | data.Body, 33 | { ContentType: data.ContentType, CacheControl: data.CacheControl, Metadata: data.Metadata }, 34 | acl 35 | )); 36 | } 37 | }).catch((err) => { 38 | reject("S3 getObject failed: " + err); 39 | }); 40 | }); 41 | } 42 | 43 | /** 44 | * Put object data to S3 bucket 45 | * 46 | * @param ImageData image 47 | * @return Promise 48 | */ 49 | putObject(image, options) { 50 | const params = { 51 | Bucket: image.bucketName, 52 | Key: image.fileName, 53 | Body: image.data, 54 | Metadata: Object.assign({}, image.headers.Metadata, { "img-processed": "true" }), 55 | ContentType: image.headers.ContentType, 56 | CacheControl: (options.cacheControl !== undefined) ? options.cacheControl : image.headers.CacheControl, 57 | ACL: image.acl || "private" 58 | }; 59 | 60 | console.log("Uploading to: " + params.Key + " (" + params.Body.length + " bytes)"); 61 | 62 | return this.client.putObject(params).promise(); 63 | } 64 | 65 | /** 66 | * Delete object data from S3 bucket 67 | * 68 | * @param ImageData image 69 | * @return Promise 70 | */ 71 | deleteObject(image) { 72 | const params = { 73 | Bucket: image.bucketName, 74 | Key: image.fileName 75 | }; 76 | 77 | console.log("Delete original object: " + params.Key); 78 | 79 | return this.client.deleteObject(params).promise(); 80 | } 81 | } 82 | 83 | module.exports = S3FileSystem; 84 | -------------------------------------------------------------------------------- /test/event-parser.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const eventParser = require( "../lib/EventParser" ); 4 | const test = require( "ava" ); 5 | const fs = require("fs"); 6 | 7 | test("Parsing S3 PUT file event", t => { 8 | const eventSource = `${__dirname}/fixture/events/s3_put_file.json`; 9 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 10 | const record = eventParser( event ); 11 | 12 | t.is( record.object.key, "HappyFace.jpg" ); 13 | t.is( record.object.size, 1024 ); 14 | t.is( record.bucket.name, "sourcebucket" ); 15 | t.is( record.bucket.arn, "arn:aws:s3:::mybucket" ); 16 | }); 17 | 18 | test("Parsing S3 PUT directory event", t => { 19 | const eventSource = `${__dirname}/fixture/events/s3_put_directory.json`; 20 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 21 | const record = eventParser( event ); 22 | 23 | t.is( record.object.key, "some/" ); 24 | t.is( record.object.size, 0 ); 25 | t.is( record.bucket.name, "mybucket" ); 26 | t.is( record.bucket.arn, "arn:aws:s3:::mybucket" ); 27 | }); 28 | 29 | test("Parsing SNS event carrying S3 PUT file event", t => { 30 | const eventSource = `${__dirname}/fixture/events/sns_s3_put_file.json`; 31 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 32 | const record = eventParser( event ); 33 | 34 | t.is( record.object.key, "HappyFace.jpg" ); 35 | t.is( record.object.size, 1024 ); 36 | t.is( record.bucket.name, "sourcebucket" ); 37 | t.is( record.bucket.arn, "arn:aws:s3:::mybucket" ); 38 | }); 39 | 40 | test("Parsing SNS event carrying S3 PUT directory event", t => { 41 | const eventSource = `${__dirname}/fixture/events/sns_s3_put_directory.json`; 42 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 43 | const record = eventParser( event ); 44 | 45 | t.is( record.object.key, "some/" ); 46 | t.is( record.object.size, 0 ); 47 | t.is( record.bucket.name, "mybucket" ); 48 | t.is( record.bucket.arn, "arn:aws:s3:::mybucket" ); 49 | }); 50 | 51 | test("Parsing S3 test event", t => { 52 | const eventSource = `${__dirname}/fixture/events/s3_test_event.json`; 53 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 54 | const record = eventParser( event ); 55 | 56 | t.is( record, false ); 57 | }); 58 | 59 | test("Parsing SNS test event", t => { 60 | const eventSource = `${__dirname}/fixture/events/sns_test_event.json`; 61 | const event = JSON.parse( fs.readFileSync( eventSource ) ); 62 | const record = eventParser( event ); 63 | 64 | t.is( record, false ); 65 | }); 66 | 67 | test("Parsing unsupported event returns false", t => { 68 | const record = eventParser( { "Records": [ { "dynamodb": { "Keys": "Value" }, "eventSource": "aws:dynamodb" } ] } ); 69 | 70 | t.is( record, false ); 71 | }); 72 | 73 | test("Parsing null event returns false", t => { 74 | const record = eventParser( null ); 75 | 76 | t.is( record, false ); 77 | }); 78 | 79 | test("Parsing empty event returns false", t => { 80 | const record = eventParser( {} ); 81 | 82 | t.is( record, false ); 83 | }); 84 | -------------------------------------------------------------------------------- /lib/ImageReducer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageData = require("./ImageData"); 4 | const Mozjpeg = require("./optimizer/Mozjpeg"); 5 | const JpegOptim = require("./optimizer/JpegOptim"); 6 | const Pngquant = require("./optimizer/Pngquant"); 7 | const Gifsicle = require("./optimizer/Gifsicle"); 8 | const ReadableStream = require("./ReadableImageStream"); 9 | const StreamChain = require("./StreamChain"); 10 | 11 | class ImageReducer { 12 | 13 | /** 14 | * Image Reducer 15 | * Accept png/jpeg typed image 16 | * 17 | * @constructor 18 | * @param Object option 19 | */ 20 | constructor(option) { 21 | this.option = option || {}; 22 | } 23 | 24 | /** 25 | * Execute process 26 | * 27 | * @public 28 | * @param ImageData image 29 | * @return Promise 30 | */ 31 | exec(image) { 32 | const option = this.option; 33 | 34 | const input = new ReadableStream(image.data); 35 | const streams = this.createReduceProcessList(image.type.ext); 36 | const chain = new StreamChain(input); 37 | 38 | return chain.pipes(streams).run() 39 | .then((buffer) => { 40 | return new ImageData( 41 | image.combineWithDirectory({ 42 | directory: option.directory, 43 | template: option.template, 44 | prefix: option.prefix, 45 | suffix: option.suffix, 46 | keepExtension: option.keepExtension 47 | }), 48 | option.bucket || image.bucketName, 49 | buffer, 50 | image.headers, 51 | option.acl || image.acl 52 | ); 53 | }); 54 | } 55 | 56 | /** 57 | * Create reduce image process list 58 | * 59 | * @protected 60 | * @param String type 61 | * @return Array 62 | * @thorws Error 63 | */ 64 | createReduceProcessList(type) { 65 | console.log("Reducing to: " + (this.option.directory || "in-place")); 66 | const args = this.option.optimizerOptions || {}; 67 | 68 | const streams = []; 69 | switch ( type ) { 70 | case "png": 71 | streams.push(new Pngquant(args.pngquant)); 72 | break; 73 | case "jpg": 74 | case "jpeg": 75 | if ( this.option.jpegOptimizer === "jpegoptim" ) { // using jpegoptim 76 | streams.push(new JpegOptim(this.option.quality, args.jpegoptim)); 77 | } else { // using mozjpeg 78 | streams.push(new Mozjpeg(this.option.quality, args.mozjpeg) ); 79 | } 80 | break; 81 | case "gif": 82 | streams.push(new Gifsicle(args.gifsicle)); 83 | break; 84 | default: 85 | throw new Error("Unexpected output type: " + type); 86 | } 87 | 88 | return streams; 89 | } 90 | } 91 | 92 | module.exports = ImageReducer; 93 | -------------------------------------------------------------------------------- /test/e2e-png.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageProcessor = require("../lib/ImageProcessor"); 4 | const ImageData = require("../lib/ImageData"); 5 | const Config = require("../lib/Config"); 6 | const S3FileSystem = require("../lib/S3FileSystem"); 7 | const test = require("ava"); 8 | const sinon = require("sinon"); 9 | const pify = require("pify"); 10 | const fs = require("fs"); 11 | const fsP = pify(fs); 12 | const sourceFile = `${__dirname}/fixture/events/s3_put_file.json`; 13 | const setting = JSON.parse(fs.readFileSync(sourceFile)); 14 | const AWS = require("aws-sdk-mock"); 15 | 16 | let processor; 17 | let images; 18 | let fileSystem; 19 | 20 | test.before(async t => { 21 | AWS.mock("S3", "deleteObject", (params, callback) => { 22 | return callback( null ); 23 | }); 24 | fileSystem = new S3FileSystem(); 25 | sinon.stub(fileSystem, "getObject", () => { 26 | return fsP.readFile(`${__dirname}/fixture/fixture.png`).then(data => { 27 | return new ImageData( 28 | "test.png", 29 | setting.Records[0].s3.bucket.name, 30 | data 31 | ); 32 | }); 33 | }); 34 | sinon.stub(fileSystem, "putObject", (image) => { 35 | images.push(image); 36 | return Promise.resolve(image); 37 | }); 38 | }); 39 | 40 | test.after(async t => { 41 | AWS.restore("S3"); 42 | fileSystem.getObject.restore(); 43 | fileSystem.putObject.restore(); 44 | }); 45 | 46 | test.beforeEach(async t => { 47 | processor = new ImageProcessor(fileSystem, setting.Records[0].s3); 48 | images = []; 49 | }); 50 | 51 | test("Remove original file if move option is supplied", async t => { 52 | const spy = sinon.spy(fileSystem, "deleteObject"); 53 | const im = await processor.run(new Config({ 54 | "bucket": "some", 55 | "backup": { 56 | "move": true 57 | } 58 | })); 59 | t.true(spy.called); 60 | images = []; 61 | }); 62 | 63 | test("Reduce PNG with no configuration", async t => { 64 | await processor.run(new Config({})); 65 | // no working 66 | t.is(images.length, 0); 67 | }); 68 | 69 | test("Reduce PNG with basic configuration", async t => { 70 | await processor.run(new Config({ 71 | reduce: {} 72 | })); 73 | t.is(images.length, 1); 74 | const image = images.shift(); 75 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.png`); 76 | t.is(image.bucketName, setting.Records[0].s3.bucket.name); 77 | t.is(image.fileName, "test.png"); 78 | t.true(image.data.length > 0); 79 | t.true(image.data.length < fixture.length); 80 | }); 81 | 82 | test("Reduce PNG with bucket/directory configuration", async t => { 83 | await processor.run(new Config({ 84 | "bucket": "some", 85 | "reduce": { 86 | "directory": "resized" 87 | } 88 | })); 89 | t.is(images.length, 1); 90 | const image = images.shift(); 91 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.png`); 92 | t.is(image.bucketName, "some"); 93 | t.is(image.fileName, "resized/test.png"); 94 | t.true(image.data.length > 0); 95 | t.true(image.data.length < fixture.length); 96 | }); 97 | 98 | -------------------------------------------------------------------------------- /test/e2e-jpeg-jpegoptim.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageProcessor = require("../lib/ImageProcessor"); 4 | const ImageData = require("../lib/ImageData"); 5 | const Config = require("../lib/Config"); 6 | const S3FileSystem = require("../lib/S3FileSystem"); 7 | const test = require("ava"); 8 | const sinon = require("sinon"); 9 | const pify = require("pify"); 10 | const fs = require("fs"); 11 | const fsP = pify(fs); 12 | const sourceFile = `${__dirname}/fixture/events/s3_put_file.json`; 13 | const setting = JSON.parse(fs.readFileSync(sourceFile)); 14 | 15 | let processor; 16 | let images; 17 | let fileSystem; 18 | 19 | test.before(async t => { 20 | fileSystem = new S3FileSystem(); 21 | sinon.stub(fileSystem, "getObject", () => { 22 | return fsP.readFile(`${__dirname}/fixture/fixture.jpg`).then(data => { 23 | return new ImageData( 24 | setting.Records[0].s3.object.key, 25 | setting.Records[0].s3.bucket.name, 26 | data 27 | ); 28 | }); 29 | }); 30 | sinon.stub(fileSystem, "putObject", (image) => { 31 | images.push(image); 32 | return Promise.resolve(image); 33 | }); 34 | }); 35 | 36 | test.after(async t => { 37 | fileSystem.getObject.restore(); 38 | fileSystem.putObject.restore(); 39 | }); 40 | 41 | test.beforeEach(async t => { 42 | processor = new ImageProcessor(fileSystem, setting.Records[0].s3); 43 | images = []; 44 | }); 45 | 46 | test("Reduce JPEG with no configuration", async t => { 47 | await processor.run(new Config({jpegOptimizer: "jpegoptim"})); 48 | // no working 49 | t.is(images.length, 0); 50 | }); 51 | 52 | test("Reduce JPEG with basic configuration", async t => { 53 | await processor.run(new Config({ 54 | jpegOptimizer: "jpegoptim", 55 | reduce: {} 56 | })); 57 | t.is(images.length, 1); 58 | const image = images.shift(); 59 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 60 | t.is(image.bucketName, "sourcebucket"); 61 | t.is(image.fileName, "HappyFace.jpg"); 62 | t.true(image.data.length > 0); 63 | t.true(image.data.length < fixture.length); 64 | }); 65 | 66 | test("Reduce JPEG with bucket/directory configuration", async t => { 67 | await processor.run(new Config({ 68 | "reduce": { 69 | "bucket": "foo", 70 | "directory": "some", 71 | "jpegOptimizer": "jpegoptim" 72 | } 73 | })); 74 | t.is(images.length, 1); 75 | const image = images.shift(); 76 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 77 | t.is(image.bucketName, "foo"); 78 | t.is(image.fileName, "some/HappyFace.jpg"); 79 | t.true(image.data.length > 0); 80 | t.true(image.data.length < fixture.length); 81 | }); 82 | 83 | 84 | test("Reduce JPEG with quality", async t => { 85 | await processor.run(new Config({ 86 | "reduce": { 87 | "quality": 90, 88 | "jpegOptimizer": "jpegoptim" 89 | } 90 | })); 91 | t.is(images.length, 1); 92 | const image = images.shift(); 93 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 94 | t.true(image.data.length > 0); 95 | t.true(image.data.length < fixture.length); 96 | }); 97 | -------------------------------------------------------------------------------- /lib/ImageResizer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageData = require("./ImageData"); 4 | const gm = require("gm").subClass({ imageMagick: true }) 5 | 6 | const cropSpec = /(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(%)?/; 7 | 8 | /** 9 | * Get enable to use memory size in ImageMagick 10 | * Typically we determine to us 90% of max memory size 11 | * @see https://docs.aws.amazon.com/lambda/latest/dg/lambda-environment-variables.html 12 | */ 13 | const getEnableMemory = () => { 14 | const mem = parseInt(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE, 10); 15 | return Math.floor(mem * 90 / 100); 16 | }; 17 | 18 | class ImageResizer { 19 | 20 | /** 21 | * Image Resizer 22 | * resize image with ImageMagick 23 | * 24 | * @constructor 25 | * @param Object options 26 | */ 27 | constructor(options) { 28 | this.options = options; 29 | } 30 | 31 | /** 32 | * Execute resize 33 | * 34 | * @public 35 | * @param ImageData image 36 | * @return Promise 37 | */ 38 | exec(image) { 39 | const acl = this.options.acl; 40 | 41 | return new Promise((resolve, reject) => { 42 | console.log("Resizing to: " + (this.options.directory || "in-place")); 43 | 44 | let img = gm(image.data) 45 | .limit("memory", `${getEnableMemory()}MB`) 46 | .geometry(this.options.size.toString()); 47 | if ( "orientation" in this.options ) { 48 | img = img.autoOrient(); 49 | } 50 | if ( "gravity" in this.options ) { 51 | img = img.gravity(this.options.gravity); 52 | } 53 | if ( "background" in this.options ) { 54 | img = img.background(this.options.background).flatten(); 55 | } 56 | if ( "crop" in this.options ) { 57 | const cropArgs = this.options.crop.match(cropSpec); 58 | const cropWidth = cropArgs[1]; 59 | const cropHeight = cropArgs[2]; 60 | const cropX = cropArgs[3]; 61 | const cropY = cropArgs[4]; 62 | const cropPercent = cropArgs[5]; 63 | img = img.crop(cropWidth, cropHeight, cropX, cropY, cropPercent === "%"); 64 | } 65 | if( "format" in this.options ) { 66 | img = img.setFormat(this.options.format); 67 | } 68 | 69 | // @see: https://github.com/aheckmann/gm/issues/572#issuecomment-293768810 70 | img.stream((err, stdout, stderr) => { 71 | if ( err ) { 72 | return reject(err); 73 | } 74 | const chunks = []; 75 | stdout.on('data', (chunk) => { 76 | chunks.push(chunk); 77 | }); 78 | // these are 'once' because they can and do fire multiple times for multiple errors, 79 | // but this is a promise so you'll have to deal with them one at a time 80 | stdout.once('end', () => { 81 | resolve(new ImageData( 82 | image.fileName, 83 | image.bucketName, 84 | Buffer.concat(chunks), 85 | image.headers, 86 | acl || image.acl 87 | )); 88 | }); 89 | stderr.once('data', (data) => { 90 | reject(String(data)); 91 | }); 92 | }); 93 | }); 94 | } 95 | } 96 | 97 | module.exports = ImageResizer; 98 | -------------------------------------------------------------------------------- /test/e2e-jpeg.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageProcessor = require("../lib/ImageProcessor"); 4 | const ImageData = require("../lib/ImageData"); 5 | const Config = require("../lib/Config"); 6 | const S3FileSystem = require("../lib/S3FileSystem"); 7 | const test = require("ava"); 8 | const sinon = require("sinon"); 9 | const pify = require("pify"); 10 | const fs = require("fs"); 11 | const fsP = pify(fs); 12 | const sourceFile = `${__dirname}/fixture/events/s3_put_file.json`; 13 | const setting = JSON.parse(fs.readFileSync(sourceFile)); 14 | 15 | let processor; 16 | let images; 17 | let fileSystem; 18 | 19 | test.before(async t => { 20 | fileSystem = new S3FileSystem(); 21 | sinon.stub(fileSystem, "getObject", () => { 22 | return fsP.readFile(`${__dirname}/fixture/fixture.jpg`).then(data => { 23 | return new ImageData( 24 | setting.Records[0].s3.object.key, 25 | setting.Records[0].s3.bucket.name, 26 | data 27 | ); 28 | }); 29 | }); 30 | sinon.stub(fileSystem, "putObject", (image) => { 31 | images.push(image); 32 | return Promise.resolve(image); 33 | }); 34 | }); 35 | 36 | test.after(async t => { 37 | fileSystem.getObject.restore(); 38 | fileSystem.putObject.restore(); 39 | }); 40 | 41 | test.beforeEach(async t => { 42 | processor = new ImageProcessor(fileSystem, setting.Records[0].s3); 43 | images = []; 44 | }); 45 | 46 | test("Reduce JPEG with no configuration", async t => { 47 | await processor.run(new Config({})); 48 | // no working 49 | t.is(images.length, 0); 50 | }); 51 | 52 | test("Reduce JPEG with basic configuration", async t => { 53 | await processor.run(new Config({ 54 | reduce: {} 55 | })); 56 | t.is(images.length, 1); 57 | const image = images.shift(); 58 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 59 | t.is(image.bucketName, "sourcebucket"); 60 | t.is(image.fileName, "HappyFace.jpg"); 61 | t.true(image.data.length > 0); 62 | t.true(image.data.length < fixture.length); 63 | }); 64 | 65 | test("Reduce JPEG with bucket/directory configuration", async t => { 66 | await processor.run(new Config({ 67 | "reduce": { 68 | "bucket": "foo", 69 | "directory": "some" 70 | } 71 | })); 72 | t.is(images.length, 1); 73 | const image = images.shift(); 74 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 75 | t.is(image.bucketName, "foo"); 76 | t.is(image.fileName, "some/HappyFace.jpg"); 77 | t.true(image.data.length > 0); 78 | t.true(image.data.length < fixture.length); 79 | }); 80 | 81 | test("Backup JPEG with prefix and suffix", async t => { 82 | await processor.run(new Config({ 83 | backup: { 84 | prefix: "a_", 85 | suffix: "_b" 86 | } 87 | })); 88 | t.is(images.length, 1); 89 | const image = images.shift(); 90 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 91 | t.is(image.bucketName, "sourcebucket"); 92 | t.is(image.fileName, "a_HappyFace_b.jpg"); 93 | t.true(image.data.length === fixture.length); 94 | }); 95 | 96 | test("Resize JPEG with quality", async t => { 97 | await processor.run(new Config({ 98 | "resizes": [ 99 | { 100 | "size": 100, 101 | "quality": 90 102 | } 103 | ] 104 | })); 105 | t.is(images.length, 1); 106 | const image = images.shift(); 107 | const fixture = await fsP.readFile(`${__dirname}/fixture/fixture.jpg`); 108 | t.is(image.fileName, "HappyFace.jpg"); 109 | t.true(image.data.length > 0); 110 | t.true(image.data.length < fixture.length); 111 | }); 112 | 113 | test("Resize JPEG with format", async t => { 114 | await processor.run(new Config({ 115 | "resizes": [ 116 | { 117 | "size": 100, 118 | "format": "png" 119 | }, 120 | { 121 | "size": 100, 122 | "format": "gif" 123 | } 124 | ] 125 | })); 126 | t.is(images.length, 2); 127 | 128 | const pngImage = images.shift(); 129 | t.is(pngImage.fileName, "HappyFace.png"); 130 | t.true(pngImage.data.length > 0); 131 | 132 | const gifImage = images.shift(); 133 | t.is(gifImage.fileName, "HappyFace.gif"); 134 | t.true(gifImage.data.length > 0); 135 | }); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | parser: babel-eslint 2 | 3 | env: 4 | amd: true 5 | es6: true 6 | node: true 7 | 8 | # http://eslint.org/docs/rules/ 9 | rules: 10 | # Possible Errors 11 | comma-dangle: [2, never] 12 | no-cond-assign: 2 13 | no-console: 0 14 | no-constant-condition: 2 15 | no-control-regex: 2 16 | no-debugger: 2 17 | no-dupe-args: 2 18 | no-dupe-keys: 2 19 | no-duplicate-case: 2 20 | no-empty: 2 21 | no-empty-character-class: 2 22 | no-ex-assign: 2 23 | no-extra-boolean-cast: 2 24 | no-extra-parens: 0 25 | no-extra-semi: 2 26 | no-func-assign: 2 27 | no-inner-declarations: [2, functions] 28 | no-invalid-regexp: 2 29 | no-irregular-whitespace: 2 30 | no-negated-in-lhs: 2 31 | no-obj-calls: 2 32 | no-regex-spaces: 2 33 | no-sparse-arrays: 2 34 | no-unexpected-multiline: 2 35 | no-unreachable: 2 36 | use-isnan: 2 37 | valid-jsdoc: 0 38 | valid-typeof: 2 39 | 40 | # Best Practices 41 | accessor-pairs: 2 42 | block-scoped-var: 0 43 | complexity: 2 44 | consistent-return: 0 45 | curly: 0 46 | default-case: 0 47 | dot-location: 0 48 | dot-notation: 0 49 | eqeqeq: 2 50 | guard-for-in: 2 51 | no-alert: 2 52 | no-caller: 2 53 | no-case-declarations: 2 54 | no-div-regex: 2 55 | no-else-return: 0 56 | no-empty-pattern: 2 57 | no-eq-null: 2 58 | no-eval: 2 59 | no-extend-native: 2 60 | no-extra-bind: 2 61 | no-fallthrough: 2 62 | no-floating-decimal: 0 63 | no-implicit-coercion: 0 64 | no-implied-eval: 2 65 | no-invalid-this: 0 66 | no-iterator: 2 67 | no-labels: 0 68 | no-lone-blocks: 2 69 | no-loop-func: 2 70 | no-magic-number: 0 71 | no-multi-spaces: 0 72 | no-multi-str: 0 73 | no-native-reassign: 2 74 | no-new-func: 2 75 | no-new-wrappers: 2 76 | no-new: 2 77 | no-octal-escape: 2 78 | no-octal: 2 79 | no-proto: 2 80 | no-redeclare: 2 81 | no-return-assign: 2 82 | no-script-url: 2 83 | no-self-compare: 2 84 | no-sequences: 0 85 | no-throw-literal: 0 86 | no-unused-expressions: 2 87 | no-useless-call: 2 88 | no-useless-concat: 2 89 | no-void: 2 90 | no-warning-comments: 0 91 | no-with: 2 92 | radix: 2 93 | vars-on-top: 0 94 | wrap-iife: 2 95 | yoda: 0 96 | 97 | # Strict 98 | strict: 0 99 | 100 | # Variables 101 | init-declarations: 0 102 | no-catch-shadow: 2 103 | no-delete-var: 2 104 | no-label-var: 2 105 | no-shadow-restricted-names: 2 106 | no-shadow: 0 107 | no-undef-init: 2 108 | no-undef: 0 109 | no-undefined: 0 110 | no-unused-vars: 0 111 | no-use-before-define: 0 112 | 113 | # Node.js and CommonJS 114 | callback-return: 2 115 | global-require: 2 116 | handle-callback-err: 2 117 | no-mixed-requires: 0 118 | no-new-require: 0 119 | no-path-concat: 2 120 | no-process-exit: 2 121 | no-restricted-modules: 0 122 | no-sync: 0 123 | 124 | # Stylistic Issues 125 | array-bracket-spacing: 0 126 | block-spacing: 0 127 | brace-style: 0 128 | camelcase: 0 129 | comma-spacing: 0 130 | comma-style: 0 131 | computed-property-spacing: 0 132 | consistent-this: 0 133 | eol-last: 0 134 | func-names: 0 135 | func-style: 0 136 | id-length: 0 137 | id-match: 0 138 | indent: 0 139 | jsx-quotes: 0 140 | key-spacing: 0 141 | linebreak-style: 0 142 | lines-around-comment: 0 143 | max-depth: 0 144 | max-len: 0 145 | max-nested-callbacks: 0 146 | max-params: 0 147 | max-statements: [2, 30] 148 | new-cap: 0 149 | new-parens: 0 150 | newline-after-var: 0 151 | no-array-constructor: 0 152 | no-bitwise: 0 153 | no-continue: 0 154 | no-inline-comments: 0 155 | no-lonely-if: 0 156 | no-mixed-spaces-and-tabs: 0 157 | no-multiple-empty-lines: 0 158 | no-negated-condition: 0 159 | no-nested-ternary: 0 160 | no-new-object: 0 161 | no-plusplus: 0 162 | no-restricted-syntax: 0 163 | no-spaced-func: 0 164 | no-ternary: 0 165 | no-trailing-spaces: 0 166 | no-underscore-dangle: 0 167 | no-unneeded-ternary: 0 168 | object-curly-spacing: 0 169 | one-var: 0 170 | operator-assignment: 0 171 | operator-linebreak: 0 172 | padded-blocks: 0 173 | quote-props: 0 174 | quotes: 0 175 | require-jsdoc: 0 176 | semi-spacing: 0 177 | semi: 0 178 | sort-vars: 0 179 | space-after-keywords: 0 180 | space-before-blocks: 0 181 | space-before-function-paren: 0 182 | space-before-keywords: 0 183 | space-in-parens: 0 184 | space-infix-ops: 0 185 | space-return-throw-case: 0 186 | space-unary-ops: 0 187 | spaced-comment: 0 188 | wrap-regex: 0 189 | 190 | # ECMAScript 6 191 | arrow-body-style: 0 192 | arrow-parens: 0 193 | arrow-spacing: 0 194 | constructor-super: 0 195 | generator-star-spacing: 0 196 | no-arrow-condition: 0 197 | no-class-assign: 0 198 | no-const-assign: 0 199 | no-dupe-class-members: 0 200 | no-this-before-super: 0 201 | no-var: 0 202 | object-shorthand: 0 203 | prefer-arrow-callback: 0 204 | prefer-const: 0 205 | prefer-reflect: 0 206 | prefer-spread: 0 207 | prefer-template: 0 208 | require-yield: 0 209 | -------------------------------------------------------------------------------- /test/image-data.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageData = require("../lib/ImageData"); 4 | const test = require("ava"); 5 | 6 | let image; 7 | 8 | test.before(t => { 9 | image = new ImageData("a/b/c/key.png", "bucket", "data", {}); 10 | }); 11 | 12 | test("Build output path when directory is undefined", t => { 13 | t.is(image.combineWithDirectory({}), "a/b/c/key.png"); 14 | }); 15 | 16 | test("Build output path when directory is empty", t => { 17 | t.is(image.combineWithDirectory({directory: ""}), "key.png"); 18 | }); 19 | 20 | test("Build output path when directory is relative deeper", t => { 21 | t.is(image.combineWithDirectory({directory: "./d"}), "a/b/c/d/key.png"); 22 | }); 23 | 24 | test("Build output path when directory is relative deeper - 2nd level", t => { 25 | t.is(image.combineWithDirectory({directory: "./d/e"}), "a/b/c/d/e/key.png"); 26 | }); 27 | 28 | test("Build output path when directory is relative backward", t => { 29 | t.is(image.combineWithDirectory({directory: ".."}), "a/b/key.png"); 30 | }); 31 | 32 | test("Build output path when directory is relative backward with new subdirectory branch", t => { 33 | t.is(image.combineWithDirectory({directory: "../d"}), "a/b/d/key.png"); 34 | }); 35 | 36 | test("Build output path when directory is absolute", t => { 37 | t.is(image.combineWithDirectory({directory: "d"}), "d/key.png"); 38 | }); 39 | 40 | test("Build output path when directory is absolute - 2nd level", t => { 41 | t.is(image.combineWithDirectory({directory: "d/e"}), "d/e/key.png"); 42 | }); 43 | 44 | test("Build output path with prefix", t => { 45 | t.is(image.combineWithDirectory({directory: "d/e", prefix: "prefix-"}), "d/e/prefix-key.png"); 46 | }); 47 | 48 | test("Build output path with suffix", t => { 49 | t.is(image.combineWithDirectory({directory: "d/e", suffix: "-suffix"}), "d/e/key-suffix.png"); 50 | }); 51 | 52 | test("Build output path with prefix and suffix", t => { 53 | t.is(image.combineWithDirectory({directory: "d/e", prefix: "prefix-", suffix: "_suffix"}), "d/e/prefix-key_suffix.png"); 54 | }); 55 | 56 | test("Build output path with keep orignal extension", t => { 57 | t.is(image.combineWithDirectory({directory: "d/e", keepExtension: true}), "d/e/key.png"); 58 | }); 59 | 60 | test("[path-template] Build output path when template is an empty object", t => { 61 | t.is(image.combineWithDirectory({}), "a/b/c/key.png"); 62 | }); 63 | 64 | test("[path-template] Build output path when template is an empty map", t => { 65 | t.is(image.combineWithDirectory({template: {}}), "a/b/c/key.png"); 66 | }); 67 | 68 | test("[path-template] Build output path when template is an map with pattern and output keys empty", t => { 69 | t.is(image.combineWithDirectory({template: {pattern: "", output: ""}}), "a/b/c/key.png"); 70 | }); 71 | 72 | test("[path-template] Build output path when template replace whole directory", t => { 73 | t.is(image.combineWithDirectory({template: {pattern: "*", output: ""}}), "key.png"); 74 | }); 75 | 76 | test("[path-template] Build output path when template adds subdirectory", t => { 77 | t.is(image.combineWithDirectory({template: {pattern: "*path", output: "*path/d"}}), "a/b/c/d/key.png"); 78 | }); 79 | 80 | test("[path-template] Build output path when template adds subdirectory - 2nd level", t => { 81 | t.is(image.combineWithDirectory({template: {pattern: "*path", output: "*path/d/e"}}), "a/b/c/d/e/key.png"); 82 | }); 83 | 84 | test("[path-template] Build output path when template removes top subdirectory", t => { 85 | t.is(image.combineWithDirectory({template: {pattern: "*path/c", output: "*path"}}), "a/b/key.png"); 86 | }); 87 | 88 | test("[path-template] Build output path when template replaces top subdirectory with new one", t => { 89 | t.is(image.combineWithDirectory({template: {pattern: "*path/c", output: "*path/d"}}), "a/b/d/key.png"); 90 | }); 91 | 92 | test("[path-template] Build output path when template replaces old path with new absolute one", t => { 93 | t.is(image.combineWithDirectory({template: {pattern: "*", output: "d"}}), "d/key.png"); 94 | }); 95 | 96 | test("[path-template] Build output path when template replaces old path with new absolute one - 2nd level", t => { 97 | t.is(image.combineWithDirectory({template: {pattern: "*", output: "d/e"}}), "d/e/key.png"); 98 | }); 99 | 100 | test("[path-template] Build output path when template didn't match base directory", t => { 101 | t.is(image.combineWithDirectory({template: {pattern: "x/:something", output: "d/e"}}), "a/b/c/key.png"); 102 | }); 103 | 104 | test("[path-template] Build output path with template and prefix", t => { 105 | t.is(image.combineWithDirectory({template: {pattern: "*", output: "d/e"}, prefix: "prefix-"}), "d/e/prefix-key.png"); 106 | }); 107 | 108 | test("[path-template] Build output path with template and suffix", t => { 109 | t.is(image.combineWithDirectory({template: {pattern: "*", output: "d/e"}, suffix: "-suffix"}), "d/e/key-suffix.png"); 110 | }); 111 | 112 | test("[path-template] Build output path with template, prefix and suffix", t => { 113 | t.is(image.combineWithDirectory({template: {pattern: "*", output: "d/e"}, prefix: "prefix-", suffix: "_suffix"}), "d/e/prefix-key_suffix.png"); 114 | }); 115 | -------------------------------------------------------------------------------- /doc/DIRECTORY.md: -------------------------------------------------------------------------------- 1 | # Directory configuration 2 | 3 | There are few ways of setting the output directory for processed files. All 4 | of them work in the same way for resized, reduced and archived images. 5 | 6 | ## Nothing 7 | 8 | You are allowed to choose to do not setup any output directory configuration and 9 | use only `prefix` and/or `suffix` parameters. Just bare in mind that in such 10 | case all output files will be saved in same directory as input file - 11 | [S3 event notification limitations](#s3-event-notification-limitations). 12 | 13 | ## Directory 14 | 15 | | Parameter | Type | Required | 16 | |:---------:|:------:|:--------:| 17 | | directory | String | no | 18 | 19 | `directory` parameter should be a `String` representing output path. It could be 20 | an absolute (ie. `output/`) or relative (ie. `../output/`, `./output`) path. If 21 | you decide to use relative path, bare in mind that this could lead to situation 22 | where all output files will be saved in same directory structure as input file - 23 | [S3 event notification limitations](#s3-event-notification-limitations). 24 | 25 | ## Template 26 | 27 | | Parameter | Type | Required | 28 | |:---------:|:------:|:--------:| 29 | | template | Object | no | 30 | 31 | `template` parameter is a `Map` with two keys: `pattern` and `output`, ie.: 32 | 33 | ``` 34 | { 35 | template: { 36 | pattern: "*path/c", 37 | output: "*path/d" 38 | } 39 | } 40 | ``` 41 | 42 | `pattern` defines a pattern that describe path of input file directory. It's 43 | used for matching and and parsing, which allows you to store parts of parsed 44 | input directory as variables. More details in [Syntax](#template-syntax) 45 | section. 46 | 47 | In case the input file directory will not match the `pattern`, it will be 48 | skipped and the [`directory`](#directory) parameter will be processed, if 49 | present. 50 | 51 | `output` defines a pattern that describe output directory path. It allows you to 52 | reuse variables parsed from input directory, like in example above. More details 53 | in [Syntax](#template-syntax) section. 54 | 55 | If you decide to use `template` parameter, bare in mind to avoid situation 56 | where output files will be saved in same directory structure as input file - 57 | [S3 event notification limitations](#s3-event-notification-limitations). 58 | 59 | ### Template syntax 60 | 61 | **Source**: [path-template](https://github.com/matsadler/path-template/blob/master/readme.md#template-syntax) 62 | 63 | The characters `:`, `*`, `(`, and `)` have special meanings. 64 | 65 | `:` indicates the following segment is the name of a variable 66 | `*` indicates the following segment is the splat/glob 67 | `(` starts an optional segment 68 | `)` ends an optional segment 69 | 70 | additionally `/` and `.` will start a new segment. 71 | 72 | ##### Static Segments 73 | 74 | "/foo/bar.baz" 75 | ^ ^ ^ 76 | | | Starts a segment, matching ".baz" 77 | | | 78 | | Starts a segment, matching "/bar" 79 | | 80 | Starts a segment, matching "/foo" 81 | 82 | ##### Variables 83 | 84 | "/foo/:bar.baz" 85 | ^ ^ ^ 86 | | | Starts a new segment, that matches ".baz" 87 | | | 88 | | Matches anything up to the start of the next segment, with the value 89 | | being stored in the "bar" parameter of the returned match object 90 | | 91 | Starts a segment, matching "/foo" 92 | 93 | ##### Splat/Glob 94 | 95 | "/foo/*bar" 96 | ^ ^ 97 | | Matches any number of segments, the values being stored as an array 98 | | in the "bar" parameter of the returned match object 99 | | 100 | Starts a segment, matching "/foo" 101 | 102 | ###### Anonymous Splat/Glob 103 | 104 | "/foo/*" 105 | ^ ^ 106 | | Matches any number of segments, the values will not appear in the 107 | | returned match object 108 | | 109 | Starts a segment, matching "/foo" 110 | 111 | ##### Optional Segments 112 | 113 | "/foo(/baz)/baz" 114 | ^ ^ ^^ 115 | | | |Starts a new segment, that matches "/baz" 116 | | | | 117 | | | Ends the optional segment 118 | | | 119 | | Starts an optional segment, this segment need not be in the path being 120 | | matched for the match to be successful 121 | | 122 | Starts a segment, matching "/foo" 123 | 124 | ### More examples 125 | 126 | Examples of `template` usage cases you can find in our 127 | [test files](../test/image-data.js). 128 | 129 | ## S3 event notification limitations 130 | 131 | S3 event notifications are limited to filter file events only by predefined 132 | `prefix` and `suffix`. This could be problematic in situation where you decide 133 | to store output files in the same directory structure as the input image. This 134 | could cause S3 to fire new event notification for each output image saved in 135 | that path. In extreme case this could lead to situation where Lambda 136 | functions are executed in never ending loop. But, we are prepared to prevent 137 | such incidents, or maybe I should rather say, we are prepared to minimise the 138 | potential damage. 139 | 140 | Each processed file is stored with additional 141 | `Metadata: { img-processed: true }`. Also, each input file that we process is 142 | checked against this `flag`, and if it's present, we will stop the processing 143 | flow with `"Object was already processed."` error message. 144 | -------------------------------------------------------------------------------- /lib/ImageData.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require("path"); 4 | const imageType = require("image-type"); 5 | const PathTemplate = require("path-template"); 6 | 7 | class ImageType { 8 | 9 | /** 10 | * Gets real image type from ImageData 11 | * 12 | * @constructor 13 | * @param ImageData image 14 | */ 15 | constructor(image) { 16 | const type = imageType(image.data); 17 | if ( type ) { 18 | this._ext = type.ext; 19 | this._mime = type.mime; 20 | } else { 21 | this._ext = path.extname(image.fileName).slice(1).toLowerCase(); 22 | this._mime = image.headers.ContentType; 23 | } 24 | } 25 | 26 | /** 27 | * Extension getter 28 | * 29 | * @public 30 | * @return String: "png", "jpg", etc... 31 | */ 32 | get ext() { 33 | return this._ext; 34 | } 35 | 36 | /** 37 | * Mime getter 38 | * 39 | * @public 40 | * @return String: "image/png", "image/jpeg", etc... 41 | */ 42 | get mime() { 43 | return this._mime; 44 | } 45 | } 46 | 47 | class ImageData { 48 | /** 49 | * Image data interface 50 | * 51 | * @constructor 52 | * @param String key 53 | * @param String name 54 | * @param String|Buffer data 55 | * @param Object headers 56 | * @param Object acl 57 | */ 58 | constructor(key, name, data, headers, acl) { 59 | this._fileName = key; 60 | this._bucketName = name; 61 | this._data = ( Buffer.isBuffer(data) ) ? data : new Buffer(data, "binary"); 62 | this._headers = Object.assign({}, headers); 63 | this._acl = acl; 64 | this._ext = path.extname(key); 65 | 66 | this._type = new ImageType(this); 67 | this._headers.ContentType = this._type.mime; 68 | } 69 | 70 | /** 71 | * Bucket name getter 72 | * 73 | * @public 74 | * @return String 75 | */ 76 | get bucketName() { 77 | return this._bucketName; 78 | } 79 | 80 | /** 81 | * Basename getter 82 | * 83 | * @public 84 | * @return String 85 | */ 86 | get baseName() { 87 | return path.basename(this._fileName); 88 | } 89 | 90 | /** 91 | * Dirname getter 92 | * 93 | * @public 94 | * @return String 95 | */ 96 | get dirName() { 97 | const dir = path.dirname(this._fileName); 98 | 99 | return ( dir === "." ) ? "" : dir; 100 | } 101 | 102 | /** 103 | * Filename getter 104 | * 105 | * @public 106 | * @return String 107 | */ 108 | get fileName() { 109 | return this._fileName; 110 | } 111 | 112 | /** 113 | * Image type getter 114 | * 115 | * @public 116 | * @return String 117 | */ 118 | get type() { 119 | return this._type; 120 | } 121 | 122 | /** 123 | * Image buffer getter 124 | * 125 | * @public 126 | * @return Buffer 127 | */ 128 | get data() { 129 | return this._data; 130 | } 131 | 132 | /** 133 | * Image headers getter 134 | * 135 | * @public 136 | * @return Object 137 | */ 138 | get headers() { 139 | return this._headers; 140 | } 141 | 142 | /** 143 | * Image acl getter 144 | * 145 | * @public 146 | * @return Object 147 | */ 148 | get acl() { 149 | return this._acl; 150 | } 151 | 152 | /** 153 | * Image extension getter 154 | * 155 | * @public 156 | * @return String 157 | */ 158 | get ext() { 159 | return this._ext; 160 | } 161 | 162 | /** 163 | * Combines dirName, filename, and directory (from options). 164 | * 165 | * @public 166 | * @param String directory (from options) 167 | * @param String filePrefix (from options) 168 | * @param String fileSuffix (from options) 169 | * @param Boolean keepExtension (from options) 170 | * @return String 171 | */ 172 | combineWithDirectory(output) { 173 | const prefix = output.prefix || ""; 174 | const suffix = output.suffix || ""; 175 | const fileName = path.parse(this.baseName).name; 176 | const extension = ( output.keepExtension ) 177 | ? this.ext 178 | : "." + this.type.ext; 179 | 180 | const template = output.template; 181 | if ( typeof template === "object" && template.pattern ) { 182 | const inputTemplate = PathTemplate.parse(template.pattern); 183 | const outputTemplate = PathTemplate.parse(template.output || ""); 184 | 185 | const match = PathTemplate.match(inputTemplate, this.dirName); 186 | if ( match ) { 187 | const outputPath = PathTemplate.format(outputTemplate, match); 188 | return path.join(outputPath, prefix + fileName + suffix + extension); 189 | } else { 190 | console.log( "Directory " + this.dirName + " didn't match template " + template.pattern ); 191 | } 192 | } 193 | 194 | const directory = output.directory; 195 | if ( typeof directory === "string" ) { 196 | // ./X , ../X , . , .. 197 | if ( directory.match(/^\.\.?\//) || directory.match(/^\.\.?$/) ) { 198 | return path.join(this.dirName, directory, prefix + fileName + suffix + extension); 199 | } else { 200 | return path.join(directory, prefix + fileName + suffix + extension); 201 | } 202 | } 203 | 204 | return path.join(this.dirName, prefix + fileName + suffix + extension); 205 | } 206 | } 207 | 208 | module.exports = ImageData; 209 | -------------------------------------------------------------------------------- /test/s3-file-system.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const S3FileSystem = require("../lib/S3FileSystem"); 4 | const ImageData = require("../lib/ImageData"); 5 | const test = require("ava"); 6 | const AWS = require("aws-sdk-mock"); 7 | const pify = require("pify"); 8 | const fs = require("fs"); 9 | const uploadedObjects = {}; 10 | 11 | let fixture; 12 | let fileSystem; 13 | 14 | test.before(async t => { 15 | fixture = await pify(fs.readFile)(`${__dirname}/fixture/fixture.jpg`); 16 | 17 | AWS.mock( "S3", "getObject", (params, callback) => { 18 | switch ( params.Key ) { 19 | case "processed.jpg": 20 | return callback( null, { Metadata: { "img-processed": "true" }, CacheControl: "cache-control" } ); 21 | case "empty-file.jpg": 22 | return callback( null, { ContentLength: 0, Metadata: {}, CacheControl: "cache-control" } ); 23 | case "network-error.jpg": 24 | return callback( "Simulated network error" ); 25 | default: 26 | return callback( null, { Body: fixture, Metadata: {}, CacheControl: "cache-control" } ); 27 | } 28 | }); 29 | AWS.mock( "S3", "putObject", (params, callback) => { 30 | uploadedObjects[params.Key] = params; 31 | switch ( params.Key ) { 32 | case "network-error.jpg": 33 | return callback( "Simulated network error" ); 34 | default: 35 | return callback( null ); 36 | } 37 | }); 38 | AWS.mock( "S3", "deleteObject", (params, callback) => { 39 | if ( uploadedObjects.hasOwnProperty(params.Key) ) { 40 | delete( uploadedObjects[params.Key] ); 41 | } 42 | switch ( params.Key ) { 43 | case "network-error.jpg": 44 | return callback( "Simulated network error" ); 45 | default: 46 | return callback( null ); 47 | } 48 | }); 49 | 50 | fileSystem = new S3FileSystem(); 51 | }); 52 | 53 | test.after(async t => { 54 | AWS.restore( "S3" ); 55 | }); 56 | 57 | test("Create ImageData from valid image file", async t => { 58 | const image = await fileSystem.getObject( "bucket", "regular.jpg", "private" ); 59 | 60 | t.is( image.acl, "private" ); 61 | t.is( image.fileName, "regular.jpg" ); 62 | t.is( image.bucketName, "bucket" ); 63 | t.is( image.data, fixture ); 64 | t.is( image.headers.ContentType, "image/jpeg" ); 65 | t.is( image.headers.CacheControl, "cache-control" ); 66 | }); 67 | 68 | test("Fail on creating ImageData from image already processed", t => { 69 | return fileSystem.getObject("bucket", "processed.jpg", "acl").then((value) => { 70 | t.fail(); 71 | }, (reason) => { 72 | t.is(reason, "Object was already processed.") 73 | }); 74 | }); 75 | 76 | test("Fail on creating ImageData from empty image or directory", t => { 77 | return fileSystem.getObject("bucket", "empty-file.jpg", "acl").then((value) => { 78 | t.fail(); 79 | }, (reason) => { 80 | t.is(reason, "Empty file or directory.") 81 | }); 82 | }); 83 | 84 | test("Fail on creating ImageData because of network error", t => { 85 | return fileSystem.getObject("bucket", "network-error.jpg", "acl").then((value) => { 86 | t.fail(); 87 | }, (reason) => { 88 | t.is(reason, "S3 getObject failed: Simulated network error") 89 | }) 90 | }); 91 | 92 | test("Push valid ImageData object to S3", t => { 93 | const image = new ImageData("regular.jpg", "fixture", fixture, {}, "private"); 94 | 95 | return fileSystem.putObject(image, {}).then(() => t.pass()); 96 | }); 97 | 98 | test("Fail on network error while pushing ImageData object to S3", t => { 99 | const image = new ImageData("network-error.jpg", "fixture", fixture, {}, "private"); 100 | 101 | return fileSystem.putObject(image, {}).then((value) => { 102 | t.fail(); 103 | }, (reason) => { 104 | t.is(reason, "Simulated network error") 105 | }) 106 | }); 107 | 108 | test("Delete valid ImageData object from S3", t => { 109 | const image = new ImageData("regular.jpg", "fixture", fixture, {}, "private"); 110 | 111 | return fileSystem.deleteObject(image).then(() => t.pass()); 112 | }); 113 | 114 | test("Fail on network error while deleting ImageData object to S3", t => { 115 | const image = new ImageData("network-error.jpg", "fixture", fixture, {}, "private"); 116 | 117 | return fileSystem.deleteObject(image).then((value) => { 118 | t.fail(); 119 | }, (reason) => { 120 | t.is(reason, "Simulated network error") 121 | }) 122 | }); 123 | 124 | test("Options don't contain cacheControl", async t => { 125 | const image = new ImageData("regular01.jpg", "fixture", fixture, { "CacheControl": "original-cache-control" }, "private"); 126 | 127 | fileSystem.putObject(image, { "cacheControl": undefined }); 128 | const obj = uploadedObjects["regular01.jpg"]; 129 | t.is(obj.CacheControl, "original-cache-control"); 130 | }); 131 | 132 | test("Options contain cacheControl", async t => { 133 | const image = new ImageData("regular02.jpg", "fixture", fixture, { "CacheControl": "original-cache-control" }, "private"); 134 | 135 | fileSystem.putObject(image, { "cacheControl": "options-cache-control" }); 136 | const obj = uploadedObjects["regular02.jpg"]; 137 | t.is(obj.CacheControl, "options-cache-control"); 138 | }); 139 | 140 | test("Options contain null cacheControl", async t => { 141 | const image = new ImageData("regular03.jpg", "fixture", fixture, { "CacheControl": "original-cache-control" }, "private"); 142 | 143 | fileSystem.putObject(image, { "cacheControl": null }); 144 | const obj = uploadedObjects["regular03.jpg"]; 145 | t.is(obj.CacheControl, null); 146 | }); 147 | -------------------------------------------------------------------------------- /lib/ImageProcessor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const ImageArchiver = require("./ImageArchiver"); 4 | const ImageResizer = require("./ImageResizer"); 5 | const ImageReducer = require("./ImageReducer"); 6 | 7 | class ImageProcessor { 8 | 9 | /** 10 | * Image processor 11 | * management resize/reduce image list by configration, 12 | * and pipe AWS Lambda's event/context 13 | * 14 | * @constructor 15 | * @param Object fileSystem 16 | * @param Object s3Object 17 | */ 18 | constructor(fileSystem, s3Object) { 19 | this.fileSystem = fileSystem; 20 | this.s3Object = s3Object; 21 | } 22 | 23 | /** 24 | * Run the process 25 | * 26 | * @public 27 | * @param Config config 28 | */ 29 | run(config) { 30 | if ( ! config.get("bucket") ) { 31 | config.set("bucket", this.s3Object.bucket.name); 32 | } 33 | 34 | return this.fileSystem.getObject( 35 | this.s3Object.bucket.name, 36 | decodeURIComponent(this.s3Object.object.key.replace(/\+/g, ' ')) 37 | ) 38 | .then((imageData) => this.processImage(imageData, config)); 39 | } 40 | 41 | /** 42 | * Processing image 43 | * 44 | * @public 45 | * @param ImageData imageData 46 | * @param Config config 47 | * @return Promise 48 | */ 49 | processImage(imageData, config) { 50 | const acl = config.get("acl"); 51 | const cacheControl = config.get("cacheControl"); 52 | const bucket = config.get("bucket"); 53 | const jpegOptimizer = config.get("jpegOptimizer", "mozjpeg"); 54 | const optimizerOptions = config.get("optimizers", {}); 55 | 56 | let promise = Promise.resolve(); 57 | let processedImages = 0; 58 | 59 | if ( config.exists("backup") ) { 60 | const backup = config.get("backup"); 61 | 62 | backup.acl = backup.acl || acl; 63 | backup.cacheControl = ( backup.cacheControl !== undefined ) ? backup.cacheControl : cacheControl; 64 | backup.bucket = backup.bucket || bucket; 65 | backup.keepExtension = backup.keepExtension || config.get("keepExtension") || false; 66 | 67 | promise = promise 68 | .then(() => this.execBackupImage(backup, imageData)) 69 | .then((image) => this.fileSystem.putObject(image, backup)) 70 | .then(() => ( backup.move === true ) ? this.fileSystem.deleteObject(imageData) : Promise.resolve()) 71 | .then(() => Promise.resolve(++processedImages)); 72 | } 73 | 74 | if ( config.exists("reduce") ) { 75 | const reduce = config.get("reduce"); 76 | 77 | reduce.acl = reduce.acl || acl; 78 | reduce.cacheControl = ( reduce.cacheControl !== undefined ) ? reduce.cacheControl : cacheControl; 79 | reduce.bucket = reduce.bucket || bucket; 80 | reduce.jpegOptimizer = reduce.jpegOptimizer || jpegOptimizer; 81 | reduce.optimizerOptions = optimizerOptions; 82 | reduce.keepExtension = reduce.keepExtension || config.get("keepExtension") || false; 83 | 84 | promise = promise 85 | .then(() => this.execReduceImage(reduce, imageData)) 86 | .then((image) => this.fileSystem.putObject(image, reduce)) 87 | .then(() => Promise.resolve(++processedImages)); 88 | } 89 | 90 | config.get("resizes", []).filter((resize) => { 91 | return resize.size && 92 | imageData.fileName.indexOf(resize.directory) !== 0; // don't process images in the output folder 93 | }).forEach((resize) => { 94 | resize.acl = resize.acl || acl; 95 | resize.cacheControl = ( resize.cacheControl !== undefined ) ? resize.cacheControl : cacheControl; 96 | resize.bucket = resize.bucket || bucket; 97 | resize.jpegOptimizer = resize.jpegOptimizer || jpegOptimizer; 98 | resize.optimizerOptions = optimizerOptions; 99 | resize.keepExtension = resize.keepExtension || config.get("keepExtension") || false; 100 | 101 | promise = promise 102 | .then(() => this.execResizeImage(resize, imageData)) 103 | .then((image) => this.fileSystem.putObject(image, resize)) 104 | .then(() => Promise.resolve(++processedImages)); 105 | }); 106 | 107 | return promise; 108 | } 109 | 110 | /** 111 | * Execute resize image 112 | * 113 | * @public 114 | * @param Object option 115 | * @param imageData imageData 116 | * @return Promise 117 | */ 118 | execResizeImage(option, imageData) { 119 | const resizer = new ImageResizer(option); 120 | 121 | return resizer.exec(imageData) 122 | .then((resizedImage) => { 123 | const reducer = new ImageReducer(option); 124 | 125 | return reducer.exec(resizedImage); 126 | }); 127 | } 128 | 129 | /** 130 | * Execute reduce image 131 | * 132 | * @public 133 | * @param Object option 134 | * @param ImageData imageData 135 | * @return Promise 136 | */ 137 | execReduceImage(option, imageData) { 138 | const reducer = new ImageReducer(option); 139 | 140 | return reducer.exec(imageData); 141 | } 142 | 143 | /** 144 | * Execute image backup 145 | * 146 | * @public 147 | * @param Object option 148 | * @param ImageData imageData 149 | * @return Promise 150 | */ 151 | execBackupImage(option, imageData) { 152 | const archiver = new ImageArchiver(option); 153 | 154 | return archiver.exec(imageData); 155 | } 156 | } 157 | 158 | module.exports = ImageProcessor; 159 | -------------------------------------------------------------------------------- /bin/configtest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require("path"); 4 | var fs = require("fs"); 5 | var configPath = path.resolve(__dirname, "../config.json"); 6 | var projectDir = path.resolve(__dirname, ".."); 7 | var warning = []; 8 | var fatals = []; 9 | var stdout = process.stdout; 10 | var config; 11 | var buffer; 12 | 13 | var red = '\u001b[31m'; 14 | var green = '\u001b[32m'; 15 | var magenta = '\u001b[35m'; 16 | var yellow = '\u001b[33m'; 17 | var reset = '\u001b[0m'; 18 | 19 | (function() { 20 | 21 | console.log(green + "==========================================" + reset); 22 | console.log(green + " AWS-Lambda-Image Configuration Checker" + reset); 23 | console.log(green + "==========================================" + reset); 24 | stdout.write("\r\n"); 25 | 26 | if ( ! fs.existsSync(configPath) ) { 27 | stdout.write(red + "[Error] Config file not exists.\r\n" + reset); 28 | stdout.write(red + "Did you put a config file at " + projectDir + "/config.json?\r\n" + reset); 29 | return; 30 | } 31 | 32 | buffer = fs.readFileSync(configPath, {encoding: "utf8"}); 33 | 34 | stdout.write(magenta + "Configuration status: " + reset); 35 | try { 36 | config = JSON.parse(buffer); 37 | stdout.write(" OK\r\n"); 38 | } catch ( e ) { 39 | stdout.write("\r\n"); 40 | stdout.write(red + "Error!\r\n" + reset); 41 | console.log(e.message); 42 | process.exit(1); 43 | } 44 | 45 | var jpegOptimizer = config.jpegOptimizer; 46 | stdout.write(magenta + "Global Optimizer: " + reset + (jpegOptimizer || "Not set") + "\r\n"); 47 | 48 | var bucket = config.bucket; 49 | stdout.write(magenta + "Destination bucket: " + reset + (bucket || "[Same bucket]") + "\r\n"); 50 | 51 | var acl = config.acl; 52 | stdout.write(magenta + "Global S3 ACL: " + reset + (acl || "Not set") + "\r\n"); 53 | 54 | var cacheControl = config.cacheControl; 55 | stdout.write(magenta + "Global S3 CacheControl: " + reset + (cacheControl || "Not set") + "\r\n"); 56 | 57 | var keepExtension = config.keepExtension; 58 | stdout.write(magenta + "Keep original extension: " + reset + (keepExtension ? "yes": "no") + "\r\n"); 59 | 60 | var optimizer = config.optimizers || {}; 61 | stdout.write("\r\n"); 62 | stdout.write("Override Optimizer configuration\r\n"); 63 | stdout.write("--------------------------------\r\n"); 64 | stdout.write(magenta + " pngquant : " + reset + (optimizer.pngquant ? formatArray(optimizer.pngquant) : "Not set") + "\r\n"); 65 | stdout.write(magenta + " jpegoptim : " + reset + (optimizer.jpegoptim ? formatArray(optimizer.jpegoptim) : "Not set") + "\r\n"); 66 | stdout.write(magenta + " mozjpeg : " + reset + (optimizer.mozjpeg ? formatArray(optimizer.mozjpeg) : "Not set") + "\r\n"); 67 | stdout.write(magenta + " gifsicle : " + reset + (optimizer.gifsicle ? formatArray(optimizer.gifsicle) : "Not set") + "\r\n"); 68 | 69 | stdout.write("\r\n"); 70 | stdout.write("Backup image configuration\r\n"); 71 | stdout.write("--------------------------------\r\n"); 72 | if ( "backup" in config ) { 73 | var backup = config.backup || {}; 74 | validateDestination(stdout, bucket, backup.bucket, backup.directory, backup.template); 75 | validatePrefixAndSuffix(stdout, backup.prefix, backup.suffix); 76 | validateAcl(stdout, acl, backup.acl); 77 | validateCacheControl(stdout, cacheControl, backup.cacheControl); 78 | validateKeepExtension(stdout, keepExtension, backup.keepExtension); 79 | } else { 80 | stdout.write("Backup option is not supplied, skip it.\r\n"); 81 | } 82 | 83 | stdout.write("\r\n"); 84 | stdout.write("Reduce image configuration\r\n"); 85 | stdout.write("--------------------------------\r\n"); 86 | if ( "reduce" in config ) { 87 | var reduce = config.reduce || {}; 88 | validateQuality(stdout, reduce.quality); 89 | validateOptimizer(stdout, reduce.jpegOptimizer || jpegOptimizer); 90 | validateDestination(stdout, bucket, reduce.bucket, reduce.directory, reduce.template); 91 | validatePrefixAndSuffix(stdout, reduce.prefix, reduce.suffix); 92 | validateAcl(stdout, acl, reduce.acl); 93 | validateCacheControl(stdout, cacheControl, reduce.cacheControl); 94 | validateKeepExtension(stdout, keepExtension, reduce.keepExtension); 95 | } else { 96 | stdout.write("Reduce option is not supplied, skip it.\r\n"); 97 | } 98 | 99 | stdout.write("\r\n"); 100 | stdout.write("Resize image configuration\r\n"); 101 | stdout.write("--------------------------------\r\n"); 102 | var resizes = config.resizes || []; 103 | stdout.write(magenta + " Number of resize images: " + reset + resizes.length + "\r\n"); 104 | stdout.write("\r\n"); 105 | resizes.forEach(function(resize, index) { 106 | stdout.write(" Resize image " + (index + 1) + " ( of " + resizes.length + " )\r\n"); 107 | stdout.write(" --------------------------------\r\n"); 108 | validateSize(stdout, resize.size); 109 | validateFormat(stdout, resize.format); 110 | validateQuality(stdout, resize.quality); 111 | validateCrop(stdout, resize.crop); 112 | validateOptimizer(stdout, resize.jpegOptimizer || jpegOptimizer); 113 | validateDestination(stdout, bucket, resize.bucket, resize.directory, resize.template); 114 | validatePrefixAndSuffix(stdout, resize.prefix, resize.suffix); 115 | validateAcl(stdout, acl, resize.acl); 116 | validateCacheControl(stdout, cacheControl, resize.cacheControl); 117 | validateKeepExtension(stdout, keepExtension, resize.keepExtension); 118 | stdout.write("\r\n"); 119 | }); 120 | 121 | stdout.write("\r\n"); 122 | stdout.write(green + "Configuration check finished.\r\n" + reset + "\r\n"); 123 | if ( fatals.length === 0 && warning.length === 0 ) { 124 | stdout.write(green + "Your configuration is green!\r\n" + reset); 125 | } else { 126 | new Set(fatals).forEach(function(f) { 127 | stdout.write(red + "[Fatal] " + f + "\r\n" + reset); 128 | }); 129 | new Set(warning).forEach(function(n) { 130 | stdout.write(red + "[Warning] " + n + "\r\n" + reset); 131 | }); 132 | } 133 | if (fatals.length !== 0) { 134 | process.exit(1); 135 | } 136 | 137 | function formatArray(ary) { 138 | return `[${ary.map(v => `"${v}"`).join(", ")}]`; 139 | } 140 | 141 | function validateSize(stdout, size) { 142 | var color = reset; 143 | if ( ! size ) { 144 | fatals.push("Resize destination size must be supplied"); 145 | color = red; 146 | } else if ( isNaN(parseInt(size, 10)) ) { 147 | fatals.push("Resize destination size must be a number"); 148 | color = red; 149 | } 150 | stdout.write(magenta + " Size: " + color + size + reset + "\r\n"); 151 | } 152 | 153 | function validateQuality(stdout, quality) { 154 | if ( quality ) { 155 | var color = reset; 156 | if ( isNaN(parseInt(quality, 10)) || quality < 0 || quality > 100 ) { 157 | fatals.push("Invalid value of 'quality' option. It should be a number in range 0-100."); 158 | color = red; 159 | } 160 | stdout.write(magenta + " Image Quality: " + color + quality + " (JPG Only)" + reset + "\r\n"); 161 | } 162 | } 163 | 164 | function validateOptimizer(stdout, optimizer) { 165 | var color = reset; 166 | if ( ["mozjpeg", "jpegoptim", undefined].indexOf(optimizer) === -1 ) { 167 | warning.push("Optimizer is invalid. It accepts 'jpegoptim', 'mozjpeg' or undefined only."); 168 | color = red; 169 | } 170 | 171 | var optimizers; 172 | if ( ["mozjpeg", "jpegoptim"].indexOf(optimizer) !== -1 ) { 173 | optimizers = optimizer + " (JPG Only)"; 174 | } else { 175 | optimizers = "default (JPG Only)"; 176 | } 177 | 178 | stdout.write(magenta + " Optimizer: " + color + optimizers + reset + "\r\n"); 179 | } 180 | 181 | function validateFormat(stdout, format) { 182 | if ( format ) { 183 | stdout.write(magenta + " Convert: " + reset + format + "\r\n"); 184 | } 185 | } 186 | 187 | function validateDestination(stdout, globalBucket, bucket, directory, template) { 188 | var color = reset; 189 | if ( ! bucket && ! globalBucket && (! directory || /^\.\//.test(directory)) && (! template || ! template.pattern)) { 190 | warning.push(" Saving image to the same or relative directory may cause infinite Lambda process loop."); 191 | color = red; 192 | } 193 | 194 | stdout.write( magenta + " Save bucket: " + color ); 195 | stdout.write( bucket ? bucket : globalBucket ? globalBucket : "[Same bucket]"); 196 | stdout.write("\r\n"); 197 | stdout.write (magenta + " Save directory: " + color ); 198 | if ( directory ) { 199 | stdout.write(directory); 200 | stdout.write( /^\.\.?/.test(directory) ? " [Relative]" : ""); 201 | } else if ( template && template.pattern ) { 202 | stdout.write(template.output || "/"); 203 | stdout.write(" [Pattern]"); 204 | } else { 205 | stdout.write("[Same directory]"); 206 | } 207 | stdout.write(reset + "\r\n"); 208 | } 209 | 210 | function validatePrefixAndSuffix(stdout, prefix, suffix) { 211 | if ( prefix ) { 212 | stdout.write(magenta + " Filename Prefix: " + reset + prefix + "\r\n"); 213 | } 214 | if ( suffix ) { 215 | stdout.write(magenta + " Filename Suffix: " + reset + suffix + "\r\n"); 216 | } 217 | } 218 | 219 | function validateAcl(stdout, globalAcl, acl) { 220 | stdout.write(magenta + " S3 ACL: " + reset); 221 | stdout.write(acl ? acl : globalAcl ? "[Global ACL]" : "[Source ACL]"); 222 | stdout.write("\r\n"); 223 | } 224 | 225 | function validateCacheControl(stdout, globalCacheControl, cacheControl) { 226 | stdout.write(magenta + " S3 CacheControl: " + reset); 227 | stdout.write((cacheControl !== undefined) ? (cacheControl || "null") : globalCacheControl ? "[Global CacheControl]" : "[Source CacheControl]"); 228 | stdout.write("\r\n"); 229 | } 230 | 231 | function validateKeepExtension(stdout, globalKeepExtension, keepExtension) { 232 | var enable = keepExtension || globalKeepExtension || false; 233 | stdout.write(magenta + " Keep Extension: " + reset); 234 | stdout.write(enable ? "yes" : "no"); 235 | stdout.write("\r\n"); 236 | } 237 | 238 | function validateCrop(stdout, cropOption) { 239 | if ( ! cropOption ) { 240 | return; 241 | } 242 | const cropSpec = /(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(%)?/; 243 | if ( ! cropSpec.test(cropOption) ) { 244 | fatals.push("Resize crop option is invalid for expected spec: " + cropOption); 245 | } 246 | stdout.write(magenta + " Crop: " + reset); 247 | stdout.write(cropOption); 248 | stdout.write("\r\n"); 249 | } 250 | 251 | })(); 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-lambda-image 2 | 3 | [![Build Status](https://travis-ci.org/ysugimoto/aws-lambda-image.svg?branch=master)](https://travis-ci.org/ysugimoto/aws-lambda-image) 4 | [![Code Climate](https://codeclimate.com/github/ysugimoto/aws-lambda-image/badges/gpa.svg)](https://codeclimate.com/github/ysugimoto/aws-lambda-image) 5 | [![Coverage Status](https://coveralls.io/repos/github/ysugimoto/aws-lambda-image/badge.svg?branch=master)](https://coveralls.io/github/ysugimoto/aws-lambda-image?branch=master) 6 | [![npm version](https://badge.fury.io/js/aws-lambda-image.svg)](https://badge.fury.io/js/aws-lambda-image) 7 | [![Join the chat at https://gitter.im/aws-lambda-image](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/aws-lambda-image?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | 9 | An AWS Lambda Function to resize/reduce images automatically. When an image is 10 | put on AWS S3 bucket, this package will resize/reduce it and put to S3. 11 | 12 | ## Requirements 13 | 14 | - Node.js ( AWS Lambda supports versions of **8.10** or later ) 15 | 16 | ### Important Notice 17 | 18 | From `nodejs10.x`, AWS Lambda doesn't bundle `ImageMagick` and image related libraries. 19 | 20 | https://forums.aws.amazon.com/thread.jspa?messageID=906619&tstart=0 21 | 22 | Therefore, if you'd deploy with `nodejs10.x` runtime (but we prefer and default as it), it needs to install AWS Lambda Layer with this function. 23 | This project can support it automatically, see [LAYERS](https://github.com/ysugimoto/aws-lambda-image/blob/master/doc/LAYERS.md) in detail. 24 | 25 | ## Preparation 26 | 27 | Clone this repository and install dependencies: 28 | 29 | ```bash 30 | git clone git@github.com:ysugimoto/aws-lambda-image.git 31 | cd aws-lambda-image 32 | npm install . 33 | ``` 34 | 35 | When upload to AWS Lambda, the project will bundle only needed files - no dev 36 | dependencies will be included. 37 | 38 | ## Configuration 39 | 40 | Configuration file you will find under the name `config.json` in project root. 41 | It's copy of our example file `config.json.sample`. More or less it looks like: 42 | 43 | ```json 44 | { 45 | "bucket": "your-destination-bucket", 46 | "backup": { 47 | "directory": "./original" 48 | }, 49 | "reduce": { 50 | "directory": "./reduced", 51 | "prefix": "reduced-", 52 | "quality": 90, 53 | "acl": "public-read", 54 | "cacheControl": "public, max-age=31536000" 55 | }, 56 | "resizes": [ 57 | { 58 | "size": 300, 59 | "directory": "./resized/small", 60 | "prefix": "resized-", 61 | "cacheControl": null 62 | }, 63 | { 64 | "size": 450, 65 | "directory": "./resized/medium", 66 | "suffix": "_medium" 67 | }, 68 | { 69 | "size": "600x600^", 70 | "gravity": "Center", 71 | "crop": "600x600", 72 | "directory": "./resized/cropped-to-square" 73 | }, 74 | { 75 | "size": 600, 76 | "directory": "./resized/600-jpeg", 77 | "format": "jpg", 78 | "background": "white" 79 | }, 80 | { 81 | "size": 900, 82 | "directory": "./resized/large", 83 | "quality": 90 84 | } 85 | ] 86 | } 87 | ``` 88 | 89 | ### Configuration Parameters 90 | 91 | | name | field | type | description | 92 | |:-------------:|:-------------:|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------| 93 | | bucket | - | String | Destination bucket name at S3 to put processed image. If not supplied, it will use same bucket of event source. | 94 | | jpegOptimizer | - | String | Determine optimiser that should be used `mozjpeg` (default) or `jpegoptim` ( only `JPG` ). | 95 | | acl | - | String | Permission of S3 object. [See AWS ACL documentation](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property). | 96 | | cacheControl | - | String | Cache-Control of S3 object. If not specified, defaults to original image's Cache-Control. | 97 | | keepExtension | - | Boolean | Global setting fo keeping original extension. If `true`, program keeps orignal file extension. otherwise use strict extension eg JPG,jpeg -> jpg | 98 | | backup | - | Object | Backup original file setting. | 99 | | | bucket | String | Destination bucket to override. If not supplied, it will use `bucket` setting. | 100 | | | directory | String | Image directory path. Supports relative and absolute paths. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#directory) | 101 | | | template | Object | Map representing pattern substitution pair. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#template) | 102 | | | prefix | String | Prepend filename prefix if supplied. | 103 | | | suffix | String | Append filename suffix if supplied. | 104 | | | acl | String | Permission of S3 object. [See AWS ACL documentation](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property). | 105 | | | cacheControl | String | Cache-Control of S3 object. If not specified, defaults to original image's Cache-Control. | 106 | | | keepExtension | Boolean | If `true`, program keeps orignal file extension. otherwise, use strict extension eg JPG,jpeg -> jpg | 107 | | | move | Boolean | If `true`, an original uploaded file will delete from Bucket after completion. | 108 | | reduce | - | Object | Reduce setting following fields. | 109 | | | quality | Number | Determine reduced image quality ( only `JPG` ). | 110 | | | jpegOptimizer | String | Determine optimiser that should be used `mozjpeg` (default) or `jpegoptim` ( only `JPG` ). | 111 | | | bucket | String | Destination bucket to override. If not supplied, it will use `bucket` setting. | 112 | | | directory | String | Image directory path. Supports relative and absolute paths. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#directory) | 113 | | | template | Object | Map representing pattern substitution pair. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#template) | 114 | | | prefix | String | Prepend filename prefix if supplied. | 115 | | | suffix | String | Append filename suffix if supplied. | 116 | | | acl | String | Permission of S3 object. [See AWS ACL documentation](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property). | 117 | | | cacheControl | String | Cache-Control of S3 object. If not specified, defaults to original image's Cache-Control. | 118 | | | keepExtension | Boolean | If `true`, program keeps orignal file extension. otherwise, use strict extension eg JPG,jpeg -> jpg | 119 | | resize | - | Array | Resize setting list of following fields. | 120 | | | size | String | Image dimensions. [See ImageMagick geometry documentation](http://imagemagick.org/script/command-line-processing.php#geometry). | 121 | | | format | String | Image format override. If not supplied, it will leave the image in original format. | 122 | | | crop | String | Dimensions to crop the image. [See ImageMagick crop documentation](http://imagemagick.org/script/command-line-options.php#crop). | 123 | | | gravity | String | Changes how `size` and `crop`. [See ImageMagick gravity documentation](http://imagemagick.org/script/command-line-options.php#gravity). | 124 | | | quality | Number | Determine reduced image quality ( forces format `JPG` ). | 125 | | | jpegOptimizer | String | Determine optimiser that should be used `mozjpeg` (default) or `jpegoptim` ( only `JPG` ). | 126 | | | orientation | Boolean | Auto orientation if value is `true`. | 127 | | | bucket | String | Destination bucket to override. If not supplied, it will use `bucket` setting. | 128 | | | directory | String | Image directory path. Supports relative and absolute paths. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#directory) | 129 | | | template | Object | Map representing pattern substitution pair. Mode details in [DIRECTORY.md](doc/DIRECTORY.md/#template) | 130 | | | prefix | String | Prepend filename prefix if supplied. | 131 | | | suffix | String | Append filename suffix if supplied. | 132 | | | acl | String | Permission of S3 object. [See AWS ACL documentation](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property). | 133 | | | cacheControl | String | Cache-Control of S3 object. If not specified, defaults to original image's Cache-Control. | 134 | | | keepExtension | Boolean | If `true`, program keeps orignal file extension. otherwise, use strict extension eg JPG,jpeg -> jpg | 135 | | optimizers | - | Object | Definitions for override the each Optimizers command arguments. | 136 | | | pngquant | Array | `Pngquant` command arguments. Default is `["--speed=1", "256"]`. | 137 | | | jpegoptim | Array | `Jpegoptim` command arguments. Default is `["-s", "--all-progressive"]`. | 138 | | | mozjpeg | Array | `Mozjpeg` command arguments. Default is `["-optimize", "-progressive"]`. | 139 | | | gifsicle | Array | `Gifsicle` command arguments. Default is `["--optimize"]`. | 140 | 141 | Note that the `optmizers` option will **force** override its command arguments, so if you define these configurations, we don't care any more about how optimizer works. 142 | 143 | ### Testing Configuration 144 | 145 | If you want to check how your configuration will work, you can use: 146 | 147 | ```bash 148 | npm run test-config 149 | ``` 150 | 151 | ## Installation 152 | 153 | ### Setup 154 | 155 | To use the automated deployment scripts you will need to have 156 | [aws-cli installed and configured](http://docs.aws.amazon.com/cli/latest/userguide/installing.html). 157 | 158 | Deployment scripts are pre-configured to use some default values for the Lambda 159 | configuration. I you want to change any of those just use: 160 | 161 | ```bash 162 | npm config set aws-lambda-image:profile default 163 | npm config set aws-lambda-image:region eu-west-1 164 | npm config set aws-lambda-image:memory 1280 165 | npm config set aws-lambda-image:timeout 5 166 | npm config set aws-lambda-image:name lambda-function-name 167 | npm config set aws-lambda-image:role lambda-execution-role 168 | ``` 169 | 170 | Note that `aws-lambda-image:name` and `aws-lambda-image:role` are optional. 171 | If you want to change lambda function name or execution role, type above commands before deploy. 172 | 173 | And make sure AWS Lambda Layer has installed in your account/region. 174 | See [LAYERS](https://github.com/ysugimoto/aws-lambda-image/blob/master/doc/LAYERS.md) for instructions. 175 | 176 | ### Deployment 177 | 178 | Command below will deploy the Lambda function on AWS, together with setting up 179 | roles and policies. 180 | 181 | ```bash 182 | npm run deploy 183 | ``` 184 | 185 | *Notice*: Because there are some limitations in `Claudia.js` support for 186 | policies, which could lead to issues with `Access Denied` when processing 187 | images from one bucket and saving them to another, we have decided to introduce 188 | support for custom policies. 189 | 190 | #### Custom policies 191 | 192 | Policies which should be installed together with our Lambda function are stored 193 | in `policies/` directory. We keep there policy that grants access to all 194 | buckets, which is preventing possible errors with `Access Denied` described 195 | above. If you have any security-related concerns, feel free to change the: 196 | 197 | ```json 198 | "Resource": [ 199 | "*" 200 | ] 201 | ``` 202 | 203 | in the `policies/s3-bucket-full-access.json` to something more restrictive, 204 | like: 205 | 206 | ```json 207 | "Resource": [ 208 | "arn:aws:s3:::destination-bucket-name/*" 209 | ] 210 | ``` 211 | 212 | Just keep in mind, that you need to make those changes before you do the 213 | deployment. 214 | 215 | ### Adding S3 event handlers 216 | 217 | To complete installation process you will need to take one more action. It will 218 | allow you to install S3 Bucket event handler, which will send information about 219 | all uploaded images directly to your Lambda function. 220 | 221 | ```bash 222 | npm run add-s3-handler --s3_bucket="your-bucket-name" --s3_prefix="directory/" --s3_suffix=".jpg" 223 | ``` 224 | 225 | You are able to install multiple handlers per Bucket. So, to add handler for PNG 226 | files you just need to re-run above command with different _suffix_, ie: 227 | 228 | ```bash 229 | npm run add-s3-handler --s3_bucket="your-bucket-name" --s3_prefix="directory/" --s3_suffix=".png" 230 | ``` 231 | 232 | ### Adding SNS message handlers 233 | 234 | As an addition, you can also setup and SNS message handler in case you would 235 | like to process S3 events over an SNS topic. 236 | 237 | ```bash 238 | npm run add-sns-handler --sns_topic="arn:of:SNS:topic" 239 | ``` 240 | 241 | ### Updating 242 | 243 | To update Lambda with you latest code just use command below. Script will build 244 | new package and automatically publish it on AWS. 245 | 246 | ```bash 247 | npm run update 248 | ``` 249 | 250 | ### More 251 | 252 | For more scripts look into [package.json](package.json). 253 | 254 | ## Complete / Failed hooks 255 | 256 | You can handle resize/reduce/backup process on success/error result on 257 | `index.js`. `ImageProcessor::run` will return `Promise` object, run your 258 | original code: 259 | 260 | ```javascript 261 | processor.run(config) 262 | .then(function(proceedImages)) { 263 | 264 | // Success case: 265 | // proceedImages is list of ImageData instance on you configuration 266 | 267 | /* your code here */ 268 | 269 | // notify lambda 270 | context.succeed("OK, numbers of " + proceedImages.length + " images has proceeded."); 271 | }) 272 | .catch(function(messages) { 273 | 274 | // Failed case: 275 | // messages is list of string on error messages 276 | 277 | /* your code here */ 278 | 279 | // notify lambda 280 | context.fail("Woops, image process failed: " + messages); 281 | }); 282 | ``` 283 | 284 | ## Image resize 285 | 286 | - `ImageMagick` (installed on AWS Lambda) 287 | 288 | ## Image reduce 289 | 290 | - [cjpeg](https://github.com/mozilla/mozjpeg) 291 | - [jpegoptim](https://github.com/tjko/jpegoptim) 292 | - [pngquant](https://pngquant.org/) 293 | - [gifsicle](https://github.com/kohler/gifsicle) 294 | 295 | ## License 296 | 297 | MIT License. 298 | 299 | ## Author 300 | 301 | Yoshiaki Sugimoto 302 | 303 | ## Image credits 304 | 305 | Thanks for testing fixture images: 306 | 307 | - [pngimg](http://pngimg.com/) 308 | - [pakutaso](https://www.pakutaso.com/) 309 | --------------------------------------------------------------------------------