import * as cdk from "aws-cdk-lib"; 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 * as s3n from "aws-cdk-lib/aws-s3-notifications"; import * as sqs from "aws-cdk-lib/aws-sqs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import { Construct } from "constructs"; import * as path from "path"; interface ImageProcessorLambdaStackProps extends cdk.StackProps { /** * Environment name (staging, prod) */ environment: string; /** * Database URL for the Lambda */ databaseUrl: string; /** * Frontend URL for CORS configuration */ frontendUrl: string; /** * VPC for Lambda function (required for network isolation) */ vpc: ec2.IVpc; /** * Security group for Lambda function */ lambdaSecurityGroup: ec2.ISecurityGroup; } export class ImageProcessorLambdaStack extends cdk.Stack { /** * The Lambda function for image processing */ public readonly lambdaFunction: lambda.Function; /** * The S3 bucket for image uploads */ public readonly uploadsBucket: s3.Bucket; /** * Dead letter queue for failed Lambda invocations */ public readonly deadLetterQueue: sqs.Queue; constructor( scope: Construct, id: string, props: ImageProcessorLambdaStackProps ) { super(scope, id, props); const { environment, databaseUrl, frontendUrl, vpc, lambdaSecurityGroup } = props; // Dead Letter Queue for failed Lambda invocations this.deadLetterQueue = new sqs.Queue(this, "ImageProcessorDLQ", { queueName: `image-processor-dlq-${environment}`, retentionPeriod: cdk.Duration.days(14), }); // S3 bucket for uploads this.uploadsBucket = new s3.Bucket(this, "UploadsBucket", { bucketName: `village-share-${environment}`, versioned: true, encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: new s3.BlockPublicAccess({ blockPublicAcls: true, blockPublicPolicy: false, // Allow bucket policy for public reads ignorePublicAcls: true, restrictPublicBuckets: false, }), cors: [ { allowedMethods: [ s3.HttpMethods.GET, s3.HttpMethods.PUT, s3.HttpMethods.POST, ], allowedOrigins: [frontendUrl, "http://localhost:3000"], allowedHeaders: ["*"], exposedHeaders: ["ETag"], maxAge: 3600, }, ], lifecycleRules: [ { // Clean up incomplete multipart uploads abortIncompleteMultipartUploadAfter: cdk.Duration.days(1), }, { // Delete staging files that weren't processed after 7 days prefix: "staging/", expiration: cdk.Duration.days(7), }, ], }); // Bucket policy: allow public read for non-staging files this.uploadsBucket.addToResourcePolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, principals: [new iam.AnyPrincipal()], actions: ["s3:GetObject"], resources: [ `${this.uploadsBucket.bucketArn}/profiles/*`, `${this.uploadsBucket.bucketArn}/items/*`, `${this.uploadsBucket.bucketArn}/forum/*`, ], }) ); // Lambda execution role const lambdaRole = new iam.Role(this, "ImageProcessorLambdaRole", { roleName: `image-processor-lambda-role-${environment}`, assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), description: "Execution role for Image Processor Lambda", }); // CloudWatch Logs permissions - scoped to this Lambda's log group lambdaRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", ], resources: [ `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/image-processor-${environment}`, `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/image-processor-${environment}:*`, ], }) ); // S3 permissions lambdaRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:HeadObject", ], resources: [`${this.uploadsBucket.bucketArn}/*`], }) ); // VPC permissions - use AWS managed policy for Lambda VPC access lambdaRole.addManagedPolicy( iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaVPCAccessExecutionRole" ) ); // Lambda function this.lambdaFunction = new lambda.Function(this, "ImageProcessorLambda", { functionName: `image-processor-${environment}`, runtime: lambda.Runtime.NODEJS_20_X, handler: "index.handler", code: lambda.Code.fromAsset( path.join(__dirname, "../../../lambdas/imageProcessor"), { bundling: { image: lambda.Runtime.NODEJS_20_X.bundlingImage, command: [ "bash", "-c", [ "cp -r /asset-input/* /asset-output/", "cd /asset-output", "npm install --omit=dev", // Copy shared modules "mkdir -p shared", "cp -r /asset-input/../shared/* shared/", "cd shared && npm install --omit=dev", ].join(" && "), ], }, } ), role: lambdaRole, timeout: cdk.Duration.seconds(60), memorySize: 1024, // Higher memory for image processing environment: { NODE_ENV: environment, DATABASE_URL: databaseUrl, S3_BUCKET: this.uploadsBucket.bucketName, AWS_REGION: this.region, LOG_LEVEL: environment === "prod" ? "info" : "debug", }, deadLetterQueue: this.deadLetterQueue, retryAttempts: 2, description: "Processes uploaded images: extracts metadata and strips EXIF", // VPC configuration for network isolation vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }, securityGroups: [lambdaSecurityGroup], }); // S3 event notification for staging uploads this.uploadsBucket.addEventNotification( s3.EventType.OBJECT_CREATED, new s3n.LambdaDestination(this.lambdaFunction), { prefix: "staging/", } ); // Outputs new cdk.CfnOutput(this, "LambdaFunctionArn", { value: this.lambdaFunction.functionArn, description: "ARN of the Image Processor Lambda", exportName: `ImageProcessorLambdaArn-${environment}`, }); new cdk.CfnOutput(this, "UploadsBucketName", { value: this.uploadsBucket.bucketName, description: "Name of the uploads S3 bucket", exportName: `UploadsBucketName-${environment}`, }); new cdk.CfnOutput(this, "UploadsBucketArn", { value: this.uploadsBucket.bucketArn, description: "ARN of the uploads S3 bucket", exportName: `UploadsBucketArn-${environment}`, }); new cdk.CfnOutput(this, "DLQUrl", { value: this.deadLetterQueue.queueUrl, description: "URL of the Dead Letter Queue", exportName: `ImageProcessorDLQUrl-${environment}`, }); } }