diff --git a/README.md b/README.md index 32385a3..90f3a4f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,19 @@ AWS S3 Image Uploader [![Node version](https://img.shields.io/node/v/s3-uploader.svg "Node version")](https://www.npmjs.com/package/s3-uploader) [![Dependency status](https://img.shields.io/david/turistforeningen/node-s3-uploader.svg "Dependency status")](https://david-dm.org/turistforeningen/node-s3-uploader) -Flexible and efficient resize, rename, and upload images to Amazon S3 disk -storage. Uses the official [AWS Node SDK](http://aws.amazon.com/sdkfornodejs/) -and [GM](https://github.com/aheckmann/gm) for image processing. +Flexible and efficient image resize, rename, and upload to Amazon S3 disk +storage. Uses the official [AWS Node SDK](http://aws.amazon.com/sdkfornodejs/), +and [im-resize](https://github.com/Turistforeningen/node-im-resize) and +[im-metadata](https://github.com/Turistforeningen/node-im-metadata) for image +processing. [![Join the chat at https://gitter.im/Turistforeningen/node-s3-uploader](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Turistforeningen/node-s3-uploader) +## Changelog + +All changes are documentated on the [releases page](https://github.com/Turistforeningen/node-s3-uploader/releases). +Changes for latest release can be [found here](https://github.com/Turistforeningen/node-s3-uploader/releases/latest). + ## Install ``` @@ -37,16 +44,18 @@ var Upload = require('s3-uploader'); * **string** `awsBucketName` - name of Amazon S3 bucket * **object** `opts` - global upload options - * **number** `resizeQuality` - thumbnail resize quallity (**default** `70`) + * **object** `cleanup` + * **boolean** `original` - remove original image after successful upload (**default**: `false`) + * **boolean** `versions` - remove thumbnail versions after sucessful upload (**default**: `false`) + * **boolean** `returnExif` - return exif data for original image (**default** `false`) - * **string** `tmpDir` - directory to store temporary files (**default** `os.tmpdir()`) - * **number** `workers` - number of async workers (**default** `1`) + * **string** `url` - custom public url (**default** build from `region` and `awsBucketName`) - * **object** `aws` - AWS SDK configuration optsion + * **object** `aws` * **string** `region` - region for you bucket (**default** `us-east-1`) * **string** `path` - path within your bucket (**default** `""`) - * **string** `acl` - default ACL for uploded images (**default** `privat`) + * **string** `acl` - default ACL for uploaded images (**default** `privat`) * **string** `accessKeyId` - AWS access key ID override * **string** `secretAccessKey` - AWS secret access key override @@ -54,46 +63,78 @@ var Upload = require('s3-uploader'); > options](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor_details) > in order to fine tune the connection – if you know what you are doing. - * **object[]** `versions` - versions to upload to S3 - * **boolean** `original` - set this to `true` to save the original image - * **string** `suffix` - this is appended to the file name (**default** `""`) - * **number** `quality` - resized image quality (**default** `resizeQuality`) + * **object** `resize` + * **string** `path` - local directory for resized images (**default**: same as original image) + * **string** `prefix` - local file name prefix for resized images (**default**: `""`) + * **integer** `quality` - default quality for resized images (**default**: `70`) + + * **object[]** `versions` + * **string** `suffix` - image file name suffix (**default** `""`) + * **number** `quality` - image resize quality * **number** `maxWidth` - max width for resized image * **number** `maxHeight` - max height for resized image + * **string** `aspect` - force aspect ratio for resized image (**example:** `4:3` + * **string** `background` - set background for transparent images (**example:** `red`) + * **boolean** `flatten` - flatten backgrund for transparent images + * **string** `awsImageAcl` - access control for AWS S3 upload (**example:** `private`) + + * **object** `original` + * **string** `awsImageAcl` - access control for AWS S3 upload (**example:** `private`) #### Example ```javascript var client = new Upload('my_s3_bucket', { - awsBucketRegion: 'us-east-1', - awsBucketPath: 'images/', - awsBucketAcl: 'public-read', + aws: { + path: 'images/', + region: 'us-east-1', + acl: 'public-read' + }, + + cleanup: { + versions: true, + original: false + }, + + original: { + awsImageAcl: 'private' + }, versions: [{ - original: true - },{ - suffix: '-large', - quality: 80, maxHeight: 1040, maxWidth: 1040, + suffix: '-large', + quality: 80 },{ - suffix: '-medium', maxHeight: 780, - maxWidth: 780 + maxWidth: 780, + aspect: '4:3', + suffix: '-medium' },{ - suffix: '-small', maxHeight: 320, - maxWidth: 320 + maxWidth: 320, + aspect: '4:3', + suffix: '-small' + },{ + maxHeight: 100, + maxWidth: 100, + aspect: '1:1', + suffix: '-thumb1' + },{ + maxHeight: 250, + maxWidth: 250, + aspect: '1:1', + suffix: '-thumb2' }] }); ``` ### #upload(**string** `src`, **object** `opts`, **function** `cb`) -* **string** `src` - absolute path to source image to upload +* **string** `src` - path to the image you want to upload -* **object** `opts` - upload config options - * **string** `awsPath` - local override for `opts.aws.path` +* **object** `opts` + * **string** `awsPath` - override the path on AWS set through `opts.aws.path` * **function** `cb` - callback function (**Error** `err`, **object[]** `versions`, **object** `meta`) * **Error** `err` - `null` if everything went fine @@ -128,4 +169,3 @@ which is used to crate the thumbnails C, D, and E. ``` ## [MIT License](https://github.com/Turistforeningen/node-s3-uploader/blob/master/LICENSE) - diff --git a/package.json b/package.json index d9b5942..ee31a81 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "async": "~1.2", "aws-sdk": "~2.1", "depd": "~1.0", - "gm": "~1.18" + "im-resize": "~2.0", + "im-metadata": "~2.1" }, "engines": { "node": ">=0.10 <0.12" diff --git a/src/index.coffee b/src/index.coffee index 3030e5c..0ec859e 100644 --- a/src/index.coffee +++ b/src/index.coffee @@ -1,10 +1,15 @@ -S3 = require('aws-sdk').S3 -fs = require 'fs' -gm = require('gm').subClass imageMagick: true -mapLimit = require('async').mapLimit +fs = require('fs') +extname = require('path').extname -hash = require('crypto').createHash -rand = require('crypto').pseudoRandomBytes +S3 = require('aws-sdk').S3 + +auto = require('async').auto +each = require('async').each +map = require('async').map +retry = require('async').retry + +resize = require 'im-resize' +metadata = require 'im-metadata' deprecate = require('depd') 's3-uploader' @@ -19,36 +24,37 @@ Upload = module.exports = (awsBucketName, @opts = {}) -> deprecate '`awsAccessKeyId` is deprecated, use `aws.accessKeyId` instead' if @opts.awsAccessKeyId deprecate '`awsSecretAccessKey` is deprecated, use `aws.secretAccessKey` instead' if @opts.awsSecretAccessKey - @opts.aws ?= {} - @opts.aws.region ?= @opts.awsBucketRegion or 'us-east-1' - @opts.aws.path ?= @opts.awsBucketPath or '' - @opts.aws.acl ?= @opts.awsBucketAcl or 'privat' - - @opts.aws.sslEnabled ?= true - @opts.aws.maxRetries ?= @opts.awsMaxRetries or 3 - @opts.aws.accessKeyId ?= @opts.awsAccessKeyId - @opts.aws.secretAccessKey ?= @opts.awsSecretAccessKey - - @opts.aws.params ?= {} - @opts.aws.params.Bucket = awsBucketName - + @opts.aws ?= {} + @opts.aws.accessKeyId ?= @opts.awsAccessKeyId + @opts.aws.acl ?= @opts.awsBucketAcl or 'privat' @opts.aws.httpOptions ?= {} @opts.aws.httpOptions.timeout ?= @opts.awsHttpTimeout or 10000 + @opts.aws.maxRetries ?= @opts.awsMaxRetries or 3 + @opts.aws.params ?= {} + @opts.aws.params.Bucket = awsBucketName + @opts.aws.path ?= @opts.awsBucketPath or '' + @opts.aws.region ?= @opts.awsBucketRegion or 'us-east-1' + @opts.aws.secretAccessKey ?= @opts.awsSecretAccessKey + @opts.aws.sslEnabled ?= true + + @opts.cleanup ?= {} + @opts.returnExif ?= false + + @opts.resize ?= {} + #@opts.resize.path + #@opts.resize.prefix + @opts.resize.quality ?= 70 + @opts.versions ?= [] - @opts.versions ?= [] - @opts.resizeQuality ?= 70 - @opts.returnExif ?= false - - @opts.tmpDir ?= require('os').tmpdir() + '/' - @opts.tmpPrefix ?= 'gm-' - - @opts.workers ?= 1 @opts.url ?= "https://s3-#{@opts.aws.region}.amazonaws.com/#{@opts.aws.params.Bucket}/" @s3 = new S3 @opts.aws @ +## +# Generate a random path on the form /xx/yy/zz +## Upload.prototype._getRandomPath = -> input = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' res = [] @@ -60,130 +66,107 @@ Upload.prototype._getRandomPath = -> return res.join '/' -Upload.prototype._uploadPathIsAvailable = (path, callback) -> - @s3.listObjects Prefix: path, (err, data) -> - return callback err if err - return callback null, path, data.Contents.length is 0 - -Upload.prototype._uploadGeneratePath = (prefix, callback) -> - @._uploadPathIsAvailable prefix + @._getRandomPath(), (err, path, avaiable) -> - return callback err if err - return callback new Error "Path '#{path}' not avaiable!" if not avaiable - return callback null, path +## +# Generate a random avaiable path on the S3 bucket +## +Upload.prototype._getDestPath = (prefix, callback) -> + retry 5, (cb) => + path = prefix + @_getRandomPath() + @s3.listObjects Prefix: path, (err, data) -> + return cb err if err + return cb null, path if data.Contents.length is 0 + return cb new Error "Path #{path} not avaiable" + , callback +## +# Upload a new image to the S3 bucket +## Upload.prototype.upload = (src, opts, cb) -> - prefix = opts?.awsPath or @opts.aws.path - - @_uploadGeneratePath prefix, (err, dest) => - return cb err if err - new Image(src, dest, opts, @).exec cb - -Image = Upload.Image = (src, dest, opts, config) -> - @config = config - - @src = src - @dest = dest - @tmpName = hash('sha1').update(rand(128)).digest('hex') - - @opts = opts or {} - - @meta = {} - @gm = gm @src + image = new Image src, opts, @ + image.start(cb) +## +# Image upload +## +Image = module.exports.Image = (@src, @opts, @upload) -> @ -Image.prototype.getMeta = (cb) -> - @gm.identify (err, val) => - return cb err if err - @meta = - format: val.format.toLowerCase() - fileSize: val.Filesize - imageSize: val.size - orientation: val.Orientation - colorSpace: val.Colorspace - compression: val.Compression - quallity: val.Quality - exif: val.Properties if @config.opts.returnExif - - return cb null, @meta - -Image.prototype.makeMpc = (cb) -> - @gm.write @src + '.mpc', (err) -> - return cb err if err - - @gm = gm @src + '.mpc' +Image.prototype.start = (cb) -> + auto + metadata: @getMetadata.bind(@, @src) + dest: @getDest.bind(@) + versions: ['metadata', @resizeVersions.bind(@)] + uploads: ['versions', 'dest', @uploadVersions.bind(@)] + cleanup: ['uploads', @removeVersions.bind(@)] + , (err, results) -> + cb err, results.uploads, results.metadata + +## +# Get image metadata +## +Image.prototype.getMetadata = (src, cb) -> + metadata src, exif: @upload.opts.returnExif, cb + +## +# Get image destination +## +Image.prototype.getDest = (cb) -> + prefix = @opts?.awsPath or @upload.opts.aws.path + @upload._getDestPath prefix, cb + +## +# Resize image +## +Image.prototype.resizeVersions = (cb, results) -> + resize results.metadata, + path: @upload.opts.resize.path + prefix: @upload.opts.resize.prefix + quality: @upload.opts.resize.quality + versions: JSON.parse JSON.stringify @upload.opts.versions + , cb + +## +# Upload resized versions +## +Image.prototype.uploadVersions = (cb, results) -> + if @upload.opts.original + results.versions.push + awsImageAcl: @upload.opts.original.awsImageAcl + original: true + path: @src + + map results.versions, @_upload.bind(@, results.dest), cb + +## +# Clean up local copies +## +Image.prototype.removeVersions = (cb, results) -> + each results.uploads, (image, callback) => + if not @upload.opts.cleanup.original and image.original \ + or not @upload.opts.cleanup.versions and not image.original + return setTimeout callback, 0 + + fs.unlink image.path, callback + , (err) -> + cb() + +## +# Upload image version to S3 +## +Image.prototype._upload = (dest, version, cb) -> + format = extname version.path - return cb null - -Image.prototype.resize = (version, cb) -> - if typeof version.original isnt 'undefined' - if version.original is false - throw new Error "version.original can not be false" - - version.src = @src - version.format = @meta.format - version.size = @meta.fileSize - version.width = @meta.imageSize.width - version.height = @meta.imageSize.height - - return process.nextTick -> cb null, version - - version.format = 'jpeg' - version.src = [ - @config.opts.tmpDir - @config.opts.tmpPrefix - @tmpName - version.suffix - ".#{version.format}" - ].join('') - - img = @gm - .resize(version.maxWidth, version.maxHeight) - .quality(version.quality or @config.opts.resizeQuality) - - img.autoOrient() if @meta.orientation - img.colorspace('RGB') if @meta.colorSpace not in ['RGB', 'sRGB'] - - img.write version.src, (err) -> - return cb err if err - - version.width = version.maxWidth; delete version.maxWidth - version.height = version.maxHeight; delete version.maxHeight - - cb null, version - -Image.prototype.upload = (version, cb) -> options = - Key: @dest + version.suffix + '.' + version.format - ACL: version.awsImageAcl or @config.opts.aws.acl - Body: fs.createReadStream(version.src) - ContentType: 'image/' + version.format - Metadata: @opts.metadata or {} + Key: dest + (version.suffix || '') + format + ACL: version.awsImageAcl or @upload.opts.aws.acl + Body: fs.createReadStream version.path + ContentType: "image/#{format.substr(1)}" - @config.s3.putObject options, (err, data) => + @upload.s3.putObject options, (err, data) => return cb err if err - version.etag = data.ETag.substr(1, data.ETag.length-2) - version.path = options.Key - version.url = @config.opts.url + version.path if @config.opts.url - - delete version.awsImageAcl - delete version.suffix + version.etag = data.ETag + version.key = options.Key + version.url = @upload.opts.url + options.Key if @upload.opts.url cb null, version - -Image.prototype.resizeAndUpload = (version, cb) -> - version.suffix = version.suffix or '' - - @resize version, (err, version) => - return cb err if err - @upload version, cb - -Image.prototype.exec = (cb) -> - @getMeta (err) => - @makeMpc (err) => - return cb err if err - versions = JSON.parse(JSON.stringify(@config.opts.versions)) - mapLimit versions, @config.opts.workers, @resizeAndUpload.bind(@), (err, versions) => - return cb err if err - return cb null, versions, @meta diff --git a/test/assets/photo.tiff b/test/assets/photo.tiff deleted file mode 100644 index bd0c707..0000000 Binary files a/test/assets/photo.tiff and /dev/null differ diff --git a/test/suite.coffee b/test/suite.coffee index 4f2a5f1..c0343b8 100644 --- a/test/suite.coffee +++ b/test/suite.coffee @@ -1,28 +1,21 @@ assert = require 'assert' Upload = require '../src/index.coffee' -fs = require('fs') -gm = require('gm').subClass imageMagick: true - -hash = require('crypto').createHash -rand = require('crypto').pseudoRandomBytes - upload = listObjects = putObject = null cleanup = [] -SIZE = if process.env.DRONE or process.env.CI then 'KBB' else 'KB' -COLOR = if process.env.DRONE or process.env.CI then 'RGB' else 'sRGB' - beforeEach -> upload = new Upload process.env.AWS_BUCKET_NAME, aws: path: process.env.AWS_BUCKET_PATH region: process.env.AWS_BUCKET_REGION acl: 'public-read' - versions: [{ - original: true + cleanup: + versions: true + original: false + original: awsImageAcl: 'private' - },{ + versions: [{ maxHeight: 1040 maxWidth: 1040 suffix: '-large' @@ -30,11 +23,23 @@ beforeEach -> },{ maxHeight: 780 maxWidth: 780 + aspect: '4:3' suffix: '-medium' },{ maxHeight: 320 maxWidth: 320 + aspect: '4:3' suffix: '-small' + },{ + maxHeight: 100 + maxWidth: 100 + aspect: '1:1' + suffix: '-thumb1' + },{ + maxHeight: 250 + maxWidth: 250 + aspect: '1:1' + suffix: '-thumb2' }] # Mock S3 API calls @@ -43,7 +48,7 @@ beforeEach -> process.nextTick -> cb null, Contents: [] upload.s3.putObject = (opts, cb) -> - process.nextTick -> cb null, ETag: '"' + hash('md5').update(rand(32)).digest('hex') + '"' + process.nextTick -> cb null, ETag: '"f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1"' # Clean up S3 objects if process.env.INTEGRATION_TEST is 'true' @@ -59,33 +64,33 @@ if process.env.INTEGRATION_TEST is 'true' describe 'Upload', -> describe 'constructor', -> - it 'should throw error for missing awsBucketName param', -> + it 'throws error for missing awsBucketName param', -> assert.throws -> new Upload() , /Bucket name can not be undefined/ - it 'should set default values if not provided', -> + it 'sets default values if not provided', -> upload = new Upload 'myBucket' assert upload.s3 instanceof require('aws-sdk').S3 - - assert.equal upload.opts.aws.region, 'us-east-1' - assert.equal upload.opts.aws.path, '' - assert.equal upload.opts.aws.acl, 'privat' - assert.equal upload.opts.aws.maxRetries, 3 - assert.equal upload.opts.aws.httpOptions.timeout, 10000 - - assert upload.opts.versions instanceof Array - assert.equal upload.opts.resizeQuality, 70 - assert.equal upload.opts.returnExif, false - - assert.equal upload.opts.tmpDir, '/tmp/' - assert.equal upload.opts.tmpPrefix, 'gm-' - - assert.equal upload.opts.workers, 1 - assert.equal upload.opts.url, 'https://s3-us-east-1.amazonaws.com/myBucket/' - - it 'should set deprecated options correctly', -> + assert.deepEqual upload.opts, + aws: + accessKeyId: undefined, + acl: 'privat' + httpOptions: timeout: 10000 + maxRetries: 3 + params: Bucket: 'myBucket' + path: '' + region: 'us-east-1' + secretAccessKey: undefined + sslEnabled: true + cleanup: {} + returnExif: false + resize: quality: 70 + versions: [] + url: 'https://s3-us-east-1.amazonaws.com/myBucket/' + + it 'sets deprecated options correctly', -> upload = new Upload 'myBucket', awsBucketRegion: 'my-region' awsBucketPath: '/some/path' @@ -103,316 +108,333 @@ describe 'Upload', -> assert.equal upload.opts.aws.accessKeyId, 'public' assert.equal upload.opts.aws.secretAccessKey, 'secret' - it 'should set custom url', -> + it 'sets default url based on AWS region', -> + upload = new Upload 'myBucket', aws: region: 'my-region-1' + assert.equal upload.opts.url, 'https://s3-my-region-1.amazonaws.com/myBucket/' + + it 'sets custom url', -> upload = new Upload 'myBucket', url: 'http://cdn.app.com/' assert.equal upload.opts.url, 'http://cdn.app.com/' - it 'should override default values' - - it 'should connect to AWS S3 using environment variables', (done) -> + it 'connects to AWS S3 using environment variables', (done) -> @timeout 10000 upload = new Upload process.env.AWS_BUCKET_NAME - upload.s3.headBucket Bucket: process.env.AWS_BUCKET_NAME, (err, data) -> + upload.s3.headBucket upload.opts.aws.params, (err, data) -> assert.ifError err done() - it 'should connect to AWS S3 using constructor options', (done) -> + it 'connects to AWS S3 using constructor options', (done) -> @timeout 10000 upload = new Upload process.env.AWS_BUCKET_NAME, aws: accessKeyId: process.env.AWS_ACCESS_KEY_ID secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY - upload.s3.headBucket Bucket: process.env.AWS_BUCKET_NAME, (err, data) -> + upload.s3.headBucket upload.opts.aws.params, (err, data) -> assert.ifError err done() describe '#_getRandomPath()', -> - it 'should return a new random path', -> + it 'returns a new random path', -> path = upload._getRandomPath() assert(/^[A-Za-z0-9]{2}\/[A-Za-z0-9]{2}\/[A-Za-z0-9]{2}$/.test(path)) - describe '#_uploadPathIsAvailable()', -> - it 'should return true for avaiable path', (done) -> + describe '#_getDestPath()', -> + beforeEach -> + upload._getRandomPath = -> return 'aa/bb/cc' + + it 'returns a random avaiable path', (done) -> upload.s3.listObjects = (opts, cb) -> process.nextTick -> cb null, Contents: [] - upload._uploadPathIsAvailable 'some/path/', (err, path, isAvaiable) -> + upload._getDestPath 'some/prefix/', (err, path) -> assert.ifError err - assert.equal isAvaiable, true + assert.equal path, 'some/prefix/aa/bb/cc' done() - it 'should return false for unavaiable path', (done) -> + it 'returns error if no available path can be found', (done) -> upload.s3.listObjects = (opts, cb) -> process.nextTick -> cb null, Contents: [opts.Prefix] - upload._uploadPathIsAvailable 'some/path/', (err, path, isAvaiable) -> - assert.ifError err - assert.equal isAvaiable, false - done() - - describe '#_uploadGeneratePath()', -> - it 'should return an error if path is taken', (done) -> - upload._uploadPathIsAvailable = (path, cb) -> process.nextTick -> cb null, path, false - upload._uploadGeneratePath 'some/path/', (err, path) -> - assert /Path '[^']+' not avaiable!/.test err - done() - - it 'should return an avaiable path', (done) -> - upload._uploadPathIsAvailable = (path, cb) -> process.nextTick -> cb null, path, true - upload._uploadGeneratePath 'some/path/', (err, path) -> - assert.ifError err - assert /^some\/path\/[A-Za-z0-9]{2}\/[A-Za-z0-9]{2}\/[A-Za-z0-9]{2}$/.test(path) + upload._getDestPath 'some/prefix/', (err, path) -> + assert err instanceof Error + assert.equal err.message, 'Path some/prefix/aa/bb/cc not avaiable' done() - describe '#upload()', -> - it 'should use default aws path', (done) -> - upload._uploadGeneratePath = (prefix, cb) -> - assert.equal prefix, upload.opts.aws.path - done() + it 'retries five 5 times to find an avaiable path', (done) -> + count = 0 - upload.upload 'dummy.jpg', {} + upload.s3.listObjects = (opts, cb) -> + if ++count < 5 + return process.nextTick -> cb null, Contents: [opts.Prefix] + process.nextTick -> cb null, Contents: [] - it 'should override default aws path', (done) -> - upload._uploadGeneratePath = (prefix, cb) -> - assert.equal prefix, '/my/new/path' + upload._getDestPath 'some/prefix/', (err, path) -> + assert.ifError err + assert.equal path, 'some/prefix/aa/bb/cc' done() - upload.upload 'dummy.jpg', awsPath: '/my/new/path' - describe 'Image', -> image = null beforeEach -> src = __dirname + '/assets/photo.jpg' - dest = 'images_test/Wm/PH/f3/I0' opts = {} - image = new Upload.Image src, dest, opts, upload + image = new Upload.Image src, opts, upload + image.upload._getRandomPath = -> return 'aa/bb/cc' describe 'constructor', -> - it 'should set default values', -> + it 'sets default values', -> assert image instanceof Upload.Image - assert image.config instanceof Upload assert.equal image.src, __dirname + '/assets/photo.jpg' - assert.equal image.dest, 'images_test/Wm/PH/f3/I0' - assert /[a-z0-9]{24}/.test image.tmpName - assert.deepEqual image.meta, {} - assert.equal typeof image.gm, 'object' + assert.deepEqual image.opts, {} + assert image.upload instanceof Upload - describe '#getMeta()', -> - it 'should return image metadata', (done) -> - @timeout 10000 - image.getMeta (err, meta) -> - assert.ifError err - assert.equal meta.format, 'jpeg' - assert.equal meta.fileSize, '617' + SIZE - assert.equal meta.imageSize.width, 1536 - assert.equal meta.imageSize.height, 2048 - assert.equal meta.orientation, 'Undefined' - assert.equal meta.colorSpace, COLOR - assert.equal meta.compression, 'JPEG' - assert.equal meta.quallity, '96' - assert.equal meta.exif, undefined + describe '#_upload()', -> + beforeEach -> + image.upload.s3.putObject = (opts, cb) -> + process.nextTick -> cb null, ETag: '"f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1"' + + it 'sets upload key', (done) -> + version = path: '/some/image.jpg' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.Key, 'aa/bb/cc.jpg' done() - it 'should store image matadata', (done) -> - @timeout 10000 - image.getMeta (err) -> - assert.ifError err - assert.equal image.meta.format, 'jpeg' - assert.equal image.meta.fileSize, '617' + SIZE - assert.equal image.meta.imageSize.width, 1536 - assert.equal image.meta.imageSize.height, 2048 - assert.equal image.meta.orientation, 'Undefined' - assert.equal image.meta.colorSpace, COLOR - assert.equal image.meta.compression, 'JPEG' - assert.equal image.meta.quallity, '96' - assert.equal image.meta.exif, undefined + image._upload 'aa/bb/cc', version + + it 'sets upload key suffix', (done) -> + version = path: '/some/image.jpg', suffix: '-small' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.Key, 'aa/bb/cc-small.jpg' done() - it 'should return exif data if returnExif is set to true', (done) -> - @timeout 10000 - image.config.opts.returnExif = true - image.getMeta (err) -> - assert.ifError err - assert.equal typeof image.meta.exif, 'object' + image._upload 'aa/bb/cc', version + + it 'sets upload key format', (done) -> + version = path: '/some/image.png' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.Key, 'aa/bb/cc.png' done() - it 'should store gm image instance', (done) -> - @timeout 10000 - image.getMeta (err) -> - assert.ifError err - assert image.gm instanceof require('gm') + image._upload 'aa/bb/cc', version + + it 'sets default ACL', (done) -> + version = path: '/some/image.png' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.ACL, upload.opts.aws.acl done() - describe '#resize()', -> - versions = null - beforeEach -> - versions = JSON.parse JSON.stringify upload.opts.versions - versions[0].suffix = '' - - image.src = __dirname + '/assets/photo.jpg' - image.gm = gm image.src - image.tmpName = 'ed8d8b72071e731dc9065095c92c3e384d7c1e27' - image.meta = - format: 'jpeg' - fileSize: '617' + SIZE - imageSize: width: 1536, height: 2048 - orientation: 'Undefined' - colorSpace: 'RGB' - compression: 'JPEG' - quallity: '96' - exif: undefined - - it 'should return updated properties for original image', (done) -> - @timeout 10000 - image.resize JSON.parse(JSON.stringify(versions[0])), (err, version) -> + image._upload 'aa/bb/cc', version + + it 'sets specific ACL', (done) -> + version = path: '/some/image.png', awsImageAcl: 'private' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.ACL, version.awsImageAcl + done() + + image._upload 'aa/bb/cc', version + + it 'sets upload body', (done) -> + version = path: '/some/image.png' + image.upload.s3.putObject = (opts, cb) -> + assert opts.Body instanceof require('fs').ReadStream + assert.equal opts.Body.path, version.path + done() + + image._upload 'aa/bb/cc', version + + it 'sets upload conentet type', (done) -> + version = path: '/some/image.png' + image.upload.s3.putObject = (opts, cb) -> + assert.equal opts.ContentType, 'image/png' + done() + + image._upload 'aa/bb/cc', version + + it 'returns etag for uploaded version', (done) -> + version = path: '/some/image.jpg' + image._upload 'aa/bb/cc', version, (err, version) -> assert.ifError err - assert.deepEqual version, - original: true, - awsImageAcl: 'private' - suffix: '' - src: image.src - format: image.meta.format - size: image.meta.fileSize - width: image.meta.imageSize.width - height: image.meta.imageSize.height + assert.equal version.etag, '"f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1"' done() - it 'should throw error when version original is false', -> - assert.throws -> - image.resize original: false - , '/version.original can not be false/' + it 'returns url for uploaded version', (done) -> + version = path: '/some/image.jpg' + image._upload 'aa/bb/cc', version, (err, version) -> + assert.ifError err + assert.equal version.url, image.upload.opts.url + 'aa/bb/cc.jpg' + done() - it 'should return updated properties for resized image', (done) -> - @timeout 10000 - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + describe '#getMetadata()', -> + it 'returns image metadata without exif data', (done) -> + image.upload.opts.returnExif = false + image.getMetadata image.src, (err, metadata) -> assert.ifError err - assert.deepEqual version, - suffix: versions[1].suffix - quality: versions[1].quality - format: 'jpeg' - src: '/tmp/gm-ed8d8b72071e731dc9065095c92c3e384d7c1e27-large.jpeg' - width: versions[1].maxWidth - height: versions[1].maxHeight + + assert.deepEqual metadata, + path: image.src + name: '' + size: '617KB' + format: 'JPEG' + colorspace: 'RGB' + height: 2048 + width: 1536 + orientation: '' done() - it 'should write resized image to temp destination', (done) -> - @timeout 10000 - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + it 'returns image metadata with exif data', (done) -> + image.upload.opts.returnExif = true + image.getMetadata image.src, (err, metadata) -> assert.ifError err - fs.stat version.src, (err, stat) -> - assert.ifError err - assert.equal typeof stat, 'object' - done() + assert.equal Object.keys(metadata).length, 9 + assert.equal metadata.exif.GPSInfo, '954' + done() - it 'should set hegith and width for reszied image', (done) -> - @timeout 10000 - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + describe '#getDest()', -> + it 'returns destination path', (done) -> + image.getDest (err, path) -> assert.ifError err - gm(version.src).size (err, value) -> - assert.ifError err - assert.deepEqual value, - width: 780 - height: 1040 - done() + assert.equal path, image.upload.opts.aws.path + 'aa/bb/cc' + done() - it 'should set correct orientation for resized image', (done) -> - @timeout 10000 - image.src = __dirname + '/assets/rotate.jpg' - image.gm = gm image.src - image.meta.orientation = 'TopLeft' - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + it 'overrides destination path prefix', (done) -> + image.opts.awsPath = 'custom/path/' + image.getDest (err, path) -> assert.ifError err - gm(version.src).identify (err, value) -> - assert.ifError err - assert.equal value.Orientation, 'Undefined' - assert.equal value.size.width, 585 - assert.equal value.size.height, 1040 - done() + assert.equal path, 'custom/path/aa/bb/cc' + done() - it 'should set colorspace to RGB for resized image', (done) -> - @timeout 10000 - image.src = __dirname + '/assets/cmyk.jpg' - image.gm = gm image.src - image.meta.colorSpace = 'CMYK' - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + describe '#resizeVersions()', -> + it 'resizes image versions', (done) -> + image.getMetadata image.src, (err, metadata) -> assert.ifError err - gm(version.src).identify (err, value) -> + + image.resizeVersions (err, versions) -> assert.ifError err - assert.equal value.Colorspace, COLOR + + # Check that resized files exists on disk + for version in versions + require('fs').statSync version.path + require('fs').unlinkSync version.path + done() + , metadata: metadata + + describe '#uploadVersions()', -> + it 'uploads image versions', (done) -> + i = 0 + image._upload = (dest, version, cb) -> + assert.equal dest, '/foo/bar' + assert.equal version, i++ + cb null, version + 1 + + image.upload.opts.original = undefined + image.uploadVersions (err, versions) -> + assert.ifError err - it 'should set quality for resized image', (done) -> - @timeout 10000 - versions[1].quality = 50 - image.src = __dirname + '/assets/photo.jpg' - image.gm = gm image.src - image.resize JSON.parse(JSON.stringify(versions[1])), (err, version) -> + assert.deepEqual versions, [1, 2, 3, 4] + + done() + + , versions: [0, 1, 2, 3], dest: '/foo/bar' + + it 'uploads original image', (done) -> + image._upload = (dest, version, cb) -> + assert.deepEqual version, + awsImageAcl: 'private' + original: true + path: image.src + + cb null, version + + image.upload.opts.original = awsImageAcl: 'private' + image.uploadVersions (err, versions) -> assert.ifError err - gm(version.src).identify (err, value) -> - assert.ifError err - assert.equal value.Quality, versions[1].quality - done() - describe '#upload()', -> - it 'should set object Key to correct location on AWS S3' - it 'should set ojbect ACL to specified ACL' - it 'should set object ACL to default if not specified' - it 'should set object Body to version src file' - it 'should set object ContentType according to version type' - it 'should set object Metadata from default metadata' - it 'should set object Metadata from upload metadata' - it 'should return updated version object on successfull upload' - - describe '#resizeAndUpload()', -> - it 'should set version suffix if not provided' - it 'should resize and upload according to image version' - - describe '#exec()', -> - it 'should get source image metadata' - it 'should make a copy of master version objects array' - it 'should resize and upload original image accroding to versions' + assert.deepEqual versions, [ + awsImageAcl: 'private' + original: true + path: image.src + ] -describe 'Integration Tests', -> - it 'should upload image to new random path', (done) -> - @timeout 40000 - upload.upload __dirname + '/assets/photo.jpg', {}, (err, images, meta) -> - assert.ifError err + done() - for image in images - cleanup.push Key: image.path if image.path # clean up in AWS + , versions: [], dest: '/foo/bar' - if image.original - assert.equal typeof image.size, 'string' - assert.equal typeof image.src, 'string' - assert.equal typeof image.format, 'string' - assert.equal typeof image.width, 'number' - assert.equal typeof image.height, 'number' - assert /[0-9a-f]{32}/.test image.etag - assert.equal typeof image.path, 'string' - assert.equal typeof image.url, 'string' + describe '#removeVersions()', -> + unlink = require('fs').unlink + results = uploads: [] - done() + beforeEach -> + image.upload.opts.cleanup = {} - it 'should not upload original if not in versions array', (done) -> - @timeout 40000 - upload.opts.versions.shift() - upload.upload __dirname + '/assets/photo.jpg', {}, (err, images, meta) -> + results.uploads = [ + original: true + path: '/foo/bar' + , + path: '/foo/bar-2' + ] + + afterEach -> + require('fs').unlink = unlink + + it 'keeps all local images', (done) -> + require('fs').unlink = (path, cb) -> + assert.fail new Error 'unlink shall not be called' + + image.removeVersions done, results + + it 'removes image versions by default', (done) -> + require('fs').unlink = (path, cb) -> + assert.equal path, results.uploads[1].path + cb() + + image.upload.opts.cleanup.versions = true + image.removeVersions done, results + + it 'removes original image', (done) -> + require('fs').unlink = (path, cb) -> + assert.equal path, results.uploads[0].path + cb() + + image.upload.opts.cleanup.original = true + image.removeVersions done, results + + it 'removes all images', (done) -> + i = 0 + require('fs').unlink = (path, cb) -> + assert.equal path, results.uploads[i++].path + cb() + + image.upload.opts.cleanup.original = true + image.upload.opts.cleanup.versions = true + image.removeVersions done, results + +describe 'Integration Tests', -> + beforeEach -> + if process.env.INTEGRATION_TEST isnt 'true' + upload._getRandomPath = -> return 'aa/bb/cc' + + it 'uploads image to new random path', (done) -> + @timeout 10000 + upload.upload __dirname + '/assets/portrait.jpg', {}, (err, images, meta) -> assert.ifError err for image in images - cleanup.push Key: image.path if image.path # clean up in AWS - - assert.equal typeof image.original, 'undefined' - assert.equal typeof image.src, 'string' - assert.equal typeof image.format, 'string' - assert.equal typeof image.width, 'number' - assert.equal typeof image.height, 'number' - assert /[0-9a-f]{32}/.test image.etag + cleanup.push Key: image.key if image.key # clean up in AWS + + assert.equal typeof image.etag, 'string' assert.equal typeof image.path, 'string' + assert.equal typeof image.key, 'string' assert.equal typeof image.url, 'string' - done() + if image.original + assert.equal image.original, true + else + assert.equal typeof image.suffix, 'string' + assert.equal typeof image.maxHeight, 'number' + assert.equal typeof image.maxWidth, 'number' + done()