Next.js and AWS image demo - Part 2

dlw

Darren White

Posted on January 2, 2021

Next.js and AWS image demo - Part 2

As this post is getting pretty long I'll now add a part 3 updating the website to use the AWS services setup here. On that note, if you haven't already read part 1 for the website set up as I will refer back to that post.

For the second part, I'll set up the required services in AWS.

  • S3 Bucket for storing the images
  • Lambda function for retrieving the images
  • API Gateway endpoint (added automatically) to access the lambda function

For the API endpoint and bucket setup, I'll be using the Serverless framework.

The services setup here could be used with any front end framework. I've just chosed to use React/Next.Js.

Serverless setup

First, add a server folder in the root of the project

mkdir server && cd server
Enter fullscreen mode Exit fullscreen mode

Then run the serverless setup command

serverless
Enter fullscreen mode Exit fullscreen mode

Serverless will guide you through the options

Serverless: No project detected. Do you want to create a new one? Yes
Serverless: What do you want to make? AWS Node.js
Serverless: What do you want to call this project? dlw-nextjs-image-demo

Project successfully created in 'dlw-nextjs-image-demo' folder.

You can monitor, troubleshoot, and test your new service with a free Serverless account.

Serverless: Would you like to enable this? No
You can run the “serverless” command again if you change your mind later.

Serverless: Would you like the Framework to update automatically? Yes

Auto updates were succesfully turned on.
You may turn off at any time with "serverless config --no-autoupdate"
Enter fullscreen mode Exit fullscreen mode

When complete, there will be a serverless.yml in the server directory. By default, the file contains various services with example configuration code commented out. You can remove all the commented out code as I'll walk you through adding the code for each service. You should be left with similar to the following:

service: dlw-nextjs-aws-image-demo
# app and org for use with dashboard.serverless.com
#app: your-app-name
#org: your-org-name

# You can pin your service to only deploy with a specific Serverless version
# Check out our docs for more details
frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x

functions:
  hello:
    handler: handler.hello
Enter fullscreen mode Exit fullscreen mode

The following is optional, however to begin add a stage and region under the runtime:

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-2
Enter fullscreen mode Exit fullscreen mode

The stage will be used as part of our bucket name and as I'm based in the UK I use either London or Ireland. In this instance I've opted for London.

S3 bucket

To set up the S3 bucket I like to add a custom property which I can reference via a variable. Underneath framework version add the following:

custom:
  upload: blog-nextjs-image-demo
Enter fullscreen mode Exit fullscreen mode

We then need to add the necessary permission using IAM. Under region add the following iamRoleStatements:

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-2

  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:ListBucket
      Resource: "arn:aws:s3:::${opt:stage, self:provider.stage, 'dev'}-${self:custom.upload}"
    - Effect: Allow
      Action:
        - s3:GetObject
      Resource: "arn:aws:s3:::${opt:stage, self:provider.stage, 'dev'}-${self:custom.upload}/*"
Enter fullscreen mode Exit fullscreen mode

The indentation is important, the iamRoleStatments indentation needs to match the region. The iamRoleStatements setup tells AWS which action is allowed for the specified resource. See below for an explanation

Now add the following resource at the end of the serverless.yml:

resources:
  Resources:
    S3BucketOutputs:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: "${opt:stage, self:provider.stage, 'dev'}-${self:custom.upload}"
Enter fullscreen mode Exit fullscreen mode

The bucket name will be determined by the variables supplied. ${opt:stage, self:provider.stage, 'dev'} is determined by the flags set when deploying, for example, if I run sls deploy --stage prod then self:provider.stage is prod, if no flag is supplied then the second parameter is used.

${self:custom.upload} is taken from our custom property supplied. For the demo, I don't supply a flag resulting in a bucket name of dev-blog-nextjs-image-demo

That's it for our S3 bucket, to deploy, run the following command:

sls deploy
Enter fullscreen mode Exit fullscreen mode

Jump over to the AWS console to see the deployed bucket: https://s3.console.aws.amazon.com/s3/

AWS Lambda

We now need to add a couple of Lambda functions to retrieve the images to display on the website. For this we'll need a couple of plugins, underneath framework: "2" add the following:

plugins:
  - serverless-bundle # Package our functions with Webpack
  - serverless-dotenv-plugin
Enter fullscreen mode Exit fullscreen mode

And then install the plugins

npm i serverless-bundle serverless-pseudo-parameters serverless-dotenv-plugin
Enter fullscreen mode Exit fullscreen mode

serverless-bundle allows me to write ES2015 (and newer) JS syntax, in particular module export/imports which are then bundled appropriately for AWS Lambda. serverless-dotenv-plugin allows us to pull in variables stored in a .env file which.

Presigned URL

In order to keep our bucket private, I am going to use a presigned URL. The presigned URL allows temporary public access to our object in the bucket. However I don't want anybody with the presigned URL to be able to access our objects, therefore, I'll add an API Key to secure the API endpoint. For this under custom add a dotenv property

custom:
  upload: blog-nextjs-image-demo
  dotenv:
    path: ../.env
Enter fullscreen mode Exit fullscreen mode

And add a .env file in the root of the project. In the file add the following key replacing your_api_key with something more secure:

API_KEY=your_api_key
Enter fullscreen mode Exit fullscreen mode

Now we can finally write our function. Replace the following

functions:
  hello:
    handler: handler.hello
Enter fullscreen mode Exit fullscreen mode

with our function code.

functions:
  signedUrl:
    handler: handler.signedUrl
    events:
      - http:
          path: signed-url
          method: get
          cors: true
Enter fullscreen mode Exit fullscreen mode

Our handler function will be called signedURL, we'll be using a get request to the path signed-url from the website. I specify CORs to allow cross origin resource sharing, however as I'm using Next.js and will be using a getServerSideProps the request won't be coming from the client's browser, therefore, Cors isn't an issue. For client-side only websites, cors will be required.

Now open handler.js and remove all the example code. Add an import to the AWS SDK

import { S3 } from 'aws-sdk';
Enter fullscreen mode Exit fullscreen mode

I'm using object destructuring to pull in the S3 object from the aws-sdk as that is all I need. Add a reference to our bucket name which we'll get from the process environment variables in node.

const Bucket = process.env.BUCKET_NAME;
Enter fullscreen mode Exit fullscreen mode

For the handler function add

export const signedUrl = async (event) => {
  // if (event.headers['X-API-KEY'] !== process.env.API_KEY) {
  //   return {
  //     statusCode: 403
  //   };
  // }

  const { key } = event.queryStringParameters;
  const s3 = new S3({});
  const presignedGetUrl = await s3.getSignedUrl('getObject', {
    Bucket,
    Key: key,
    Expires: 60 * 5 // time to expire in seconds 5
  });

  return {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Origin": 'http://localhost:3000',
      "Access-Control-Allow-Headers": "*",
      "Access-Control-Allow-Methods": "*",
      "Access-Control-Allow-Credentials": true,
    },
    body: JSON.stringify(presignedGetUrl),
  };
};
Enter fullscreen mode Exit fullscreen mode

For now I've commented out the API key check to allow us to test without being locked out. First with get the image key from the query sting parameters:

const { key } = event.queryStringParameters;
Enter fullscreen mode Exit fullscreen mode

We then instantiate a new S3 object which is used to generate the presigned URL:

const presignedGetUrl = await s3.getSignedUrl('getObject', {
    Bucket,
    Key: key,
    Expires: 60 * 5 // time to expire in seconds 5
  });
Enter fullscreen mode Exit fullscreen mode

In the options object, I pass in the name of the bucket, the image key and the length of time until the key expires - currently, I've set it to 5 minutes, however in a production application I'd reduce that significantly.

Now we can re-deploy to AWS. A quick tip if you haven't updated the serverless.yml file you can add the -f flag to the command and the name of the function specified in the serverless.yml for a much quicker deployment

sls deploy -f signedUrl
Enter fullscreen mode Exit fullscreen mode

In a browser/Postman (or equivalent) you can do a GET request to the API gateway URL that calls our AWS Lambda adding the image key onto the end. For example https://y32f66o0ol.execute-api.eu-west-2.amazonaws.com/dev/signed-url?key=daniel-j-schwarz-REjuIrs2YaM-unsplash.jpg. The responding URL can be copied and pasted in a browser to see the image.

If you're not sure of the URL then you can type sls info in the terminal to get your service informarion

Service Information
service: demo-delete
stage: dev
region: eu-west-2
stack: demo-delete-dev
resources: 13
api keys:
  None
endpoints:
  GET - https://y32f66o0ol.execute-api.eu-west-2.amazonaws.com/dev/signed-url
functions:
  signedUrl: demo-delete-dev-signedUrl
layers:
  None
Enter fullscreen mode Exit fullscreen mode

The image key is one of the images, you uploaded in part 1. To confirm you can go to your bucket in Amazon S3 (https://s3.console.aws.amazon.com/s3).

Get All Images

We've actually done the functions in the opposite way we'll call them from the website. To display the images with the presigned URLs, we'll need to get the list from our S3 bucket.

Back to the serverless.yml underneath functions add

functions:
  images:
    handler: handler.images
    environment:
      BUCKET_NAME: ${opt:stage, self:provider.stage, 'dev'}-${self:custom.upload}
    events:
      - http:
          path: images
          method: get
          cors: true
Enter fullscreen mode Exit fullscreen mode

Again the bucket name is determined by the variables supplied. We have a path to our API endpoint and the method used to invoke the request.

In handler.js add

export const images = async (event) => {

  // if (event.headers['X-API-KEY'] !== process.env.API_KEY) {
  //   return {
  //     statusCode: 403
  //   };
  // }

  const data = await getAll();

  return {
    statusCode: 200,
    body: JSON.stringify(data),
  };
};
Enter fullscreen mode Exit fullscreen mode

The S3 listObjectsV2 method requires a callback function therefore in the above I've called a separate function called getAll which returns a promise. If successful the handle returns a status code of 200 and stringifies the data.

In a production app, we need to catch any errors and return the necessary HTTP status code and error.

Above the previous function add

const getAll = async () => {
  const s3 = new S3({});
  const params = {
    Bucket
  };

  return new Promise((resolve) => {
    s3.listObjectsV2(params, (err, data) => {
      if (err) {
        return resolve({ error: true, message: err });
      }

      return resolve({
        success: true,
        data: data.Contents,
      });
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

As before we instatiate a S3 object and setup some parameters

  const s3 = new S3({});
  const params = {
    Bucket
  };
Enter fullscreen mode Exit fullscreen mode

As mentioned the listObjectsV2 method requires a callback. I've used an anonymous function which I've wrapped in a promise

  return new Promise((resolve) => {
    s3.listObjectsV2(params, (err, data) => {
      if (err) {
        return resolve({ error: true, message: err });
      }

      return resolve({
        success: true,
        data: data.Contents,
      });
    });
  });
Enter fullscreen mode Exit fullscreen mode

If data is returned then the promise is resolved succesully passing the Content property from the data object.

Deploy the function sls deploy and run the API gateway URL. The returned response should look something similar to the following:

{
  "success": true,
  "data": [
    {
      "Key": "altanbagana-jargal-USCPvwqeO0U-unsplash.jpg",
      "LastModified": "2020-12-21T19:16:41.000Z",
      "ETag": "\"943f9736eb07dd0668006e0990af20df\"",
      "Size": 3377153,
      "StorageClass": "STANDARD"
    },
    {
      "Key": "daniel-j-schwarz-REjuIrs2YaM-unsplash.jpg",
      "LastModified": "2020-12-21T19:16:41.000Z",
      "ETag": "\"3988e5f9ba3c1118141dae396265054b\"",
      "Size": 2404910,
      "StorageClass": "STANDARD"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

We now have everything in place to update our website to get the images dynamically.

In the next part we'll update the Next.js website to call our AWS services and secure our API with a key.

💖 💪 🙅 🚩
dlw
Darren White

Posted on January 2, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related