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.