├── .eslintrc.yml ├── .gitignore ├── README.md ├── docs ├── apple-320x240.png ├── apple.png ├── architecture.png ├── bbc-320x240.png ├── bbc.png ├── reddit-320x240.png ├── reddit.png ├── youtube-320x240.png └── youtube.png ├── events ├── create.json └── put_object.json ├── handler.js ├── output └── template.yml ├── package.json ├── phantomjs ├── phantomjs_linux-x86_64 ├── phantomjs_osx └── screenshot.js ├── serverless.yml └── test.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: airbnb 2 | plugins: 3 | - react 4 | rules: 5 | no-console: off 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .serverless 2 | node_modules 3 | .env 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-screenshot 2 | Serverless Screenshot Service 3 | 4 | This will setup a screenshot api which will take a screenshot from a given url, and push it into an S3 bucket. This is all done with Lambda calls. After the screenshot is created, another lambda function creates thumbnails from the given screenshot. 5 | 6 | The screenshotting is done with PhantomJS (which is precompiled in this project), and the resizing is done with ImageMagick (which is available by default in Lambda). 7 | 8 | Quick installation 🚀 9 | ==================== 10 | If you just want to launch the service yourself, you can use this magic button which will setup everything for you in your AWS account through the magic of CloudFormation: 11 | 12 | [![Launch Awesomeness](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/new?stackName=serverless-screenshot-service&templateURL=https://s3-eu-west-1.amazonaws.com/serverless-screenshots-service/2016-09-23T12%3A50%3A03/template.yml) 13 | 14 | # Examples 15 | The Service will generate full screenshots for you (full page length), and create thumbnails from that original, both cropped (eg: 320x240), as well as resized thumbnails (eg: 200px wide). 16 | 17 | [![https://www.youtube.com/](https://github.com/svdgraaf/serverless-screenshot/raw/master/docs/youtube-320x240.png)](https://github.com/svdgraaf/serverless-screenshot/blob/master/docs/youtube.png) 18 | [![http://www.apple.com/](https://github.com/svdgraaf/serverless-screenshot/raw/master/docs/apple-320x240.png)](https://github.com/svdgraaf/serverless-screenshot/blob/master/docs/apple.png) 19 | [![https://www.reddit.com/](https://github.com/svdgraaf/serverless-screenshot/raw/master/docs/reddit-320x240.png)](https://github.com/svdgraaf/serverless-screenshot/blob/master/docs/reddit.png) 20 | [![http://www.bbc.com/](https://github.com/svdgraaf/serverless-screenshot/raw/master/docs/bbc-320x240.png)](https://github.com/svdgraaf/serverless-screenshot/blob/master/docs/bbc.png) 21 | 22 | Notice that the BBC screenshot handles Unicode fonts correctly. 23 | 24 | ## Architecture 25 | ![architecture](https://github.com/svdgraaf/serverless-screenshot/blob/master/docs/architecture.png?raw=true) 26 | 27 | The stack setsup 3 lambda functions, two (POST/GET) can be called via ApiGateway. The third is triggered whenever a file is uploaded into the S3 bucket. The user can request the screenshots through CloudFront. 28 | 29 | # Setup 30 | Just install all requirements with npm: 31 | 32 | ```bash 33 | npm install 34 | ``` 35 | 36 | # Installation 37 | This project uses Serverless for setting up the service. Check the `serverless.yml` for the bucket name, and change it to whatever you want to call it. You can then deploy the stack with: 38 | 39 | ```bash 40 | sls deploy -s dev 41 | # ... 42 | # endpoints: 43 | # POST - https://123j6pi123.execute-api.us-east-1.amazonaws.com/dev/screenshots 44 | # GET - https://123j6pi123.execute-api.us-east-1.amazonaws.com/dev/screenshots 45 | ``` 46 | 47 | After this, you should have a CloudFormation stack up and running. All endpoints are protected with an x-api-token key, which you should provide, and you can find it in the ApiGateway console. 48 | 49 | # Usage 50 | 51 | ## Create screenshot 52 | If you post a url to the /screenshots/ endpoint, it will create a screenshot for you, in the example above: 53 | 54 | ```bash 55 | curl -X POST "https://123j6pi123.execute-api.us-east-1.amazonaws.com/dev/screenshots?url=http://google.com/" -H "x-api-key: [your-api-key]" 56 | ``` 57 | 58 | ## List available screenshot sizes 59 | After creating a screenshot, you can see all the available sizes with a GET: 60 | ```bash 61 | curl -X GET "https://123j6pi123.execute-api.us-east-1.amazonaws.com/dev/screenshots?url=http://google.com/" -H "x-api-key: [your-api-key]" 62 | { 63 | "100": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/100.png", 64 | "200": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/200.png", 65 | "320": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/320.png", 66 | "400": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/400.png", 67 | "640": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/640.png", 68 | "800": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/800.png", 69 | "1024": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/1024.png", 70 | "1024x768": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/1024x768.png", 71 | "320x240": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/320x240.png", 72 | "640x480": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/640x480.png", 73 | "800x600": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/800x600.png", 74 | "original": "https://s3.amazonaws.com/dev-123456-screenshots/6ab016b2dad7ba49a992ba0213a91cf8/original.png" 75 | } 76 | ``` 77 | 78 | # Caveats 79 | * This service uses the awesome PhantomJS service to capture the screenshots, which is compiled on the Lambda service. There probably will be issues with fonts of some sorts. 80 | * The default timeout for PhantomJS is set to 3 seconds, if the page takes longer to load, this will result in b0rked screenshots (ofcourse). You can change the timeout in handler.js (a PR with custom timeout is greatly appreciated) 81 | * Currently, when the thumbnailer fails, it will fail silently, and will result in missing thumbnails. Could be anything really, memory, timeout, etc. PR's to fix this are welcome :) Easiest fix: just setup the Lambda function to allow for more memory usage. 82 | -------------------------------------------------------------------------------- /docs/apple-320x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/apple-320x240.png -------------------------------------------------------------------------------- /docs/apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/apple.png -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/architecture.png -------------------------------------------------------------------------------- /docs/bbc-320x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/bbc-320x240.png -------------------------------------------------------------------------------- /docs/bbc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/bbc.png -------------------------------------------------------------------------------- /docs/reddit-320x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/reddit-320x240.png -------------------------------------------------------------------------------- /docs/reddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/reddit.png -------------------------------------------------------------------------------- /docs/youtube-320x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/youtube-320x240.png -------------------------------------------------------------------------------- /docs/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/docs/youtube.png -------------------------------------------------------------------------------- /events/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "query": { 3 | "url": "https://www.indebuurt.nl/delft/delftenaren/delftenaar-van-de-week/dj-woody-s-gravemade-het-is-megaziek-om-iedereen-te-zien-dansen-op-je-muziek~12401/" 4 | }, 5 | "stageVariables": { 6 | "screenshotTimeout": 3000 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /events/put_object.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": "110eba5f8772213e2ba4d89029e68c06/original.png", 15 | "size": 1234 16 | }, 17 | "bucket": { 18 | "arn": "arn:aws:s3:::dev-foobar-screenshots", 19 | "name": "dev-foobar-screenshots", 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 | -------------------------------------------------------------------------------- /handler.js: -------------------------------------------------------------------------------- 1 | const exec = require('child_process').exec; 2 | const crypto = require('crypto'); 3 | const fs = require('fs'); 4 | const AWS = require('aws-sdk'); 5 | const validUrl = require('valid-url'); 6 | 7 | // overall constants 8 | const screenWidth = 1280; 9 | const screenHeight = 1024; 10 | 11 | // screenshot the given url 12 | module.exports.take_screenshot = (event, context, cb) => { 13 | const targetUrl = event.query.url; 14 | const timeout = event.stageVariables.screenshotTimeout; 15 | 16 | // check if the given url is valid 17 | if (!validUrl.isUri(targetUrl)) { 18 | cb(`422, please provide a valid url, not: ${targetUrl}`); 19 | return false; 20 | } 21 | 22 | const targetBucket = event.stageVariables.bucketName; 23 | const targetHash = crypto.createHash('md5').update(targetUrl).digest('hex'); 24 | const targetFilename = `${targetHash}/original.png`; 25 | console.log(`Snapshotting ${targetUrl} to s3://${targetBucket}/${targetFilename}`); 26 | 27 | // build the cmd for phantom to render the url 28 | const cmd = `./phantomjs/phantomjs_linux-x86_64 --debug=yes --ignore-ssl-errors=true ./phantomjs/screenshot.js ${targetUrl} /tmp/${targetHash}.png ${screenWidth} ${screenHeight} ${timeout}`; // eslint-disable-line max-len 29 | // const cmd =`./phantomjs/phantomjs_osx --debug=yes --ignore-ssl-errors=true ./phantomjs/screenshot.js ${targetUrl} /tmp/${targetHash}.png ${screenWidth} ${screenHeight} ${timeout}`; 30 | console.log(cmd); 31 | 32 | // run the phantomjs command 33 | exec(cmd, (error, stdout, stderr) => { 34 | if (error) { 35 | // the command failed (non-zero), fail the entire call 36 | console.warn(`exec error: ${error}`, stdout, stderr); 37 | cb(`422, please try again ${error}`); 38 | } else { 39 | // snapshotting succeeded, let's upload to S3 40 | // read the file into buffer (perhaps make this async?) 41 | const fileBuffer = fs.readFileSync(`/tmp/${targetHash}.png`); 42 | 43 | // upload the file 44 | const s3 = new AWS.S3(); 45 | s3.putObject({ 46 | ACL: 'public-read', 47 | Key: targetFilename, 48 | Body: fileBuffer, 49 | Bucket: targetBucket, 50 | ContentType: 'image/png', 51 | }, (err) => { 52 | if (err) { 53 | console.warn(err); 54 | cb(err); 55 | } else { 56 | // console.info(stderr); 57 | // console.info(stdout); 58 | cb(null, { 59 | hash: targetHash, 60 | key: `${targetFilename}`, 61 | bucket: targetBucket, 62 | url: `${event.stageVariables.endpoint}${targetFilename}`, 63 | }); 64 | } 65 | return; 66 | }); 67 | } 68 | }); 69 | }; 70 | 71 | 72 | // gives a list of urls for the given snapshotted url 73 | module.exports.list_screenshots = (event, context, cb) => { 74 | const targetUrl = event.query.url; 75 | 76 | // check if the given url is valid 77 | if (!validUrl.isUri(targetUrl)) { 78 | cb(`422, please provide a valid url, not: ${targetUrl}`); 79 | return false; 80 | } 81 | 82 | const targetHash = crypto.createHash('md5').update(targetUrl).digest('hex'); 83 | const targetBucket = event.stageVariables.bucketName; 84 | const targetPath = `${targetHash}/`; 85 | 86 | const s3 = new AWS.S3(); 87 | s3.listObjects({ 88 | Bucket: targetBucket, 89 | Prefix: targetPath, 90 | EncodingType: 'url', 91 | }, (err, data) => { 92 | if (err) { 93 | cb(err); 94 | } else { 95 | const urls = {}; 96 | // for each key, get the image width and add it to the output object 97 | data.Contents.forEach((content) => { 98 | const parts = content.Key.split('/'); 99 | const size = parts.pop().split('.')[0]; 100 | urls[size] = `${event.stageVariables.endpoint}${content.Key}`; 101 | }); 102 | cb(null, urls); 103 | } 104 | return; 105 | }); 106 | }; 107 | 108 | module.exports.create_thumbnails = (event, context, cb) => { 109 | // define all the thumbnails that we want 110 | const widths = { 111 | '320x240': `-crop ${screenWidth}x${screenHeight}+0x0 -thumbnail 320x240`, 112 | '640x480': `-crop ${screenWidth}x${screenHeight}+0x0 -thumbnail 640x480`, 113 | '800x600': `-crop ${screenWidth}x${screenHeight}+0x0 -thumbnail 800x600`, 114 | '1024x768': `-crop ${screenWidth}x${screenHeight}+0x0 -thumbnail 1024x768`, 115 | 100: '-thumbnail 100x', 116 | 200: '-thumbnail 200x', 117 | 320: '-thumbnail 320x', 118 | 400: '-thumbnail 400x', 119 | 640: '-thumbnail 640x', 120 | 800: '-thumbnail 800x', 121 | 1024: '-thumbnail 1024x', 122 | }; 123 | const record = event.Records[0]; 124 | 125 | // we only want to deal with originals 126 | if (record.s3.object.key.indexOf('original.png') === -1) { 127 | console.warn('Not an original, skipping'); 128 | cb('Not an original, skipping'); 129 | return false; 130 | } 131 | 132 | // get the prefix, and get the hash 133 | const prefix = record.s3.object.key.split('/')[0]; 134 | const hash = prefix; 135 | 136 | // download the original to disk 137 | const s3 = new AWS.S3(); 138 | const sourcePath = '/tmp/original.png'; 139 | const targetStream = fs.createWriteStream(sourcePath); 140 | const fileStream = s3.getObject({ 141 | Bucket: record.s3.bucket.name, 142 | Key: record.s3.object.key, 143 | }).createReadStream(); 144 | fileStream.pipe(targetStream); 145 | 146 | // when file is downloaded, start processing 147 | fileStream.on('end', () => { 148 | // resize to every configured size 149 | Object.keys(widths).forEach((size) => { 150 | const cmd = `convert ${widths[size]} ${sourcePath} /tmp/${hash}-${size}.png`; 151 | console.log('Running ', cmd); 152 | 153 | exec(cmd, (error, stdout, stderr) => { 154 | if (error) { 155 | // the command failed (non-zero), fail 156 | console.warn(`exec error: ${error}, stdout, stderr`); 157 | // continue 158 | } else { 159 | // resize was succesfull, upload the file 160 | console.info(`Resize to ${size} OK`); 161 | var fileBuffer = fs.readFileSync(`/tmp/${hash}-${size}.png`); 162 | s3.putObject({ 163 | ACL: 'public-read', 164 | Key: `${prefix}/${size}.png`, 165 | Body: fileBuffer, 166 | Bucket: record.s3.bucket.name, 167 | ContentType: 'image/png' 168 | }, function(err, data){ 169 | if(err) { 170 | console.warn(err); 171 | } else { 172 | console.info(`${size} uploaded`) 173 | } 174 | }); 175 | } 176 | }) 177 | }); 178 | }); 179 | }; 180 | -------------------------------------------------------------------------------- /output/template.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: '2010-09-09' 3 | Description: Serverless Screenshot Service 4 | Parameters: 5 | ScreenshotBucketName: 6 | AllowedPattern: "[A-Za-z0-9-]+" 7 | Type: String 8 | Description: Name of the bucket where the screenshots will be stored, this must 9 | be unique 10 | Resources: 11 | IamRoleLambdaExecution: 12 | Type: AWS::IAM::Role 13 | Properties: 14 | AssumeRolePolicyDocument: 15 | Version: '2012-10-17' 16 | Statement: 17 | - Effect: Allow 18 | Principal: 19 | Service: 20 | - lambda.amazonaws.com 21 | Action: 22 | - sts:AssumeRole 23 | Path: "/" 24 | IamPolicyLambdaExecution: 25 | Type: AWS::IAM::Policy 26 | Properties: 27 | PolicyName: lambda-screenshots-lambda 28 | PolicyDocument: 29 | Version: '2012-10-17' 30 | Statement: 31 | - Effect: Allow 32 | Action: 33 | - logs:CreateLogGroup 34 | - logs:CreateLogStream 35 | - logs:PutLogEvents 36 | Resource: arn:aws:logs:us-east-1:*:* 37 | - Effect: Allow 38 | Action: 39 | - s3:ListBucket 40 | - s3:Put* 41 | - s3:GetObject 42 | Resource: 43 | - Fn::Join: 44 | - '' 45 | - - 'arn:aws:s3:::' 46 | - Ref: ScreenshotBucketName 47 | - Fn::Join: 48 | - '' 49 | - - 'arn:aws:s3:::' 50 | - Ref: ScreenshotBucketName 51 | - "/*" 52 | Roles: 53 | - Ref: IamRoleLambdaExecution 54 | TakeScreenshotLambdaFunction: 55 | Type: AWS::Lambda::Function 56 | Properties: 57 | Code: 58 | S3Bucket: serverless-screenshots-service 59 | S3Key: 2016-09-23T12:50:03/lambda-screenshots.zip 60 | FunctionName: lambda-screenshots-takeScreenshot 61 | Handler: handler.take_screenshot 62 | MemorySize: 1500 63 | Role: 64 | Fn::GetAtt: 65 | - IamRoleLambdaExecution 66 | - Arn 67 | Runtime: nodejs4.3 68 | Timeout: 15 69 | ListScreenshotsLambdaFunction: 70 | Type: AWS::Lambda::Function 71 | Properties: 72 | Code: 73 | S3Bucket: serverless-screenshots-service 74 | S3Key: 2016-09-23T12:50:03/lambda-screenshots.zip 75 | FunctionName: lambda-screenshots-listScreenshots 76 | Handler: handler.list_screenshots 77 | MemorySize: 1024 78 | Role: 79 | Fn::GetAtt: 80 | - IamRoleLambdaExecution 81 | - Arn 82 | Runtime: nodejs4.3 83 | Timeout: 15 84 | CreateThumbnailsLambdaFunction: 85 | Type: AWS::Lambda::Function 86 | Properties: 87 | Code: 88 | S3Bucket: serverless-screenshots-service 89 | S3Key: 2016-09-23T12:50:03/lambda-screenshots.zip 90 | FunctionName: lambda-screenshots-createThumbnails 91 | Handler: handler.create_thumbnails 92 | MemorySize: 1500 93 | Role: 94 | Fn::GetAtt: 95 | - IamRoleLambdaExecution 96 | - Arn 97 | Runtime: nodejs4.3 98 | Timeout: 59 99 | S3BucketScreenshots: 100 | Type: AWS::S3::Bucket 101 | Properties: 102 | BucketName: 103 | Ref: ScreenshotBucketName 104 | NotificationConfiguration: 105 | LambdaConfigurations: 106 | - Event: s3:ObjectCreated:* 107 | Function: 108 | Fn::GetAtt: 109 | - CreateThumbnailsLambdaFunction 110 | - Arn 111 | CreateThumbnailsLambdaPermissionS3: 112 | Type: AWS::Lambda::Permission 113 | Properties: 114 | FunctionName: 115 | Fn::GetAtt: 116 | - CreateThumbnailsLambdaFunction 117 | - Arn 118 | Action: lambda:InvokeFunction 119 | Principal: s3.amazonaws.com 120 | ApiGatewayRestApi: 121 | Type: AWS::ApiGateway::RestApi 122 | Properties: 123 | Name: lambda-screenshots 124 | ApiGatewayResourceScreenshots: 125 | Type: AWS::ApiGateway::Resource 126 | Properties: 127 | ParentId: 128 | Fn::GetAtt: 129 | - ApiGatewayRestApi 130 | - RootResourceId 131 | PathPart: screenshots 132 | RestApiId: 133 | Ref: ApiGatewayRestApi 134 | ApiGatewayMethodScreenshotsPost: 135 | Type: AWS::ApiGateway::Method 136 | Properties: 137 | AuthorizationType: NONE 138 | HttpMethod: POST 139 | MethodResponses: 140 | - ResponseModels: {} 141 | ResponseParameters: {} 142 | StatusCode: 200 143 | - StatusCode: 400 144 | - StatusCode: 401 145 | - StatusCode: 403 146 | - StatusCode: 404 147 | - StatusCode: 422 148 | - StatusCode: 500 149 | - StatusCode: 502 150 | - StatusCode: 504 151 | RequestParameters: 152 | method.request.querystring.url: true 153 | Integration: 154 | IntegrationHttpMethod: POST 155 | Type: AWS 156 | Uri: 157 | Fn::Join: 158 | - '' 159 | - - 'arn:aws:apigateway:' 160 | - Ref: AWS::Region 161 | - ":lambda:path/2015-03-31/functions/" 162 | - Fn::GetAtt: 163 | - TakeScreenshotLambdaFunction 164 | - Arn 165 | - "/invocations" 166 | RequestTemplates: 167 | application/json: "\n #define( $loop )\n {\n #foreach($key 168 | in $map.keySet())\n \"$util.escapeJavaScript($key)\":\n 169 | \ \"$util.escapeJavaScript($map.get($key))\"\n #if( 170 | $foreach.hasNext ) , #end\n #end\n }\n #end\n\n 171 | \ {\n \"body\": $input.json(\"$\"),\n \"method\": 172 | \"$context.httpMethod\",\n \"principalId\": \"$context.authorizer.principalId\",\n 173 | \ \"stage\": \"$context.stage\",\n\n #set( $map 174 | = $input.params().header )\n \"headers\": $loop,\n\n #set( 175 | $map = $input.params().querystring )\n \"query\": $loop,\n\n 176 | \ #set( $map = $input.params().path )\n \"path\": 177 | $loop,\n\n #set( $map = $context.identity )\n \"identity\": 178 | $loop,\n\n #set( $map = $stageVariables )\n \"stageVariables\": 179 | $loop\n }\n " 180 | application/x-www-form-urlencoded: "\n #define( $body )\n {\n 181 | \ #foreach( $token in $input.path('$').split('&') )\n #set( 182 | $keyVal = $token.split('=') )\n #set( $keyValSize = $keyVal.size() 183 | )\n #if( $keyValSize >= 1 )\n #set( $key 184 | = $util.urlDecode($keyVal[0]) )\n #if( $keyValSize >= 185 | 2 )\n #set( $val = $util.urlDecode($keyVal[1]) )\n 186 | \ #else\n #set( $val = '' )\n #end\n 187 | \ \"$key\": \"$val\"#if($foreach.hasNext),#end\n #end\n 188 | \ #end\n }\n #end\n\n #define( 189 | $loop )\n {\n #foreach($key in $map.keySet())\n 190 | \ \"$util.escapeJavaScript($key)\":\n \"$util.escapeJavaScript($map.get($key))\"\n 191 | \ #if( $foreach.hasNext ) , #end\n #end\n 192 | \ }\n #end\n\n {\n \"body\": 193 | $body,\n \"method\": \"$context.httpMethod\",\n \"principalId\": 194 | \"$context.authorizer.principalId\",\n \"stage\": \"$context.stage\",\n\n 195 | \ #set( $map = $input.params().header )\n \"headers\": 196 | $loop,\n\n #set( $map = $input.params().querystring )\n \"query\": 197 | $loop,\n\n #set( $map = $input.params().path )\n \"path\": 198 | $loop,\n\n #set( $map = $context.identity )\n \"identity\": 199 | $loop,\n\n #set( $map = $stageVariables )\n \"stageVariables\": 200 | $loop\n }\n " 201 | PassthroughBehavior: NEVER 202 | IntegrationResponses: 203 | - StatusCode: 200 204 | ResponseParameters: {} 205 | ResponseTemplates: {} 206 | - StatusCode: 400 207 | SelectionPattern: ".*\\[400\\].*" 208 | - StatusCode: 401 209 | SelectionPattern: ".*\\[401\\].*" 210 | - StatusCode: 403 211 | SelectionPattern: ".*\\[403\\].*" 212 | - StatusCode: 404 213 | SelectionPattern: ".*\\[404\\].*" 214 | - StatusCode: 422 215 | SelectionPattern: ".*\\[422\\].*" 216 | - StatusCode: 500 217 | SelectionPattern: ".*(Process\\s?exited\\s?before\\s?completing\\s?request|\\[500\\]).*" 218 | - StatusCode: 502 219 | SelectionPattern: ".*\\[502\\].*" 220 | - StatusCode: 504 221 | SelectionPattern: ".*\\[504\\].*" 222 | ResourceId: 223 | Ref: ApiGatewayResourceScreenshots 224 | RestApiId: 225 | Ref: ApiGatewayRestApi 226 | ApiKeyRequired: true 227 | ApiGatewayMethodScreenshotsGet: 228 | Type: AWS::ApiGateway::Method 229 | Properties: 230 | AuthorizationType: NONE 231 | HttpMethod: GET 232 | MethodResponses: 233 | - ResponseModels: {} 234 | ResponseParameters: {} 235 | StatusCode: 200 236 | - StatusCode: 400 237 | - StatusCode: 401 238 | - StatusCode: 403 239 | - StatusCode: 404 240 | - StatusCode: 422 241 | - StatusCode: 500 242 | - StatusCode: 502 243 | - StatusCode: 504 244 | RequestParameters: 245 | method.request.querystring.url: true 246 | Integration: 247 | IntegrationHttpMethod: POST 248 | Type: AWS 249 | Uri: 250 | Fn::Join: 251 | - '' 252 | - - 'arn:aws:apigateway:' 253 | - Ref: AWS::Region 254 | - ":lambda:path/2015-03-31/functions/" 255 | - Fn::GetAtt: 256 | - ListScreenshotsLambdaFunction 257 | - Arn 258 | - "/invocations" 259 | RequestTemplates: 260 | application/json: "\n #define( $loop )\n {\n #foreach($key 261 | in $map.keySet())\n \"$util.escapeJavaScript($key)\":\n 262 | \ \"$util.escapeJavaScript($map.get($key))\"\n #if( 263 | $foreach.hasNext ) , #end\n #end\n }\n #end\n\n 264 | \ {\n \"body\": $input.json(\"$\"),\n \"method\": 265 | \"$context.httpMethod\",\n \"principalId\": \"$context.authorizer.principalId\",\n 266 | \ \"stage\": \"$context.stage\",\n\n #set( $map 267 | = $input.params().header )\n \"headers\": $loop,\n\n #set( 268 | $map = $input.params().querystring )\n \"query\": $loop,\n\n 269 | \ #set( $map = $input.params().path )\n \"path\": 270 | $loop,\n\n #set( $map = $context.identity )\n \"identity\": 271 | $loop,\n\n #set( $map = $stageVariables )\n \"stageVariables\": 272 | $loop\n }\n " 273 | application/x-www-form-urlencoded: "\n #define( $body )\n {\n 274 | \ #foreach( $token in $input.path('$').split('&') )\n #set( 275 | $keyVal = $token.split('=') )\n #set( $keyValSize = $keyVal.size() 276 | )\n #if( $keyValSize >= 1 )\n #set( $key 277 | = $util.urlDecode($keyVal[0]) )\n #if( $keyValSize >= 278 | 2 )\n #set( $val = $util.urlDecode($keyVal[1]) )\n 279 | \ #else\n #set( $val = '' )\n #end\n 280 | \ \"$key\": \"$val\"#if($foreach.hasNext),#end\n #end\n 281 | \ #end\n }\n #end\n\n #define( 282 | $loop )\n {\n #foreach($key in $map.keySet())\n 283 | \ \"$util.escapeJavaScript($key)\":\n \"$util.escapeJavaScript($map.get($key))\"\n 284 | \ #if( $foreach.hasNext ) , #end\n #end\n 285 | \ }\n #end\n\n {\n \"body\": 286 | $body,\n \"method\": \"$context.httpMethod\",\n \"principalId\": 287 | \"$context.authorizer.principalId\",\n \"stage\": \"$context.stage\",\n\n 288 | \ #set( $map = $input.params().header )\n \"headers\": 289 | $loop,\n\n #set( $map = $input.params().querystring )\n \"query\": 290 | $loop,\n\n #set( $map = $input.params().path )\n \"path\": 291 | $loop,\n\n #set( $map = $context.identity )\n \"identity\": 292 | $loop,\n\n #set( $map = $stageVariables )\n \"stageVariables\": 293 | $loop\n }\n " 294 | PassthroughBehavior: NEVER 295 | IntegrationResponses: 296 | - StatusCode: 200 297 | ResponseParameters: {} 298 | ResponseTemplates: {} 299 | - StatusCode: 400 300 | SelectionPattern: ".*\\[400\\].*" 301 | - StatusCode: 401 302 | SelectionPattern: ".*\\[401\\].*" 303 | - StatusCode: 403 304 | SelectionPattern: ".*\\[403\\].*" 305 | - StatusCode: 404 306 | SelectionPattern: ".*\\[404\\].*" 307 | - StatusCode: 422 308 | SelectionPattern: ".*\\[422\\].*" 309 | - StatusCode: 500 310 | SelectionPattern: ".*(Process\\s?exited\\s?before\\s?completing\\s?request|\\[500\\]).*" 311 | - StatusCode: 502 312 | SelectionPattern: ".*\\[502\\].*" 313 | - StatusCode: 504 314 | SelectionPattern: ".*\\[504\\].*" 315 | ResourceId: 316 | Ref: ApiGatewayResourceScreenshots 317 | RestApiId: 318 | Ref: ApiGatewayRestApi 319 | ApiKeyRequired: true 320 | ApiGatewayDeployment1474632585433: 321 | Type: AWS::ApiGateway::Deployment 322 | Properties: 323 | RestApiId: 324 | Ref: ApiGatewayRestApi 325 | StageName: devna 326 | DependsOn: 327 | - ApiGatewayMethodScreenshotsPost 328 | - ApiGatewayMethodScreenshotsGet 329 | ApiGatewayApiKey: 330 | Type: AWS::ApiGateway::ApiKey 331 | Properties: 332 | Enabled: true 333 | Name: app-api-key 334 | StageKeys: 335 | - RestApiId: 336 | Ref: ApiGatewayRestApi 337 | StageName: dev 338 | DependsOn: ApiGatewayStage 339 | TakeScreenshotLambdaPermissionApiGateway: 340 | Type: AWS::Lambda::Permission 341 | Properties: 342 | FunctionName: 343 | Fn::GetAtt: 344 | - TakeScreenshotLambdaFunction 345 | - Arn 346 | Action: lambda:InvokeFunction 347 | Principal: apigateway.amazonaws.com 348 | ListScreenshotsLambdaPermissionApiGateway: 349 | Type: AWS::Lambda::Permission 350 | Properties: 351 | FunctionName: 352 | Fn::GetAtt: 353 | - ListScreenshotsLambdaFunction 354 | - Arn 355 | Action: lambda:InvokeFunction 356 | Principal: apigateway.amazonaws.com 357 | CloudFrontEndpoint: 358 | Type: AWS::CloudFront::Distribution 359 | Properties: 360 | DistributionConfig: 361 | Enabled: true 362 | DefaultCacheBehavior: 363 | TargetOriginId: ScreenshotBucketOrigin 364 | ViewerProtocolPolicy: redirect-to-https 365 | ForwardedValues: 366 | QueryString: true 367 | Origins: 368 | - Id: ScreenshotBucketOrigin 369 | DomainName: 370 | Fn::Join: 371 | - '' 372 | - - Ref: ScreenshotBucketName 373 | - ".s3.amazonaws.com" 374 | CustomOriginConfig: 375 | OriginProtocolPolicy: http-only 376 | ApiGatewayStage: 377 | Type: AWS::ApiGateway::Stage 378 | Properties: 379 | StageName: dev 380 | Description: dev 381 | RestApiId: 382 | Ref: ApiGatewayRestApi 383 | DeploymentId: 384 | Ref: ApiGatewayDeployment1474632585433 385 | Variables: 386 | bucketName: 387 | Ref: ScreenshotBucketName 388 | endpoint: 389 | Fn::Join: 390 | - '' 391 | - - https:// 392 | - Fn::GetAtt: CloudFrontEndpoint.DomainName 393 | - "/" 394 | screenshotTimeout: 3000 395 | Outputs: 396 | TakeScreenshotLambdaFunctionArn: 397 | Description: Lambda function info 398 | Value: 399 | Fn::GetAtt: 400 | - TakeScreenshotLambdaFunction 401 | - Arn 402 | ListScreenshotsLambdaFunctionArn: 403 | Description: Lambda function info 404 | Value: 405 | Fn::GetAtt: 406 | - ListScreenshotsLambdaFunction 407 | - Arn 408 | CreateThumbnailsLambdaFunctionArn: 409 | Description: Lambda function info 410 | Value: 411 | Fn::GetAtt: 412 | - CreateThumbnailsLambdaFunction 413 | - Arn 414 | ServiceEndpoint: 415 | Description: URL of the service endpoint 416 | Value: 417 | Fn::Join: 418 | - '' 419 | - - https:// 420 | - Ref: ApiGatewayRestApi 421 | - ".execute-api.us-east-1.amazonaws.com/dev" 422 | ScreenshotBucket: 423 | Description: Screenshot bucket name 424 | Value: 425 | Ref: ScreenshotBucketName 426 | CloudFrontUrl: 427 | Description: CloudFront url 428 | Value: 429 | Fn::GetAtt: CloudFrontEndpoint.DomainName 430 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-screenshots", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "aws-sdk": "^2.6.4", 6 | "dotenv": "^2.0.0", 7 | "serverless": "^1.0.0-rc.1", 8 | "serverless-plugin-stack-outputs": "^1.3.2", 9 | "serverless-plugin-stage-variables": "^1.6.6", 10 | "valid-url": "^1.0.9" 11 | }, 12 | "devDependencies": { 13 | "eslint": "^3.5.0", 14 | "eslint-config-airbnb": "^11.1.0", 15 | "eslint-config-google": "^0.6.0", 16 | "eslint-plugin-import": "^1.15.0", 17 | "eslint-plugin-jsx-a11y": "^2.2.2", 18 | "eslint-plugin-react": "^6.3.0" 19 | }, 20 | "scripts": { 21 | "eslint": "./node_modules/eslint/bin/eslint.js handler.js" 22 | }, 23 | "license": "MIT", 24 | "repository": { 25 | "git": "https://github.com/svdgraaf/serverless-screenshot" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /phantomjs/phantomjs_linux-x86_64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/phantomjs/phantomjs_linux-x86_64 -------------------------------------------------------------------------------- /phantomjs/phantomjs_osx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svdgraaf/serverless-screenshot/871646cfbff32934d5bd3ab0a281ff858d504c55/phantomjs/phantomjs_osx -------------------------------------------------------------------------------- /phantomjs/screenshot.js: -------------------------------------------------------------------------------- 1 | var page = require('webpage').create(), 2 | system = require('system'), 3 | url, output, width, height; 4 | 5 | page.onResourceReceived = function(response) { 6 | valid = [200, 201, 301, 302] 7 | if(response.id == 1 && valid.indexOf(response.status) == -1 ){ 8 | console.log('Received a non-200 OK response, got: ', response.status); 9 | phantom.exit(1); 10 | } 11 | } 12 | 13 | address = system.args[1]; 14 | output = system.args[2]; 15 | width = system.args[3]; 16 | height = system.args[4]; 17 | timeout = system.args[5]; 18 | 19 | console.log("Args: ", system.args); 20 | console.log("Screenshotting: ", address, ", to: ", output); 21 | 22 | page.viewportSize = { width: parseInt(width), height: parseInt(height) }; 23 | console.log("Viewport: ", JSON.stringify(page.viewportSize)); 24 | 25 | page.open(address, function (status) { 26 | if (status !== 'success') { 27 | console.log('Unable to load the address!'); 28 | phantom.exit(); 29 | } else { 30 | // Scroll to bottom of page, so all resources are triggered for downloading 31 | window.setInterval(function() { 32 | page.evaluate(function() { 33 | console.log('scrolling', window.document.body.scrollTop); 34 | window.document.body.scrollTop = window.document.body.scrollTop + 1024; 35 | }); 36 | }, 255); 37 | 38 | // scroll back to top for consistency, right in time (sometimes) 39 | // logo's dissapear when scrolling down 40 | window.setTimeout(function() { 41 | page.evaluate(function() { 42 | window.document.body.scrollTop = 0; 43 | }); 44 | }, timeout - 5); 45 | 46 | // after the timeout, save the screenbuffer to file 47 | window.setTimeout(function() { 48 | page.render(output); 49 | phantom.exit(); 50 | }, timeout); 51 | } 52 | }); 53 | // 54 | // var page = require('webpage').create(); 55 | // page.open('http://github.com/', function() { 56 | // page.render('github.png'); 57 | // phantom.exit(); 58 | // }); 59 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: lambda-screenshots 2 | 3 | custom: 4 | # change this, so it's unique for your setup 5 | bucket_name: ${opt:stage,self:provider.stage}-${env:USER}-screenshots 6 | 7 | stageVariables: 8 | bucketName: ${self:custom.bucket_name} 9 | endpoint: {"Fn::Join": ["", ["https://", { "Fn::GetAtt": "CloudFrontEndpoint.DomainName" }, "/"]]} 10 | # timeout for phantomjs 11 | screenshotTimeout: 3000 12 | 13 | provider: 14 | name: aws 15 | runtime: nodejs4.3 16 | stage: dev 17 | region: us-east-1 18 | 19 | # We need to lockdown the apigateway, so we can control who can use the api 20 | apiKeys: 21 | - app-api-key 22 | 23 | # We need to give the lambda functions access to list and write to our bucket, it needs: 24 | # - to be able to 'list' the bucket 25 | # - to be able to upload a file (PutObject) 26 | iamRoleStatements: 27 | - Effect: "Allow" 28 | Action: 29 | - "s3:ListBucket" 30 | - "s3:Put*" 31 | - "s3:GetObject" 32 | Resource: 33 | - "arn:aws:s3:::${self:custom.bucket_name}" 34 | - "arn:aws:s3:::${self:custom.bucket_name}/*" 35 | 36 | functions: 37 | takeScreenshot: 38 | handler: handler.take_screenshot 39 | timeout: 15 40 | events: 41 | - http: 42 | path: screenshots 43 | method: post 44 | # Marking the function as private will require an api-key 45 | private: true 46 | 47 | # The url parameter is mandatory 48 | request: 49 | parameters: 50 | # headers: 51 | # foo: false 52 | # bar: true 53 | querystrings: 54 | url: true 55 | # paths: 56 | # bar: false 57 | 58 | listScreenshots: 59 | handler: handler.list_screenshots 60 | timeout: 15 61 | events: 62 | - http: 63 | path: screenshots 64 | method: get 65 | private: true 66 | request: 67 | parameters: 68 | querystrings: 69 | url: true 70 | 71 | createThumbnails: 72 | handler: handler.create_thumbnails 73 | events: 74 | - s3: 75 | bucket: ${self:custom.bucket_name} 76 | event: s3:ObjectCreated:* 77 | 78 | resources: 79 | Outputs: 80 | ScreenshotBucket: 81 | Description: "Screenshot bucket name" 82 | Value: ${self:custom.bucket_name} 83 | CloudFrontUrl: 84 | Description: "CloudFront url" 85 | Value: {"Fn::GetAtt": "CloudFrontEndpoint.DomainName"} 86 | Resources: 87 | # Create an endpoint for the S3 bucket in CloudFront 88 | CloudFrontEndpoint: 89 | Type: AWS::CloudFront::Distribution 90 | Properties: 91 | DistributionConfig: 92 | Enabled: True 93 | DefaultCacheBehavior: 94 | TargetOriginId: ScreenshotBucketOrigin 95 | ViewerProtocolPolicy: redirect-to-https 96 | ForwardedValues: 97 | QueryString: True 98 | Origins: 99 | - 100 | Id: ScreenshotBucketOrigin 101 | DomainName: ${self:custom.bucket_name}.s3.amazonaws.com 102 | CustomOriginConfig: 103 | OriginProtocolPolicy: http-only 104 | 105 | 106 | plugins: 107 | - serverless-plugin-stage-variables 108 | - serverless-plugin-stack-outputs 109 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var fs = require('fs'); 3 | 4 | var handler = require( path.resolve( __dirname, "./handler.js" ) ); 5 | // var input = JSON.parse(fs.readFileSync('events/put_object.json', 'utf8')); 6 | var input = JSON.parse(fs.readFileSync('events/create.json', 'utf8')); 7 | 8 | var callback = function(err, data ) { 9 | if(err) { 10 | console.warn(data); 11 | } else { 12 | console.log(data); 13 | } 14 | }; 15 | 16 | // handler.create_thumbnails(input, null, callback); 17 | handler.take_screenshot(input, null, callback); 18 | // handler.list_screenshots(input, null, callback); 19 | --------------------------------------------------------------------------------