jonelantha: Blog


How-To: Setup Gatsby on AWS using GitHub Actions [2/3]: Setup CloudFront Function for index rewrites (with optional basic authentication)

13th April 2022 - (revised version of a post originally published 13th May 2021)

This is Part 2 of a How-To guide to help you host your Gatsby site on AWS (with Continuous Deployment provided by GitHub Actions). See Part 1 for more information.

NOTE: This information should be useful for anyone looking to add Apache style index.html handling to a CloudFront distribution (regardless of whether you're using Gatsby or not)

Out of the box CloudFront has very basic handling of index.html pages. We need to add the following behaviour otherwise some Gatsby generated links won't work:

  1. When a user requests a page at /my-blog-post (a directory without a trailing /) we need CloudFront to respond with a 301 response to redirect the user's browser to /my-blog-post/ (the same directory with a trailing slash /)
  2. When a user requests a page at /my-blog-post/ (a directory with a trailing /) we need to serve them the index.html page within that directory: /my-blog-post/index.html.

CloudFront Functions to the rescue

CloudFront Functions allows us to add some logic to CloudFront to modify how CloudFront responds to user requests. We'll a add a small block of code to implement the missing functionality mentioned above.

Note that we could alternatively use Lambda@Edge to set this up - using CloudFront Functions is the new best practice approach (and more cost effective) but if you'd rather use Lambda@Edge please see this older guide

Creating the CloudFront function

  1. In the AWS console go to the CloudFront section
  2. In the left hand panel, click Functions
  3. Click Create function
  4. For Function name enter gatsby-index
  5. Cut and paste the following code into the code window (if you also want to add basic Authentication, use the version from the Appendix):
    var querystringLib = require('querystring');
    
    function handler(event) {
        var request = event.request;
        
        if (looksLikeADirectory(request.uri) && !request.uri.endsWith('/')) {
            var redirect = request.uri + '/';
            
            var querystring = stringifyRequestQuerystring(request.querystring);
            if (querystring) {
                redirect += '?' + querystring;
            }
            
            return makeRedirect301Response(redirect);
        }
        
        if (request.uri.endsWith('/')) {
            request.uri += 'index.html';
        }
        
        return request;
    }
    
    function looksLikeADirectory(path) {
        var filename = path.split('/').pop();
        
        return !filename.includes('.');
    }
    
    function makeRedirect301Response(redirect) {
        return {
            statusCode: 301,
            statusDescription: 'Moved Permanently',
            headers: {
                location: { value: redirect },
            },
        };
    }
    
    function stringifyRequestQuerystring(requestQuerystring) {
        var stringifableQuerystring = {};
        
        for (var key in requestQuerystring) {
            if (requestQuerystring[key].multiValue) {
                stringifableQuerystring[key]
                    = requestQuerystring[key].multiValue.map(mv => mv.value);
            } else {
                stringifableQuerystring[key] = requestQuerystring[key].value;
            }
        }
    
        return querystringLib.stringify(stringifableQuerystring);
    }
    • Click Save changes (orange button top-right)
  6. Click the Publish tab
    • Click Publish function
  7. In the Associated distributions section
    • Click Add association and a popup should appear
    • Distribution: On the list which appears click the ID for your CloudFront distribution (see Part 1 if you need to determine the ID of your CloudFront distribution)
    • Event type: Leave as Viewer Request
    • Cache Behavior: Select Default (*)
    • Click Add association

If you're interested to see how the CloudFront Function was linked to the CloudFront distribution you can do the following:

  • In the AWS console go to the CloudFront section
    • Select the appropriate Distribution in the list
    • Click the Behaviours tab
    • Tick the single checkbox in the behaviours table and then click Edit
    • Scroll down and you should see the link to the CloudFront Function

So how does this work?

The code above will be run each time CloudFront tries to fetch a file from the S3 bucket (the Origin of the CloudFront distribution). The code checks the requested url and it either responds with a 301 redirect or it tells CloudFront to fetch the appropriate index.html file from the S3 bucket.

And how much will this cost?

There is a free tier for CloudFront Functions and once that has expired pricing is low.

OK CloudFront function created. What's next?

Next we need to setup the Continuous Deployment environment using GitHub Actions.

Let's move on to Part 3: Continuous Deployment with GitHub Actions.


Appendix - Adding Basic Authentication

If you want to password protect your whole site using Basic Authentication you can use this code instead:

var querystringLib = require('querystring');

var authorizedUsers = [
    'user:password',
    //... add more entries here as necessary
    // all entries should be of the form '<username>:<password>'
];

function handler(event) {
    var request = event.request;
    
    if (!isRequestAuthorized(request.headers)) {
        return makeNotAuthorizedResponse();
    }
    
    if (looksLikeADirectory(request.uri) && !request.uri.endsWith('/')) {
        var redirect = request.uri + '/';
        
        var querystring = stringifyRequestQuerystring(request.querystring);
        if (querystring) {
            redirect += '?' + querystring;
        }
        
        return makeRedirect301Response(redirect);
    }
    
    if (request.uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    
    return request;
}

function isRequestAuthorized(requestHeaders) {
    if (!requestHeaders.authorization) return false;
    
    if (!requestHeaders.authorization.value.startsWith('Basic ')) return false;
    
    var authValue = requestHeaders.authorization.value.substring(6);
    
    var credentials = String.bytesFrom(authValue, 'base64');
    
    return authorizedUsers.includes(credentials);
}

function makeNotAuthorizedResponse() {
    return {
        statusCode: 401,
        statusDescription: 'Unauthorized',
        headers: {
            'www-authenticate': { value: 'Basic' },
        },
    };
}

function looksLikeADirectory(path) {
    var filename = path.split('/').pop();
    
    return !filename.includes('.');
}

function makeRedirect301Response(redirect) {
    return {
        statusCode: 301,
        statusDescription: 'Moved Permanently',
        headers: {
            location: { value: redirect },
        },
    };
}

function stringifyRequestQuerystring(requestQuerystring) {
    var stringifableQuerystring = {};
    
    for (var key in requestQuerystring) {
        if (requestQuerystring[key].multiValue) {
            stringifableQuerystring[key]
                = requestQuerystring[key].multiValue.map(mv => mv.value);
        } else {
            stringifableQuerystring[key] = requestQuerystring[key].value;
        }
    }

    return querystringLib.stringify(stringifableQuerystring);
}

Don't forget to change the entries in authorizedUsers for your own username and password. If you need to change the entries later on don't forget to Save and Publish again.

All done? Let's move on to Part 3: Continuous Deployment with GitHub Actions.


© 2003-2023 jonelantha