Creating a CDK stack for PDF uploads with Signed URLs from front-end to AWS S3

Overview

CDK is a real beauty for composing AWS services. One of my projects needed a simple PDF upload service for client consent documents. I decided to use AWS S3 with CDK so I created a simple CDK stack then used a Lambda function URL to generate a signed URL for the client to upload the PDF file to S3. By using signed urls, we can avoid the need for a backend service to handle the file upload also we can avoid using AWS credentials in the front-end.

Here is the code for the Lambda URL handler. It's a simple NodeJS Lambda function that returns a signed URL for the client to upload the PDF file to S3.

import "dotenv/config";
import * as cdk from "aws-cdk-lib";
import * as path from "path";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";
import { Duration, CfnOutput } from "aws-cdk-lib";

export class ConsentServiceStack extends cdk.Stack {
  signedUrlLambda: NodejsFunction;
  s3Bucket: s3.Bucket;
  allowedOrigins: string[] = [
    process.env.FRONTEND_URL!,
  ];

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    this.makeServices();
    this.makeBucket();
  }

  makeServices() {
    this.signedUrlLambda = new NodejsFunction(this, "S3SignedUrlService", {
      functionName: process.env.SIGNED_URL_SERVICE_NAME!,
      entry: path.join(__dirname, "../app/index.ts"),
      handler: "main",
      timeout: Duration.seconds(10),
      environment: {
        CLIENT_UPLOAD_PW: process.env.CLIENT_UPLOAD_PW!,
        S3_BUCKET_NAME: process.env.S3_BUCKET_NAME!,
      },
    });

    const functionUrl = new lambda.FunctionUrl(this, "S3SignedUrlServiceURL", {
      function: this.signedUrlLambda,
      authType: lambda.FunctionUrlAuthType.NONE,
      cors: {
        allowedOrigins: this.allowedOrigins,
      },
    });

    new CfnOutput(this, "FunctionUrl ", { value: functionUrl.url });
  }

  makeBucket() {
    this.s3Bucket = new s3.Bucket(this, "UploadBucket", {
      bucketName: process.env.S3_BUCKET_NAME,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS,
      accessControl: s3.BucketAccessControl.BUCKET_OWNER_FULL_CONTROL,
      cors: [
        {
          allowedHeaders: ["*"],
          allowedMethods: [s3.HttpMethods.PUT],
          allowedOrigins: this.allowedOrigins,
          exposedHeaders: [],
          maxAge: 3600,
        },
      ],
    });

    // Allow public read access to the S3 bucket
    const bucketPublicPolicy = new iam.PolicyStatement({
      actions: ["s3:PutObject"],
      resources: [this.s3Bucket.bucketArn, this.s3Bucket.arnForObjects("*")],
      effect: iam.Effect.ALLOW,
      principals: [new iam.AnyPrincipal()],
    });
    this.s3Bucket.addToResourcePolicy(bucketPublicPolicy);
  }
}

Here is the code for the main Lambda function.

import "dotenv/config";
import {
  Context,
  APIGatewayProxyResult,
  APIGatewayProxyEventV2,
} from "aws-lambda";
import { S3 } from "aws-sdk";
import { ulid } from "ulid";

export async function main(
  event: APIGatewayProxyEventV2,
  context?: Context
): Promise<APIGatewayProxyResult> {
  try {
    switch (event.requestContext.http.path) {
      case "/":
        const fileName = event.queryStringParameters?.fileName;
        if (!fileName) {
          throw new Error("File name not provided.");
        }
        const s3 = new S3();
        const randomID = ulid();
        const Key = `${fileName}-${randomID}.pdf`;

        // Get signed URL from S3
        const s3Params = {
          Bucket: process.env.S3_BUCKET_NAME!,
          Key,
          Expires: 60,
          ContentType: "application/pdf",
        };

        const uploadURL = await s3.getSignedUrlPromise("putObject", s3Params);

        return {
          statusCode: 200,
          body: JSON.stringify({ uploadURL, filename: Key }),
        };

      default:
        return {
          statusCode: 404,
          body: JSON.stringify({
            message: `Path (${event.requestContext.http.path}) not found.`,
          }),
        };
    }
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({
        message: error instanceof Error ? error.message : error,
      }),
    };
  }
}

For the front-end, I used React with TypeScript and for PDF generation from HTML, I used html2pdf library. Here is the onSubmit handler for the form.

export async function prepareAndUploadForm(name: string) {
  const clientName = name.trim().replace(/\s/, "_");
  const result = await savePDF({
    element: document.querySelector("#form")!,
    filename: clientName,
    output: "blob",
  });
  const uploadURLResponse = await getUploadUrl(clientName, localPW);

  if ("message" in uploadURLResponse && uploadURLResponse?.message) {
    throw new Error(uploadURLResponse.message as string);
  }

  if (!uploadURLResponse?.uploadURL) {
    throw new Error(`uploadURL is undefined.`);
  }
  if (!uploadURLResponse?.filename) {
    throw new Error(`filename is undefined.`);
  }

  const { uploadURL, filename } = uploadURLResponse;

  const uploadResponse = await uploadFile({
    file: result,
    filename: filename,
    endpoint: uploadURL,
  });

  if (!uploadResponse) {
    throw new Error(`Unknown error occured.`);
  }
}

and here are the helper functions for the PDF generation and file upload.

export interface SavePDFArgs {
  filename: string;
  element: HTMLElement | Element;
  output: "arraybuffer" | "blob" | "bloburi" | "datauristring" | "datauri";
}

export const defaultPDFOptions = {
  margin: [6, 12, 6, 12],
  image: { type: "jpeg", quality: 0.98 },
  html2canvas: { scale: 1 },
  jsPDF: { orientation: "p", unit: "mm", format: "a4" },
};

export async function savePDF(args: SavePDFArgs) {
  const opt = {
    ...defaultPDFOptions,
    filename: args.filename,
  };
  try {
    return await html2pdf().set(opt).from(args.element).outputPdf(args.output);
  } catch (error) {
    console.error(error);
    return null;
  }
}

export interface UploadFileArgs {
  file: Blob;
  filename: string;
  endpoint: string;
}

export async function uploadFile(args: UploadFileArgs): Promise<boolean> {
  const result = await fetch(args.endpoint, {
    method: "PUT",
    body: args.file,
    headers: {
      "Content-Type": "application/pdf",
    },
  });
  const response = await result.text();
  return response === "";
}

export interface UploadURLResponse {
  uploadURL: string;
  filename: string;
}

export async function getUploadUrl(
  fileName: string,
  password: string
): Promise<UploadURLResponse> {
  const params = new URLSearchParams({ fileName, pw: password });
  const url = process.env.SERVICE_LAMBDA_URL ?? `http://localhost:9003`;
  const response = await fetch(url + "?" + params.toString());
  const responseData = (await response.json()) as UploadURLResponse;
  return responseData;
}

I hope you enjoy with this post. If you have any questions, feel free to contact me.