Skip to main content

Building a Serverless File Browser Using Lambda@Edge and CloudFront: A Technical Deep Dive



Introduction


Today, we'll build a sophisticated file browsing system using AWS Lambda@Edge and CloudFront. This solution enables subdomain-based navigation of S3 folders with zero server maintenance. We'll cover everything from implementation details to deployment strategies.


Technical Architecture

Components Overview

┌─────────────┐    ┌─────────────┐    ┌──────────────┐    ┌─────────┐

│ Client      │───>│ CloudFront  │───>│ Lambda@Edge  │───>│ S3      │

└─────────────┘    └─────────────┘    └──────────────┘    └─────────┘

                          │                   │

                          │                   │

                          ▼                   ▼

                   Origin Request     Generate HTML

                   (doc subdomain)    (other subdomains)

 

   

 

   

Implementation Details

1. Project Setup

First, create the project structure:

mkdir lambda-edge-browser

cd lambda-edge-browser

 

# Initialize npm project

npm init -y

 

# Install dependencies

npm install @aws-sdk/client-s3

 

# Create source file

touch index.mjs

 

   

 

   

2. Lambda Function Implementation

Here's the complete Lambda function with detailed comments:

import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3';

 

// Initialize S3 client - must be in us-east-1 for Lambda@Edge

const s3Client = new S3Client({

    region'us-east-1',

    // No credentials needed - will use IAM role

});

 

// Configuration

const CONFIG = {

    BUCKET_NAME'your-bucket-name',

    CACHE_DURATION60,

    ALLOWED_SUBDOMAIN_PATTERN/^[a-zA-Z0-9-_]+$/,

    SPECIAL_SUBDOMAIN'doc'

};

 

// HTML template function

const generateHtml = ({ subdomain, files, error = null }) => {

    if (error) {

        return `

            <!DOCTYPE html>

            <html>

            <head>

                <title>Error</title>

                <meta charset="UTF-8">

                <meta name="viewport" content="width=device-width, initial-scale=1.0">

                <style>

                    ${getErrorStyles()}

                </style>

            </head>

            <body>

                <div class="error-container">

                    <h1>Error</h1>

                    <p>${error}</p>

                </div>

            </body>

            </html>`;

    }

 

    return `

        <!DOCTYPE html>

        <html>

        <head>

            <title>Files in ${subdomain}</title>

            <meta charset="UTF-8">

            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <style>

                ${getStyles()}

            </style>

        </head>

        <body>

            <div class="container">

                <div class="header">

                    <h1>Files in ${subdomain}</h1>

                    <div class="summary">

                        Total files: ${files.length}

                    </div>

                </div>

                <ul class="file-list">

                    ${generateFileList(files)}

                </ul>

            </div>

        </body>

        </html>`;

};

 

// Main handler

export const handler = async (event, context) => {

    try {

        // Extract request details

        const request = event.Records[0].cf.request;

        const headers = request.headers;

        const host = headers.host[0].value;

        const subdomain = host.split('.')[0].toLowerCase();

 

        // Handle special subdomain

        if (subdomain === CONFIG.SPECIAL_SUBDOMAIN) {

            return request; // Pass through to origin

        }

 

        // Validate subdomain

        if (!CONFIG.ALLOWED_SUBDOMAIN_PATTERN.test(subdomain)) {

            throw new Error('Invalid folder name');

        }

 

        // List S3 objects

        const folderPrefix = `${subdomain}/`;

        const command = new ListObjectsV2Command({

            BucketCONFIG.BUCKET_NAME,

            PrefixfolderPrefix

        });

 

        const s3Response = await s3Client.send(command);

 

        // Validate response

        if (!s3Response.Contents || s3Response.Contents.length === 0) {

            throw new Error('Folder not found or empty');

        }

 

        // Process files

        const files = s3Response.Contents

            .filter(item => item.Key !== folderPrefix)

            .map(item => ({

                nameitem.Key.replace(folderPrefix''),

                sizeitem.Size,

                lastModifieditem.LastModified,

                url`https://${CONFIG.BUCKET_NAME}.s3.amazonaws.com/${item.Key}`

            }))

            .sort((a, b) => b.lastModified - a.lastModified);

 

        // Generate response

        return {

            status'200',

            statusDescription'OK',

            headers: {

                'content-type': [{

                    key'Content-Type',

                    value'text/html'

                }],

                'cache-control': [{

                    key'Cache-Control',

                    value`max-age=${CONFIG.CACHE_DURATION}`

                }]

            },

            bodygenerateHtml({ subdomain, files })

        };

 

    } catch (error) {

        console.error('Error:', error);

       

        return {

            statuserror.message.includes('not found') ? '404' : '500',

            statusDescriptionerror.message.includes('not found') ? 'Not Found' : 'Internal Server Error',

            headers: {

                'content-type': [{

                    key'Content-Type',

                    value'text/html'

                }]

            },

            bodygenerateHtml(errorerror.message })

        };

    }

};

 

// Utility functions

function formatSize(bytes) {

    if (bytes === 0return '0 Bytes';

    const k = 1024;

    const sizes = ['Bytes''KB''MB''GB'];

    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];

}

 

function generateFileList(files) {

    return files.map(file => `

        <li class="file-item">

            <a href="${file.url}" class="file-link">

                ${file.name}

            </a>

            <div class="file-metadata">

                Size: ${formatSize(file.size)} |

                Last Modified: ${new Date(file.lastModified).toLocaleString()}

            </div>

        </li>

    `).join('');

}

 

function getStyles() {

    return `

        :root {

            --primary-color: #0066cc;

            --background-color: #f8f9fa;

            --border-color: #e9ecef;

            --text-color: #333;

            --metadata-color: #666;

        }

 

        body {

            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,

                Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;

            margin: 0;

            padding: 0;

            color: var(--text-color);

            line-height: 1.6;

        }

 

        .container {

            max-width: 1200px;

            margin: 0 auto;

            padding: 2rem;

        }

 

        .header {

            border-bottom: 2px solid var(--border-color);

            padding-bottom: 1rem;

            margin-bottom: 2rem;

        }

 

        .summary {

            background: var(--background-color);

            padding: 0.5rem 1rem;

            border-radius: 4px;

            display: inline-block;

        }

 

        .file-list {

            list-style-type: none;

            padding: 0;

        }

 

        .file-item {

            margin: 1rem 0;

            padding: 1rem;

            background: var(--background-color);

            border-radius: 6px;

            border: 1px solid var(--border-color);

            transition: transform 0.2s ease-in-out;

        }

 

        .file-item:hover {

            transform: translateX(5px);

        }

 

        .file-link {

            color: var(--primary-color);

            text-decoration: none;

            font-weight: 500;

        }

 

        .file-link:hover {

            text-decoration: underline;

        }

 

        .file-metadata {

            color: var(--metadata-color);

            font-size: 0.9em;

            margin-top: 0.5rem;

        }

    `;

}

 

function getErrorStyles() {

    return `

        body {

            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

            margin: 0;

            padding: 2rem;

            display: flex;

            justify-content: center;

            align-items: center;

            min-height: 100vh;

        }

 

        .error-container {

            background: #f8d7da;

            color: #721c24;

            padding: 2rem;

            border-radius: 8px;

            max-width: 600px;

            text-align: center;

        }

    `;

}

 

   

 

   

3. Deployment Configuration

CloudFormation Template

AWSTemplateFormatVersion: '2010-09-09'

Description: 'Lambda@Edge File Browser'

 

Parameters:

  BucketName:

    Type: String

    Description: 'S3 bucket name'

 

Resources:

  LambdaExecutionRole:

    Type: 'AWS::IAM::Role'

    Properties:

      AssumeRolePolicyDocument:

        Version: '2012-10-17'

        Statement:

          - Effect: Allow

            Principal:

              Service:

                - lambda.amazonaws.com

                - edgelambda.amazonaws.com

            Action: 'sts:AssumeRole'

      ManagedPolicyArns:

        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'

      Policies:

        - PolicyName: S3Access

          PolicyDocument:

            Version: '2012-10-17'

            Statement:

              - Effect: Allow

                Action: 's3:ListBucket'

                Resource: !Sub 'arn:aws:s3:::${BucketName}'

 

  FileBrowserFunction:

    Type: 'AWS::Lambda::Function'

    Properties:

      Handler: index.handler

      Role: !GetAtt LambdaExecutionRole.Arn

      Code:

        ZipFile: !Ref LambdaZipFile

      Runtime: nodejs18.x

      MemorySize: 128

      Timeout: 5

 

   

 

   

4. Deployment Scripts

#!/bin/bash

# deploy.sh

 

# Build

npm install

zip -r function.zip index.mjs node_modules/

 

# Deploy

aws lambda update-function-code \

    --function-name file-browser \

    --zip-file fileb://function.zip

 

# Publish version

VERSION=$(aws lambda publish-version \

    --function-name file-browser \

    --query 'Version' \

    --output text)

 

echo "Published version: $VERSION"

 

# Update CloudFront

aws cloudfront update-distribution \

    --id YOUR_DISTRIBUTION_ID \

    --distribution-config file://cf-config.json

 

   

 

   

Performance Optimizations

1. Memory Usage

The function uses minimal memory by:

  • Streaming S3 responses
  • Using ES modules for tree-shaking
  • Avoiding unnecessary dependencies

2. Caching Strategy

// Browser caching

'cache-control': [{

    key'Cache-Control',

    value'max-age=60'

}]

 

// CloudFront caching

const distributionConfig = {

    DefaultCacheBehavior: {

        MinTTL60,

        DefaultTTL300,

        MaxTTL600

    }

};

 

   

 

   

3. Error Handling

The function implements comprehensive error handling:

try {

    // ... main logic

catch (error) {

    if (error.name === 'NoSuchBucket') {

        // Handle missing bucket

    } else if (error.name === 'NoSuchKey') {

        // Handle missing folder

    } else {

        // Handle unexpected errors

    }

}

 

   

 

   

Monitoring and Logging

CloudWatch Metrics to Monitor

  1. Execution duration
  2. Memory usage
  3. Error rates
  4. Request counts

// Add custom metrics

const metrics = {

    filesCountfiles.length,

    processingTimeDate.now() - startTime,

    subdomain: subdomain

};

 

console.log('Metrics:'JSON.stringify(metrics));

 

   

 

   

Security Considerations

1. Input Validation

// Subdomain validation

const ALLOWED_SUBDOMAIN_PATTERN = /^[a-zA-Z0-9-_]+$/;

if (!ALLOWED_SUBDOMAIN_PATTERN.test(subdomain)) {

    throw new Error('Invalid folder name');

}

 

   

 

   

2. Content Security Policy

headers['content-security-policy'] = [{

    key'Content-Security-Policy',

    value"default-src 'self'; img-src 'self' data: *.amazonaws.com"

}];

 

   

 

   

Testing

// test.js

import { handler } from './index.mjs';

 

async function test() {

    const event = {

        Records: [{

            cf: {

                request: {

                    headers: {

                        host[{ value'test.example.com' }]

                    }

                }

            }

        }]

    };

 

    const response = await handler(event);

    console.log(JSON.stringify(response, null2));

}

 

test().catch(console.error);

 

   

 

   

Conclusion

This implementation provides a robust, scalable solution for browsing S3 files through subdomains. The code is production-ready with proper error handling, security measures, and performance optimizations.

Remember to:

  • Monitor CloudWatch Logs for errors
  • Set up appropriate alarms
  • Regularly review security configurations
  • Update dependencies as needed

Future Enhancements

  1. Add authentication support
  2. Implement file preview capabilities
  3. Add search functionality
  4. Support for nested folders
  5. Custom sorting and filtering options

 

Comments

Popular posts from this blog

Implementation Guide for GenAI Bedrock Voicebot project

Setup Instructions for genai-bedrock-voicebot This guide will walk you through the steps to set up the  genai-bedrock-voicebot  projects using AWS Amplify and App Runner. Table of Contents Fork the Repository Login to AWS Create a GitHub Connection in AWS App Runner Create the Admin Console Amplify App Configure Environment Variables Modify Project Name Retrieve API Endpoints Create the Chat UI Amplify App Configure Environment Variables Modify Project Name Update Environment Variable in Admin Console 1. Fork the Repository Navigate to the repository:  genai-bedrock-voicebot . Click on the  Fork  button at the top right corner. Select your GitHub account to create the fork. Once forked, note down your fork's URL (e.g.,  https://github.com/<YourGitHubUsername>/genai-bedrock-voicebot ). 2. Login to AWS Open the  AWS Management Console . Enter your AWS account credentials to log in. 2.1. Enable Bedrock "Mixtral 8x7B Instruct" LLM Model Access 1. Nav...

Identifying AWS Service and Region from a Given IP Address: A JavaScript Solution

  In today's digital age, managing cloud resources efficiently is critical for businesses of all sizes. Amazon Web Services (AWS), a leading cloud service provider, offers a plethora of services spread across various regions worldwide. One of the challenges that cloud administrators often face is identifying the specific AWS service and region associated with a given IP address. This information is vital for configuring firewalls, setting up VPNs, and ensuring secure network communication. In this blog post, we will explore how to identify the AWS service and region for a provided IP address using the AWS-provided JSON file and a simple JavaScript solution. This approach will help you streamline your cloud management tasks and enhance your network security. The Problem: Identifying AWS Services and Regions AWS provides a comprehensive range of services, each operating from multiple IP ranges across different regions. These IP ranges are frequently updated, and keeping track of them...

Mastering curl: Efficiently Handle Cookies and CSRF Tokens for Seamless POST Requests

In many scenarios, you might need to handle cookies in your curl requests and use those cookies in subsequent requests. For example, you might need to get a CSRF token from the initial request and include it in the body of a subsequent POST request. Here’s a step-by-step guide on how to achieve this using curl . Step-by-Step Guide Get the Headers and Save Cookies: First, you need to get the headers from the initial request and save the cookies to a file. This can be done using the -I option to fetch headers and -c to specify the cookie file. sh Copy code curl -c cookies.txt -I http://example.com This command will save the cookies from http://example.com into a file named cookies.txt . Extract the CSRF Token: Next, you need to extract the csrf_token cookie value from the cookies.txt file. This can be done using grep and awk : sh Copy code CSRF_TOKEN=$(grep 'csrf_token' cookies.txt | awk '{print $7}' ) This command finds the line containing csrf_token in cookies....