├── .nvmrc ├── .travis.yml ├── .prettierrc ├── .gitignore ├── .gitattributes ├── .editorconfig ├── src ├── lib │ └── envVarsChecker.js └── upload.js ├── .eslintrc ├── .babelrc ├── webpack.config.js ├── __tests__ ├── lib │ └── envVarsChecker.spec.js ├── stubs │ └── eventHttpApiGateway.json └── upload.spec.js ├── package.json ├── serverless.yml └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/carbon 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | --- 2 | singleQuote: true 3 | trailingComma: es5 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .serverless 3 | .webpack 4 | node_modules 5 | *.log 6 | config.json 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default to Unix line endings. 2 | * text=auto 3 | 4 | # Do not attempt to change line endings on these file types. 5 | *.jpg -text 6 | *.min.js -text 7 | *.png -text 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 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 | -------------------------------------------------------------------------------- /src/lib/envVarsChecker.js: -------------------------------------------------------------------------------- 1 | export default env => { 2 | const required = ['BUCKET', 'REGION']; 3 | const missing = []; 4 | 5 | required.forEach(reqVar => { 6 | if (!env[reqVar]) { 7 | missing.push(reqVar); 8 | } 9 | }); 10 | 11 | return missing; 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | root: true 3 | extends: 4 | - airbnb-base 5 | - prettier 6 | - plugin:jest/recommended 7 | plugins: 8 | - jest 9 | - prettier 10 | rules: 11 | prettier/prettier: 12 | - error 13 | - 14 | trailingComma: es5 15 | singleQuote: true 16 | env: 17 | node: true 18 | jest/globals: true 19 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": "8.10" 8 | }, 9 | "modules": false, 10 | "loose": true 11 | } 12 | ] 13 | ], 14 | "env": { 15 | "test": { 16 | "presets": [ 17 | [ 18 | "env", 19 | { 20 | "targets": { 21 | "node": "8.10" 22 | } 23 | } 24 | ] 25 | ] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const slsw = require('serverless-webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: slsw.lib.entries, 6 | target: 'node', 7 | externals: [{ 'aws-sdk': true }], 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.js$/, 12 | include: __dirname, 13 | exclude: /node_modules/, 14 | use: [{ loader: 'babel-loader' }], 15 | }, 16 | ], 17 | }, 18 | output: { 19 | libraryTarget: 'commonjs', 20 | path: path.join(__dirname, '.webpack'), 21 | filename: '[name].js', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /__tests__/lib/envVarsChecker.spec.js: -------------------------------------------------------------------------------- 1 | import checker from '../../src/lib/envVarsChecker'; 2 | 3 | describe(`Utility library envVarsChecker`, () => { 4 | test(`The helper exists`, () => { 5 | expect(checker).toBeTruthy(); 6 | }); 7 | 8 | test(`Asks for both BUCKET and REGION environment variables`, () => { 9 | const input = {}; 10 | const result = checker(input); 11 | expect(result).toEqual(['BUCKET', 'REGION']); 12 | }); 13 | 14 | test(`Asks for a missing BUCKET environment variables`, () => { 15 | const input = { 16 | REGION: 'foo', 17 | }; 18 | const result = checker(input); 19 | expect(result).toEqual(['BUCKET']); 20 | }); 21 | 22 | test(`Asks for a missing REGION environment variables`, () => { 23 | const input = { 24 | BUCKET: 'foo', 25 | }; 26 | const result = checker(input); 27 | expect(result).toEqual(['REGION']); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "aws-node-singned-uploads", 4 | "description": "Serverless example for S3 signed uploads", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "codecov": "codecov", 8 | "coverage": "npm run test && npm run codecov", 9 | "deploy": "serverless deploy -v", 10 | "lint": "eslint .", 11 | "start": "serverless offline start", 12 | "test": "jest" 13 | }, 14 | "devDependencies": { 15 | "aws-sdk": "2.579.0", 16 | "aws-sdk-mock": "4.5.0", 17 | "babel-core": "6.26.3", 18 | "babel-loader": "8.0.6", 19 | "babel-preset-env": "1.7.0", 20 | "codecov": "3.6.1", 21 | "eslint": "6.7.1", 22 | "eslint-config-airbnb-base": "14.0.0", 23 | "eslint-config-prettier": "6.7.0", 24 | "eslint-plugin-import": "2.18.2", 25 | "eslint-plugin-jest": "23.0.4", 26 | "eslint-plugin-prettier": "3.1.1", 27 | "jest": "24.9.0", 28 | "prettier": "1.19.1", 29 | "serverless": "1.58.0", 30 | "serverless-offline": "5.12.0", 31 | "serverless-webpack": "5.3.1", 32 | "webpack": "4.41.2" 33 | }, 34 | "jest": { 35 | "coverageDirectory": "./coverage/", 36 | "collectCoverage": true, 37 | "testURL": "http://localhost" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: aws-node-singned-uploads 2 | 3 | plugins: 4 | - serverless-webpack 5 | - serverless-offline #serverless-offline needs to be last in the list 6 | 7 | custom: 8 | bucketName: testbucket123notaken 9 | webpack: 10 | webpackConfig: 'webpack.config.js' 11 | includeModules: true 12 | packager: 'yarn' 13 | serverless-offline: 14 | port: 4000 15 | 16 | provider: 17 | name: aws 18 | runtime: nodejs8.10 19 | stage: ${opt:stage, env:AWS_STAGE, 'dev'} 20 | region: ${opt:region, env:AWS_REGION, 'eu-central-1'} 21 | environment: 22 | REGION: ${self:provider.region} 23 | BUCKET: { Ref: Uploads } 24 | versionFunctions: false 25 | iamRoleStatements: 26 | - Effect: "Allow" 27 | Action: 28 | - "s3:*" 29 | Resource: "*" 30 | 31 | functions: 32 | upsert-objects: 33 | handler: src/upload.handler 34 | name: ${self:provider.stage}-${self:service}-upload 35 | memorySize: 128 36 | events: 37 | - http: 38 | path: upload 39 | method: get 40 | cors: true 41 | 42 | resources: 43 | Resources: 44 | Uploads: 45 | Type: AWS::S3::Bucket 46 | Properties: 47 | BucketName: ${self:custom.bucketName} 48 | CorsConfiguration: 49 | CorsRules: 50 | - AllowedHeaders: 51 | - "Authorization" 52 | AllowedMethods: 53 | - GET 54 | AllowedOrigins: 55 | - "*" 56 | - AllowedHeaders: 57 | - "*" 58 | AllowedMethods: 59 | - PUT 60 | AllowedOrigins: 61 | - "*" 62 | -------------------------------------------------------------------------------- /src/upload.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; // eslint-disable-line import/no-extraneous-dependencies 2 | import checker from './lib/envVarsChecker'; 3 | 4 | export const handler = async event => { 5 | const bucket = process.env.BUCKET; 6 | const region = process.env.REGION; 7 | 8 | const missing = checker(process.env); 9 | 10 | if (missing.length) { 11 | const vars = missing.join(', '); 12 | return `Missing required environment variables: ${vars}`; 13 | } 14 | 15 | const S3 = new AWS.S3({ signatureVersion: 'v4', region }); 16 | 17 | const file = 18 | event.headers && event.headers['x-amz-meta-filekey'] 19 | ? event.headers['x-amz-meta-filekey'] 20 | : undefined; 21 | 22 | if (!file) { 23 | const response = { 24 | statusCode: 400, 25 | body: JSON.stringify({ 26 | message: `Missing x-amz-meta-filekey in the header of the request.`, 27 | }), 28 | }; 29 | 30 | return response; 31 | } 32 | 33 | const params = { 34 | Bucket: bucket, 35 | Key: file, 36 | Expires: 30, 37 | }; 38 | 39 | try { 40 | const url = await S3.getSignedUrl('putObject', params); 41 | 42 | const response = { 43 | statusCode: 200, 44 | headers: { 45 | 'Access-Control-Allow-Origin': '*', // Required for CORS support to work 46 | 'Access-Control-Allow-Credentials': true, // Required for cookies, authorization headers with HTTPS 47 | }, 48 | body: JSON.stringify(url), 49 | }; 50 | 51 | return response; 52 | } catch (error) { 53 | const response = { 54 | statusCode: 400, 55 | body: JSON.stringify(error), 56 | }; 57 | 58 | return response; 59 | } 60 | }; 61 | 62 | export default handler; 63 | -------------------------------------------------------------------------------- /__tests__/stubs/eventHttpApiGateway.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "/test/hello", 3 | "headers": { 4 | "x-amz-meta-key": "foo-key", 5 | "Accept": 6 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 7 | "Accept-Encoding": "gzip, deflate, lzma, sdch, br", 8 | "Accept-Language": "en-US,en;q=0.8", 9 | "CloudFront-Forwarded-Proto": "https", 10 | "CloudFront-Is-Desktop-Viewer": "true", 11 | "CloudFront-Is-Mobile-Viewer": "false", 12 | "CloudFront-Is-SmartTV-Viewer": "false", 13 | "CloudFront-Is-Tablet-Viewer": "false", 14 | "CloudFront-Viewer-Country": "US", 15 | "Host": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com", 16 | "Upgrade-Insecure-Requests": "1", 17 | "User-Agent": 18 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 19 | "Via": "1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)", 20 | "X-Amz-Cf-Id": "nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==", 21 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 22 | "X-Forwarded-Port": "443", 23 | "X-Forwarded-Proto": "https" 24 | }, 25 | "pathParameters": { "proxy": "hello" }, 26 | "requestContext": { 27 | "accountId": "123456789012", 28 | "resourceId": "us4z18", 29 | "stage": "test", 30 | "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", 31 | "identity": { 32 | "cognitoIdentityPoolId": "", 33 | "accountId": "", 34 | "cognitoIdentityId": "", 35 | "caller": "", 36 | "apiKey": "", 37 | "sourceIp": "192.168.100.1", 38 | "cognitoAuthenticationType": "", 39 | "cognitoAuthenticationProvider": "", 40 | "userArn": "", 41 | "userAgent": 42 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 43 | "user": "" 44 | }, 45 | "resourcePath": "/{proxy+}", 46 | "httpMethod": "GET", 47 | "apiId": "wt6mne2s9k" 48 | }, 49 | "resource": "/{proxy+}", 50 | "httpMethod": "GET", 51 | "queryStringParameters": { "name": "me" }, 52 | "stageVariables": { "stageVarName": "stageVarValue" } 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/upload.spec.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk-mock'; 2 | import { promisify } from 'util'; 3 | import lambda from '../src/upload'; 4 | import eventStub from './stubs/eventHttpApiGateway.json'; 5 | 6 | const handler = promisify(lambda); 7 | 8 | describe(`Service aws-node-singned-uploads: S3 mock for successful operations`, () => { 9 | beforeAll(() => { 10 | AWS.mock('S3', 'getSignedUrl', (method, _, callback) => { 11 | callback(null, { 12 | data: 'https://example.com', 13 | }); 14 | }); 15 | }); 16 | 17 | afterEach(() => { 18 | delete process.env.BUCKET; 19 | delete process.env.REGION; 20 | }); 21 | 22 | afterAll(() => { 23 | AWS.restore('S3'); 24 | }); 25 | 26 | test(`Require environment variables`, () => { 27 | const event = {}; 28 | const context = {}; 29 | 30 | expect(handler(event, context)).rejects.toThrow( 31 | `Missing required environment variables: BUCKET, REGION` 32 | ); 33 | }); 34 | 35 | test(`Require a header "x-amz-meta-key"`, () => { 36 | process.env.BUCKET = 'foo'; 37 | process.env.REGION = 'bar'; 38 | const event = {}; 39 | const context = {}; 40 | 41 | const result = handler(event, context); 42 | result.then(data => expect(data).toMatchSnapshot()); 43 | }); 44 | 45 | test(`Replies back with a JSON for a signed upload on success`, () => { 46 | process.env.BUCKET = 'foo'; 47 | process.env.REGION = 'bar'; 48 | const event = eventStub; 49 | const context = {}; 50 | 51 | const result = handler(event, context); 52 | result.then(data => expect(data).toMatchSnapshot()); 53 | }); 54 | }); 55 | 56 | describe(`Service aws-node-singned-uploads: S3 mock for failed operations`, () => { 57 | beforeAll(() => { 58 | AWS.mock('S3', 'getSignedUrl', (method, _, callback) => { 59 | callback(`S3 failed`); 60 | }); 61 | }); 62 | 63 | afterEach(() => { 64 | delete process.env.BUCKET; 65 | delete process.env.REGION; 66 | }); 67 | 68 | afterAll(() => { 69 | AWS.restore('S3'); 70 | }); 71 | 72 | test(`Correctly handles error messages from S3`, () => { 73 | process.env.BUCKET = 'foo'; 74 | process.env.REGION = 'bar'; 75 | const event = eventStub; 76 | const context = {}; 77 | 78 | const result = handler(event, context); 79 | result.then(data => expect(data).toMatchSnapshot()); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Node Signed Uploads 2 | 3 | [![codecov](https://codecov.io/gh/kalinchernev/aws-node-signed-uploads/branch/master/graph/badge.svg)](https://codecov.io/gh/kalinchernev/aws-node-signed-uploads) 4 | [![Build Status](https://travis-ci.org/kalinchernev/aws-node-signed-uploads.svg?branch=master)](https://travis-ci.org/kalinchernev/aws-node-signed-uploads) 5 | 6 | ## Requirements 7 | 8 | * Node.js (`nvm use` to use `carbon` suggested by project's `.nvmrc`) 9 | * npm which comes with Node.js 10 | 11 | ## Introduction 12 | 13 | If you have landed to this project out of curiosity for the technologies behind the service, you can see implementation details in [this article](https://kalinchernev.github.io/tdd-serverless-jest). 14 | 15 | The approach implemented in this service is useful when you want to use [Amazon API Gateway](https://aws.amazon.com/api-gateway/) and you want to solve the 10MB payload limit. 16 | 17 | The service is based on the [serverless](https://serverless.com/) framework. The service is uploading objects to a specific S3 bucket using a [pre-signed URL](http://docs.aws.amazon.com/AmazonS3/latest/dev/PresignedUrlUploadObject.html). Implemented in node.js runtime using [getSignedUrl](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property) method. 18 | 19 | The package is targeting the latest runtime of AWS Lambda. ([8.10](https://aws.amazon.com/blogs/compute/node-js-8-10-runtime-now-available-in-aws-lambda/)) 20 | 21 | ## Settings 22 | 23 | If you prefer to use a different region or stage, change these: 24 | 25 | ```sh 26 | $ export AWS_STAGE= 27 | $ export AWS_REGION= 28 | ``` 29 | 30 | Defaults are `dev` and `eu-central-1`. 31 | 32 | Change name of upload bucket: 33 | 34 | ```yaml 35 | bucketName: testBucket 36 | ``` 37 | 38 | ### File name to sign 39 | 40 | The file you want to upload is signed via `x-amz-meta-filekey` header. 41 | 42 | ### How to use 43 | 44 | Get dependencies with `yarn` or `npm install`. The following examples will assume the usage of `yarn`. 45 | 46 | Issue a `GET` request to get the signed URL: 47 | 48 | ```sh 49 | curl --request GET \ 50 | --url https://{serviceID}.execute-api.{region}.amazonaws.com/dev/upload \ 51 | --header 'x-amz-meta-filekey: the-road-to-graphql.pdf' 52 | ``` 53 | 54 | If your bucket is called `foo`, and you upload `the-road-to-graphql`, after receiving the signed URL, issue a `PUT` request with the information you have signed: 55 | 56 | ```sh 57 | curl --request PUT \ 58 | --url 'https://foo.s3.eu-central-1.amazonaws.com/the-road-to-graphql.pdf?X-Amz-SignedHeaders=host&X-Amz-Signature=the-signature&X-Amz-Security-Token=the-token&X-Amz-Expires=30&X-Amz-Date=20181210T113015Z&X-Amz-Credential=something10%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Algorithm=AWS4-HMAC-SHA256' \ 59 | --data 'somemething-awesome' 60 | ``` 61 | 62 | ### Integrations 63 | 64 | Here's a short list of possible integrations I found making a quick Google search: 65 | 66 | * [Using pre-signed URLs to upload a file to a private S3 bucket](https://sanderknape.com/2017/08/using-pre-signed-urls-upload-file-private-s3-bucket/) 67 | * [react-s3-uploader](https://www.npmjs.com/package/react-s3-uploader) 68 | 69 | ### Tests 70 | 71 | Running all tests: 72 | 73 | ```bash 74 | $ yarn test 75 | ``` 76 | 77 | Developing tests: 78 | 79 | ```bash 80 | $ npx jest --watch 81 | ``` 82 | 83 | ### Develop locally 84 | 85 | Starting a local dev server and its endpoint for receiving uploads: 86 | 87 | ```bash 88 | $ yarn start 89 | ``` 90 | 91 | ### Linter 92 | 93 | Starting the linter tasks: 94 | 95 | ```bash 96 | $ yarn lint 97 | ``` 98 | 99 | ### Deployment 100 | 101 | [Setup your AWS credentials](https://serverless.com/framework/docs/providers/aws/guide/credentials/). 102 | 103 | Run the following the fire the deployment: 104 | 105 | ```bash 106 | $ yarn deploy 107 | ``` 108 | --------------------------------------------------------------------------------