commit 9a332a12d92755f54a07ce74b836a065c921d6d6 Author: AJ ONeal Date: Thu Mar 12 04:26:31 2020 -0600 v1.0.0: a diet s3 client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f25ac30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules +.*.sw* diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..5a46e6b --- /dev/null +++ b/.jshintrc @@ -0,0 +1,4 @@ +{ "node": true +, "browser": true +, "esversion": 8 +} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2c5ec4a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "bracketSpacing": true, + "printWidth": 80, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "none", + "useTabs": false +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3699039 --- /dev/null +++ b/LICENSE @@ -0,0 +1,41 @@ +Copyright 2020 AJ ONeal + +This is open source software; you can redistribute it and/or modify it under the +terms of either: + + a) the "MIT License" + b) the "Apache-2.0 License" + +MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +Apache-2.0 License Summary + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..69ea948 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# [s3.js](https://git.rootprojects.org/root/s3.js) | a [Root](https://rootprojects.org) project + +> Minimalist S3 client + +A lightweight alternative to the s3 SDK that uses @root/request and aws4. + +* set() +* get() +* head() +* delete() + +```js +s3.set({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key, + body +}) +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..d75f25e --- /dev/null +++ b/index.js @@ -0,0 +1,208 @@ +'use strict'; + +var aws4 = require('aws4'); +var request = require('@root/request'); +var env = process.env; + +module.exports = { + // HEAD + head: function({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) { + // TODO support minio + /* + var awsHost = config.awsHost; + if (!awsHost) { + if (awsRegion) { + awsHost = awsHost || 's3.'+awsRegion+'.amazonaws.com'; + } else { + // default + awsHost = 's3.amazonaws.com'; + } + } + */ + /* + if (env.AWS_ACCESS_KEY) { + accessKeyId = accessKeyId || env.AWS_ACCESS_KEY; + secretAccessKey = secretAccessKey || env.AWS_SECRET_ACCESS_KEY; + bucket = bucket || env.AWS_BUCKET; + prefix = prefix || env.AWS_BUCKET_PREFIX; + region = region || env.AWS_REGION; + endpoint = endpoint || env.AWS_ENDPOINT; + } + */ + prefix = prefix || ''; + if (prefix) { + // whatever => whatever/ + // whatever/ => whatever/ + prefix = prefix.replace(/\/?$/, '/'); + } + var signed = aws4.sign( + { + // host: awsHost + service: 's3', + region: region, + path: '/' + bucket + '/' + prefix + key, + method: 'HEAD', + signQuery: true + }, + { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey } + ); + var url = 'https://' + signed.hostname + signed.path; + + return request({ method: 'HEAD', url }).then(function(resp) { + if (200 === resp.statusCode) { + return resp; + } + var err = new Error( + 'expected status 200 but got ' + + resp.statusCode + + '. See err.response for more info.' + ); + err.url = url; + err.response = resp; + throw err; + }); + }, + + // GET + get: function({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key, + json + }) { + prefix = prefix || ''; + if (prefix) { + prefix = prefix.replace(/\/?$/, '/'); + } + var signed = aws4.sign( + { + service: 's3', + region: region, + path: '/' + bucket + '/' + prefix + key, + method: 'GET', + signQuery: true + }, + { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey } + ); + var url = 'https://' + signed.hostname + signed.path; + + // stay binary by default + var encoding = null; + if (json) { + encoding = undefined; + } + return request({ method: 'GET', url, encoding: null, json: json }).then( + function(resp) { + if (200 === resp.statusCode) { + return resp; + } + var err = new Error( + 'expected status 200 but got ' + + resp.statusCode + + '. See err.response for more info.' + ); + err.url = url; + err.response = resp; + throw err; + } + ); + }, + + // PUT + set: function({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key, + body, + size + }) { + prefix = prefix || ''; + if (prefix) { + prefix = prefix.replace(/\/?$/, '/'); + } + var signed = aws4.sign( + { + service: 's3', + region: region, + path: '/' + bucket + '/' + prefix + key, + method: 'PUT', + signQuery: true + }, + { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey } + ); + var url = 'https://' + signed.hostname + signed.path; + var headers = {}; + if ('undefined' !== typeof size) { + headers['Content-Length'] = size; + } + + return request({ method: 'PUT', url, body, headers }).then(function( + resp + ) { + if (200 === resp.statusCode) { + return resp; + } + var err = new Error( + 'expected status 201 but got ' + + resp.statusCode + + '. See err.response for more info.' + ); + err.url = url; + err.response = resp; + throw err; + }); + }, + + // DELETE + del: function({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) { + prefix = prefix || ''; + if (prefix) { + prefix = prefix.replace(/\/?$/, '/'); + } + var signed = aws4.sign( + { + service: 's3', + region: region, + path: '/' + bucket + '/' + prefix + key, + method: 'DELETE', + signQuery: true + }, + { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey } + ); + var url = 'https://' + signed.hostname + signed.path; + + return request({ method: 'DELETE', url }).then(function(resp) { + if (204 === resp.statusCode) { + return resp; + } + var err = new Error( + 'expected status 204 but got ' + + resp.statusCode + + '. See err.response for more info.' + ); + err.url = url; + err.response = resp; + throw err; + }); + } +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..21aa096 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "@root/s3", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@root/request": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@root/request/-/request-1.5.0.tgz", + "integrity": "sha512-J9RUIwVU99/cOVuDVYlNpr4G0A1/3ZxhCXIRiTZzu8RntOnb0lmDBMckhaus5ry9x/dBqJKDplFIgwHbLi6rLA==" + }, + "aws4": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", + "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==" + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..836cd2c --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@root/s3", + "version": "1.0.0", + "description": "A simple, lightweight s3 client with only 2 dependencies", + "main": "index.js", + "files": [ + "lib" + ], + "directories": { + "example": "examples" + }, + "scripts": { + "test": "node test.js" + }, + "repository": { + "type": "git", + "url": "https://git.rootprojects.org/root/s3.js.git" + }, + "keywords": [ + "s3", + "lightweight", + "alternative" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@root/request": "^1.5.0", + "aws4": "^1.9.1" + }, + "devDependencies": { + "dotenv": "^8.2.0" + } +} diff --git a/test.bin b/test.bin new file mode 100644 index 0000000..75d1027 Binary files /dev/null and b/test.bin differ diff --git a/test.js b/test.js new file mode 100644 index 0000000..6f0dceb --- /dev/null +++ b/test.js @@ -0,0 +1,314 @@ +'use strict'; + +require('dotenv').config(); +var env = process.env; +var s3 = require('./index.js'); + +var accessKeyId = env.AWS_ACCESS_KEY; +var secretAccessKey = env.AWS_SECRET_ACCESS_KEY; +var region = env.AWS_REGION; +var bucket = env.AWS_BUCKET; +var prefix = env.AWS_BUCKET_PREFIX; + +var key = 'test-file'; +var fs = require('fs'); + +async function run() { + // UPLOAD + //var testFile = __filename; + var testFile = 'test.bin'; + var stat = fs.statSync(testFile); + var size = stat.size; + var stream = fs.createReadStream(testFile); + var file = fs.readFileSync(testFile); + await s3 + .set({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key, + body: stream, + size + }) + .then(function(resp) { + console.info('PASS: stream uploaded file'); + return null; + }) + .catch(function(err) { + console.error('Error:'); + console.error('PUT Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + console.error( + (err.response.body && err.response.body) || + JSON.stringify(err.response.body) + ); + } else { + console.error(err); + } + process.exit(1); + }); + + // CHECK DOES EXIST + await s3 + .head({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + console.info('PASS: streamed file exists'); + return null; + }) + .catch(function(err) { + console.error('HEAD Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // GET STREAMED FILE + await s3 + .get({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + if (file.toString('binary') === resp.body.toString('binary')) { + console.info( + 'PASS: streamed file downloaded with same contents' + ); + return null; + } + throw new Error("file contents don't match"); + }) + .catch(function(err) { + console.error('Error:'); + console.error('GET Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // DELETE TEST FILE + await s3 + .del({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + console.info('PASS: delete file'); + return null; + }) + .catch(function(err) { + console.error('Error:'); + console.error('DELETE Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // SHOULD NOT EXIST + await s3 + .head({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + var err = new Error('file should not exist'); + err.response = resp; + throw err; + }) + .catch(function(err) { + if (err.response && 404 === err.response.statusCode) { + console.info('PASS: streamed file deleted'); + return null; + } + console.error('Error:'); + console.error('HEAD Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // CREATE WITHOUT STREAM + await s3 + .set({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key, + body: file + }) + .then(function(resp) { + console.info('PASS: one-shot upload'); + return null; + }) + .catch(function(err) { + console.error('Error:'); + console.error('PUT Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + console.error( + (err.response.body && err.response.body) || + JSON.stringify(err.response.body) + ); + } else { + console.error(err); + } + process.exit(1); + }); + + // CHECK DOES EXIST + await s3 + .head({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + console.info('PASS: one-shot upload exists'); + return null; + }) + .catch(function(err) { + console.error('Error:'); + console.error('HEAD Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // GET ONE-SHOT FILE + await s3 + .get({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + if (file.toString('binary') === resp.body.toString('binary')) { + console.info( + 'PASS: one-shot file downloaded with same contents' + ); + return null; + } + throw new Error("file contents don't match"); + }) + .catch(function(err) { + console.error('Error:'); + console.error('GET Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // DELETE FILE + await s3 + .del({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + console.info('PASS: DELETE'); + return null; + }) + .catch(function(err) { + console.error('Error:'); + console.error('DELETE Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); + + // SHOULD NOT EXIST + await s3 + .head({ + accessKeyId, + secretAccessKey, + region, + bucket, + prefix, + key + }) + .then(function(resp) { + var err = new Error('file should not exist'); + err.response = resp; + throw err; + }) + .catch(function(err) { + if (err.response && 404 === err.response.statusCode) { + console.info('PASS: streamed file deleted'); + return null; + } + console.error('Error:'); + console.error('HEAD Response:'); + if (err.response) { + console.error(err.response.statusCode); + console.error(err.response.headers); + } else { + console.error(err); + } + process.exit(1); + }); +} + +run();