diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..9fab11eec --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Environment Configuration Example +# Copy this file to .env.dev or .env.prod and configure your values + +# Source S3 Buckets +# Comma-separated list of S3 bucket names that the solution can access +# Example: my-images-bucket,my-other-bucket +SOURCE_BUCKETS=lumberscan-image-resizer + +# Deploy Demo UI +# Set to "Yes" to deploy the demo UI, "No" to skip +# Default: No +DEPLOY_DEMO_UI=No + +# Enable S3 Object Lambda (DEPRECATED) +# WARNING: S3 Object Lambda architecture has been deprecated +# Only set to "Yes" if you were an existing user before November 7, 2025 +# Default: No +ENABLE_S3_OBJECT_LAMBDA=No + +# Enable CloudFront Signed URLs (for private content) +# Set to "Yes" to require signed URLs for all image requests +# This provides time-limited access to your images +# Requires TRUSTED_KEY_GROUP_IDS to be configured +# Default: No +ENABLE_SIGNED_URLS=No + +# Trusted CloudFront Key Group IDs +# Comma-separated list of CloudFront key group IDs for signed URL validation +# Required when ENABLE_SIGNED_URLS is Yes +# Example: 1234abcd-56ef-78gh-90ij-klmnopqrstuv,abcd1234-efgh-5678-ijkl-mnopqrstuvwx +# See SIGNED_URLS.md for setup instructions +TRUSTED_KEY_GROUP_IDS= + +# CORS Configuration +# Enable Cross-Origin Resource Sharing (CORS) for the image handler API +# Set to "Yes" to allow cross-origin requests from web browsers +# Default: No +CORS_ENABLED=No + +# CORS Allowed Origin +# Specify which origins can access the API +# Use "*" to allow any origin (less secure but convenient for testing) +# Use a specific domain for production: https://example.com +# Only applies when CORS_ENABLED is Yes +# Default: * +CORS_ORIGIN=* + +# Additional CDK Context (optional) +# You can add any additional CDK context variables here +# These will be available during deployment diff --git a/.gitignore b/.gitignore index a13a99296..113c54f1d 100755 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,11 @@ demo-ui-config.js .temp_redpencil bom.json +# deployment environment files (keep .env.example) +.env.dev +.env.prod +.env.staging + # System Files **/.DS_Store **/.vscode diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..618a7a78c --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,466 @@ +# Deployment Guide + +This guide covers how to deploy the Dynamic Image Transformation for Amazon CloudFront solution to your AWS accounts using the provided deployment script. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Script Commands](#script-commands) +- [Environment Configuration](#environment-configuration) +- [Configuration Parameters](#configuration-parameters) +- [Usage Examples](#usage-examples) +- [Troubleshooting](#troubleshooting) + +## Prerequisites + +Before deploying, ensure you have: + +1. **Node.js 20.x or later** installed +2. **AWS CLI** configured with appropriate credentials +3. **AWS profiles** configured: + - `lumberscan-dev`: For dev account + - `lumberscan-prod`: For prod account +4. **Appropriate AWS permissions** to deploy CDK stacks (CloudFormation, Lambda, S3, CloudFront, etc.) + +### Setting up AWS Profiles + +Configure your AWS profiles if not already done: + +```bash +# Configure dev profile +aws configure --profile lumberscan-dev + +# Configure prod profile +aws configure --profile lumberscan-prod +``` + +## Quick Start + +### 1. Make the script executable (first time only) + +```bash +chmod +x deploy.sh +``` + +### 2. Bootstrap your AWS accounts (first time only) + +```bash +# Bootstrap dev account +./deploy.sh bootstrap dev + +# Bootstrap prod account +./deploy.sh bootstrap prod +``` + +### 3. Deploy to dev (default) + +```bash +# Deploy to dev (default environment) +./deploy.sh + +# Or explicitly specify dev +./deploy.sh deploy dev +``` + +### 4. Deploy to prod (requires confirmation) + +```bash +./deploy.sh deploy prod --confirm +``` + +## Script Commands + +The deployment script supports the following commands: + +### deploy (default) + +Deploys the CDK stack to the specified environment. + +```bash +./deploy.sh deploy [dev|prod] [--confirm] +``` + +- Runs `npm run clean:install` to build the project +- Deploys the CloudFormation stack +- **Requires `--confirm` flag for prod deployments** + +### bootstrap + +Bootstraps CDK in the target AWS account. This is required once per account/region before the first deployment. + +```bash +./deploy.sh bootstrap [dev|prod] +``` + +### diff + +Shows what changes would be made to the deployed stack without actually deploying. + +```bash +./deploy.sh diff [dev|prod] +``` + +Useful for: +- Reviewing changes before deployment +- Understanding infrastructure changes +- Verifying configuration + +### destroy + +Removes the deployed stack from AWS. + +```bash +./deploy.sh destroy [dev|prod] --confirm +``` + +- **Requires `--confirm` flag for prod environment** +- Permanently deletes all resources +- Use with caution! + +### synth + +Synthesizes the CloudFormation template without deploying. + +```bash +./deploy.sh synth [dev|prod] +``` + +Useful for: +- Reviewing generated CloudFormation templates +- Debugging CDK code +- Integration with CI/CD pipelines + +## Environment Configuration + +The script supports two methods for configuration: + +### Method 1: Environment Files (Recommended) + +Create environment-specific configuration files: + +```bash +# Create dev config +cp .env.example .env.dev +# Edit .env.dev with your dev values + +# Create prod config +cp .env.example .env.prod +# Edit .env.prod with your prod values +``` + +Example `.env.prod`: +```bash +SOURCE_BUCKETS=my-prod-images,my-prod-assets +DEPLOY_DEMO_UI=No +ENABLE_S3_OBJECT_LAMBDA=No +``` + +Example `.env.dev`: +```bash +SOURCE_BUCKETS=my-dev-images +DEPLOY_DEMO_UI=Yes +ENABLE_S3_OBJECT_LAMBDA=No +``` + +### Method 2: Environment Variables + +Pass variables directly when running the script: + +```bash +SOURCE_BUCKETS="my-bucket" DEPLOY_DEMO_UI="Yes" ./deploy.sh deploy dev +``` + +## Configuration Parameters + +### SOURCE_BUCKETS + +**Required for most deployments** + +Comma-separated list of S3 bucket names that the solution can access for image transformation. + +- **Type**: String (comma-separated) +- **Example**: `my-images-bucket,my-other-bucket` +- **Default**: None + +```bash +SOURCE_BUCKETS=bucket1,bucket2,bucket3 +``` + +### DEPLOY_DEMO_UI + +Whether to deploy the demo UI for testing the solution. + +- **Type**: Yes/No +- **Options**: `Yes`, `No` +- **Default**: `No` +- **Recommendation**: + - `Yes` for dev environments + - `No` for production environments + +```bash +DEPLOY_DEMO_UI=Yes +``` + +### ENABLE_S3_OBJECT_LAMBDA + +**⚠️ DEPRECATED** - Enable S3 Object Lambda architecture. + +- **Type**: Yes/No +- **Options**: `Yes`, `No` +- **Default**: `No` +- **Important**: This architecture has been deprecated. Only use if you were an existing user before November 7, 2025. + +```bash +ENABLE_S3_OBJECT_LAMBDA=No +``` + +## Usage Examples + +### Basic Deployment + +```bash +# Deploy to dev with demo UI +echo "SOURCE_BUCKETS=my-dev-bucket +DEPLOY_DEMO_UI=Yes" > .env.dev +./deploy.sh deploy dev + +# Deploy to prod without demo UI +echo "SOURCE_BUCKETS=my-prod-bucket +DEPLOY_DEMO_UI=No" > .env.prod +./deploy.sh deploy prod --confirm +``` + +### Using Inline Environment Variables + +```bash +# Deploy to dev with specific configuration +SOURCE_BUCKETS="bucket1,bucket2" \ +DEPLOY_DEMO_UI="Yes" \ +./deploy.sh deploy dev + +# Deploy to prod +SOURCE_BUCKETS="prod-bucket" \ +DEPLOY_DEMO_UI="No" \ +./deploy.sh deploy prod --confirm +``` + +### Preview Changes Before Deployment + +```bash +# See what will change in prod +./deploy.sh diff prod + +# Review, then deploy if satisfied +./deploy.sh deploy prod --confirm +``` + +### Update Existing Deployment + +```bash +# Make changes to your code, then redeploy +./deploy.sh deploy dev + +# Or for prod +./deploy.sh deploy prod --confirm +``` + +### Complete Workflow Example + +```bash +# 1. First time setup - bootstrap accounts +./deploy.sh bootstrap dev +./deploy.sh bootstrap prod + +# 2. Configure dev environment +cat > .env.dev << EOF +SOURCE_BUCKETS=my-dev-images +DEPLOY_DEMO_UI=Yes +EOF + +# 3. Deploy to dev and test +./deploy.sh deploy dev + +# 4. Configure prod environment +cat > .env.prod << EOF +SOURCE_BUCKETS=my-prod-images-1,my-prod-images-2 +DEPLOY_DEMO_UI=No +EOF + +# 5. Preview prod changes +./deploy.sh diff prod + +# 6. Deploy to prod +./deploy.sh deploy prod --confirm +``` + +### CI/CD Integration + +```bash +# In your CI/CD pipeline +export SOURCE_BUCKETS="${PROD_BUCKETS}" +export DEPLOY_DEMO_UI="No" + +# Synthesize and validate +./deploy.sh synth prod + +# Deploy with confirmation +./deploy.sh deploy prod --confirm +``` + +### Cleanup + +```bash +# Remove dev stack +./deploy.sh destroy dev + +# Remove prod stack (requires confirmation) +./deploy.sh destroy prod --confirm +``` + +## Script Options Reference + +### Command Line Options + +| Option | Description | Required For | +|--------|-------------|--------------| +| `--confirm` | Confirms destructive operations | prod deploy, any destroy | +| `--help`, `-h` | Shows help message | - | + +### Positional Arguments + +| Position | Options | Default | Description | +|----------|---------|---------|-------------| +| 1st | `deploy`, `bootstrap`, `diff`, `destroy`, `synth` | `deploy` | Command to execute | +| 2nd | `dev`, `prod` | `dev` | Target environment | + +### Environment Mapping + +| Environment | AWS Profile | Config File | +|-------------|-------------|-------------| +| `dev` | `lumberscan-dev` | `.env.dev` | +| `prod` | `lumberscan-prod` | `.env.prod` | + +## Troubleshooting + +### Error: "Production deploy requires --confirm flag" + +**Solution**: Add `--confirm` flag when deploying to prod: +```bash +./deploy.sh deploy prod --confirm +``` + +### Error: "AWS profile 'lumberscan-prod' is not configured" + +**Solution**: Configure the prod AWS profile: +```bash +aws configure --profile lumberscan-prod +``` + +### Error: "This stack uses assets, so the toolkit stack must be deployed" + +**Solution**: Bootstrap the account first: +```bash +./deploy.sh bootstrap prod +``` + +### Build Errors + +If you encounter npm or build errors: + +```bash +# Clean and rebuild manually +cd source/constructs +rm -rf node_modules cdk.out +npm ci +cd ../.. + +# Then try deploying again +./deploy.sh deploy dev +``` + +### Permission Errors + +Ensure your AWS credentials have the following permissions: +- CloudFormation (full access) +- Lambda (create, update, delete functions) +- S3 (create, configure buckets) +- CloudFront (create, update distributions) +- API Gateway (create, configure APIs) +- IAM (create roles and policies) +- CloudWatch Logs (create log groups) + +### Stack Already Exists + +If you need to update an existing stack: +```bash +# The deploy command will update the existing stack +./deploy.sh deploy prod --confirm +``` + +If you need to completely redeploy: +```bash +# Destroy the existing stack +./deploy.sh destroy prod --confirm + +# Redeploy +./deploy.sh deploy prod --confirm +``` + +## Advanced Usage + +### Custom AWS Region + +The script uses the region configured in your AWS profile. To use a different region: + +```bash +aws configure set region us-west-2 --profile lumberscan-prod +./deploy.sh deploy prod --confirm +``` + +### Using Different Profile Names + +The script is configured to use `lumberscan-dev` and `lumberscan-prod` profiles. If you need to use different profile names, edit the script: + +```bash +# In deploy.sh, modify these lines: +if [ "$ENVIRONMENT" = "dev" ]; then + AWS_PROFILE="lumberscan-dev" # Change to your-dev-profile-name +else + AWS_PROFILE="lumberscan-prod" # Change to your-prod-profile-name +fi +``` + +### Deploying to Additional Environments + +To add a staging environment: + +1. Add a new AWS profile: +```bash +aws configure --profile lumberscan-staging +``` + +2. Create `.env.staging` file: +```bash +SOURCE_BUCKETS=staging-bucket +DEPLOY_DEMO_UI=No +``` + +3. Modify the script to recognize `staging` as a valid environment (update the case statement and profile mapping) + +## Getting Help + +- Script help: `./deploy.sh --help` +- CDK help: `cd source/constructs && npx cdk --help` +- AWS Solutions: https://aws.amazon.com/solutions/implementations/dynamic-image-transformation-for-amazon-cloudfront/ + +## Security Best Practices + +1. **Never commit `.env.dev` or `.env.prod` files** to version control +2. **Use AWS IAM roles** with minimum required permissions +3. **Enable MFA** for production AWS accounts +4. **Review diffs** before deploying to production +5. **Test thoroughly** in dev before promoting to prod +6. **Use separate AWS accounts** for dev and prod when possible +7. **Audit CloudTrail logs** regularly +8. **Rotate credentials** periodically diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 000000000..3af2faf3c --- /dev/null +++ b/deploy.sh @@ -0,0 +1,255 @@ +#!/bin/bash + +# Dynamic Image Transformation for Amazon CloudFront - Deployment Script +# This script handles deployment to dev and prod AWS accounts + +set -e + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +CONSTRUCTS_DIR="$SCRIPT_DIR/source/constructs" + +# Default values +ENVIRONMENT="dev" +COMMAND="deploy" +CONFIRMED=false + +# Function to print colored messages +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +# Function to show usage +show_usage() { + cat << EOF +Usage: $0 [COMMAND] [ENVIRONMENT] [OPTIONS] + +Commands: + deploy Deploy the stack (default) + bootstrap Bootstrap CDK in the target account + diff Show deployment differences + destroy Destroy the stack + synth Synthesize CloudFormation template + +Environment: + dev Deploy to development account (AWS profile: lumberscan-dev) [default] + prod Deploy to production account (AWS profile: lumberscan-prod) + +Options: + --confirm Required for prod deployments (and destroy commands) + --help Show this help message + +Environment Variables (optional): + SOURCE_BUCKETS Comma-separated list of S3 bucket names + DEPLOY_DEMO_UI Yes/No - Deploy demo UI (default: No) + ENABLE_S3_OBJECT_LAMBDA Yes/No - Enable S3 Object Lambda (default: No) + ENABLE_SIGNED_URLS Yes/No - Enable CloudFront signed URLs (default: No) + TRUSTED_KEY_GROUP_IDS Comma-separated CloudFront key group IDs (required if ENABLE_SIGNED_URLS=Yes) + CORS_ENABLED Yes/No - Enable CORS for cross-origin requests (default: No) + CORS_ORIGIN Allowed origin (* for any, or specific domain like https://example.com) + +Examples: + $0 # Deploy to dev (default) + $0 deploy dev # Deploy to dev (explicit) + $0 deploy prod --confirm # Deploy to prod (requires confirmation) + $0 bootstrap dev # Bootstrap dev account + $0 diff prod # Show diff for prod + $0 destroy prod --confirm # Destroy prod stack + +You can also create .env.dev or .env.prod files with environment-specific variables. +EOF +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + deploy|bootstrap|diff|destroy|synth) + COMMAND="$1" + shift + ;; + dev|prod) + ENVIRONMENT="$1" + shift + ;; + --confirm) + CONFIRMED=true + shift + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + print_error "Unknown argument: $1" + show_usage + exit 1 + ;; + esac +done + +# Set AWS profile based on environment +if [ "$ENVIRONMENT" = "dev" ]; then + AWS_PROFILE="lumberscan-dev" + ENV_FILE="$SCRIPT_DIR/.env.dev" +else + AWS_PROFILE="lumberscan-prod" + ENV_FILE="$SCRIPT_DIR/.env.prod" +fi + +# Check for confirmation when deploying/destroying prod +if [ "$ENVIRONMENT" = "prod" ] && ([ "$COMMAND" = "deploy" ] || [ "$COMMAND" = "destroy" ]); then + if [ "$CONFIRMED" = false ]; then + print_error "Production $COMMAND requires --confirm flag" + print_info "Run: $0 $COMMAND prod --confirm" + exit 1 + fi +fi + +# Load environment-specific variables if file exists +if [ -f "$ENV_FILE" ]; then + print_info "Loading environment variables from $ENV_FILE" + set -a + source "$ENV_FILE" + set +a +fi + +# Print deployment information +echo "" +print_info "=========================================" +print_info "CDK $COMMAND - $ENVIRONMENT environment" +print_info "AWS Profile: $AWS_PROFILE" +print_info "=========================================" +echo "" + +# Check if AWS CLI is configured +if ! aws sts get-caller-identity --profile "$AWS_PROFILE" &> /dev/null; then + print_error "AWS profile '$AWS_PROFILE' is not configured or credentials are invalid" + exit 1 +fi + +# Print AWS account information +ACCOUNT_ID=$(aws sts get-caller-identity --profile "$AWS_PROFILE" --query Account --output text) +REGION=$(aws configure get region --profile "$AWS_PROFILE" || echo "us-east-1") +print_info "Account ID: $ACCOUNT_ID" +print_info "Region: $REGION" +echo "" + +# Handle bootstrap command +if [ "$COMMAND" = "bootstrap" ]; then + print_info "Bootstrapping CDK in $ENVIRONMENT environment..." + cd "$CONSTRUCTS_DIR" + overrideWarningsEnabled=false npx cdk bootstrap --profile "$AWS_PROFILE" + print_success "Bootstrap completed successfully" + exit 0 +fi + +# Handle synth command +if [ "$COMMAND" = "synth" ]; then + print_info "Synthesizing CloudFormation template..." + cd "$CONSTRUCTS_DIR" + npm run cdk:synth -- --profile "$AWS_PROFILE" + print_success "Synth completed successfully" + exit 0 +fi + +# Build the project (for deploy, diff, destroy) +if [ "$COMMAND" = "deploy" ] || [ "$COMMAND" = "diff" ]; then + print_info "Building project (this may take a few minutes)..." + cd "$CONSTRUCTS_DIR" + npm run clean:install + print_success "Build completed" + echo "" +fi + +# Prepare CDK parameters +CDK_PARAMS="" + +if [ -n "$SOURCE_BUCKETS" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters SourceBucketsParameter=$SOURCE_BUCKETS" + print_info "Using SOURCE_BUCKETS: $SOURCE_BUCKETS" +fi + +if [ -n "$DEPLOY_DEMO_UI" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters DeployDemoUIParameter=$DEPLOY_DEMO_UI" + print_info "Using DEPLOY_DEMO_UI: $DEPLOY_DEMO_UI" +fi + +if [ -n "$ENABLE_S3_OBJECT_LAMBDA" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters EnableS3ObjectLambdaParameter=$ENABLE_S3_OBJECT_LAMBDA" + print_info "Using ENABLE_S3_OBJECT_LAMBDA: $ENABLE_S3_OBJECT_LAMBDA" +fi + +if [ -n "$ENABLE_SIGNED_URLS" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters EnableSignedUrlsParameter=$ENABLE_SIGNED_URLS" + print_info "Using ENABLE_SIGNED_URLS: $ENABLE_SIGNED_URLS" + + if [ "$ENABLE_SIGNED_URLS" = "Yes" ] && [ -z "$TRUSTED_KEY_GROUP_IDS" ]; then + print_error "TRUSTED_KEY_GROUP_IDS is required when ENABLE_SIGNED_URLS is set to Yes" + exit 1 + fi +fi + +if [ -n "$TRUSTED_KEY_GROUP_IDS" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters TrustedKeyGroupIdsParameter=$TRUSTED_KEY_GROUP_IDS" + print_info "Using TRUSTED_KEY_GROUP_IDS: $TRUSTED_KEY_GROUP_IDS" +fi + +if [ -n "$CORS_ENABLED" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters CorsEnabledParameter=$CORS_ENABLED" + print_info "Using CORS_ENABLED: $CORS_ENABLED" +fi + +if [ -n "$CORS_ORIGIN" ]; then + CDK_PARAMS="$CDK_PARAMS --parameters CorsOriginParameter=$CORS_ORIGIN" + print_info "Using CORS_ORIGIN: $CORS_ORIGIN" +fi + +echo "" + +# Execute the CDK command +cd "$CONSTRUCTS_DIR" + +case $COMMAND in + deploy) + print_info "Deploying to $ENVIRONMENT..." + if [ "$ENVIRONMENT" = "prod" ]; then + print_warning "Deploying to PRODUCTION account: $ACCOUNT_ID" + fi + overrideWarningsEnabled=false npx cdk deploy $CDK_PARAMS --profile "$AWS_PROFILE" + print_success "Deployment completed successfully" + ;; + diff) + print_info "Showing deployment differences for $ENVIRONMENT..." + overrideWarningsEnabled=false npx cdk diff $CDK_PARAMS --profile "$AWS_PROFILE" + ;; + destroy) + print_warning "Destroying stack in $ENVIRONMENT environment..." + if [ "$ENVIRONMENT" = "prod" ]; then + print_warning "DESTROYING PRODUCTION STACK in account: $ACCOUNT_ID" + fi + overrideWarningsEnabled=false npx cdk destroy --profile "$AWS_PROFILE" --force + print_success "Stack destroyed" + ;; +esac + +echo "" +print_success "Command '$COMMAND' completed successfully for $ENVIRONMENT environment" diff --git a/source/constructs/lib/back-end/api-gateway-architecture.ts b/source/constructs/lib/back-end/api-gateway-architecture.ts index 65b9307ef..a525eabe2 100644 --- a/source/constructs/lib/back-end/api-gateway-architecture.ts +++ b/source/constructs/lib/back-end/api-gateway-architecture.ts @@ -135,6 +135,17 @@ export class ApiGatewayArchitecture { { Enabled: false }, ], }); + + // Add trusted key groups for signed URLs if enabled + const keyGroupIds = props.trustedKeyGroupIds.split(",").map((id: string) => id.trim()).filter(Boolean); + cfnDistribution.addOverride( + "Properties.DistributionConfig.DefaultCacheBehavior.TrustedKeyGroups", + Fn.conditionIf( + props.conditions.enableSignedUrlsCondition.logicalId, + keyGroupIds, + Aws.NO_VALUE + ) + ); Aspects.of(cfnDistribution).add( new ConditionAspect( new CfnCondition(scope, "DeployAPIGDistribution", { diff --git a/source/constructs/lib/back-end/back-end-construct.ts b/source/constructs/lib/back-end/back-end-construct.ts index c2f980c54..3395d6810 100644 --- a/source/constructs/lib/back-end/back-end-construct.ts +++ b/source/constructs/lib/back-end/back-end-construct.ts @@ -225,16 +225,6 @@ export class BackEnd extends Construct { ).toString(), }); - const conditionalCloudFrontDistributionId = Fn.conditionIf( - props.conditions.useExistingCloudFrontDistributionCondition.logicalId, - existingDistribution.distributionId, - Fn.conditionIf( - props.conditions.enableS3ObjectLambdaCondition.logicalId, - s3ObjectLambdaArchitecture.imageHandlerCloudFrontDistribution.distributionId, - apiGatewayArchitecture.imageHandlerCloudFrontDistribution.distributionId - ).toString() - ).toString(); - solutionsMetrics.addLambdaInvocationCount({ functionName: imageHandlerLambdaFunction.functionName }); solutionsMetrics.addLambdaBilledDurationMemorySize({ logGroups: [imageHandlerLogGroup], @@ -256,29 +246,118 @@ export class BackEnd extends Construct { queryDefinitionName: "RequestInfoQuery", }); + // Add CloudFront metrics for API Gateway architecture solutionsMetrics.addCloudFrontMetric({ - distributionId: conditionalCloudFrontDistributionId, + distributionId: apiGatewayArchitecture.imageHandlerCloudFrontDistribution.distributionId, metricName: "Requests", + identifier: "apigateway", }); solutionsMetrics.addCloudFrontMetric({ - distributionId: conditionalCloudFrontDistributionId, + distributionId: apiGatewayArchitecture.imageHandlerCloudFrontDistribution.distributionId, metricName: "BytesDownloaded", + identifier: "apigateway", }); - Aspects.of(solutionsMetrics).add(new ConditionAspect(props.sendAnonymousStatistics)); + // Add CloudFront metrics for S3 Object Lambda architecture + solutionsMetrics.addCloudFrontMetric({ + distributionId: s3ObjectLambdaArchitecture.imageHandlerCloudFrontDistribution.distributionId, + metricName: "Requests", + identifier: "s3objectlambda", + }); + solutionsMetrics.addCloudFrontMetric({ + distributionId: s3ObjectLambdaArchitecture.imageHandlerCloudFrontDistribution.distributionId, + metricName: "BytesDownloaded", + identifier: "s3objectlambda", + }); - const operationalInsightsDashboard = new OperationalInsightsDashboard( + // Add CloudFront metrics for existing distribution + solutionsMetrics.addCloudFrontMetric({ + distributionId: existingDistribution.distributionId, + metricName: "Requests", + identifier: "existing", + }); + solutionsMetrics.addCloudFrontMetric({ + distributionId: existingDistribution.distributionId, + metricName: "BytesDownloaded", + identifier: "existing", + }); + + Aspects.of(solutionsMetrics as unknown as Construct).add(new ConditionAspect(props.sendAnonymousStatistics)); + + // Create dashboards for each architecture - only the active one will be deployed based on conditions + // Combined condition for API Gateway: not using existing AND not using S3 Object Lambda + const deployApiGatewayDashboardCondition = new CfnCondition(this, "DeployApiGatewayDashboardCondition", { + expression: Fn.conditionAnd( + Fn.conditionNot(props.conditions.useExistingCloudFrontDistributionCondition), + Fn.conditionNot(props.conditions.enableS3ObjectLambdaCondition) + ), + }); + + const apiGatewayDashboard = new OperationalInsightsDashboard( Stack.of(this), - "OperationalInsightsDashboard", + "ApiGatewayOperationalInsightsDashboard", { enabled: props.conditions.deployUICondition, backendLambdaFunctionName: imageHandlerLambdaFunction.functionName, - cloudFrontDistributionId: conditionalCloudFrontDistributionId, + cloudFrontDistributionId: apiGatewayArchitecture.imageHandlerCloudFrontDistribution.distributionId, namespace: Aws.REGION, } ); - this.operationalDashboard = operationalInsightsDashboard.dashboard; + Aspects.of(apiGatewayDashboard).add( + new ConditionAspect( + new CfnCondition(this, "DeployApiGatewayDashboardCombinedCondition", { + expression: Fn.conditionAnd(props.deployCloudWatchDashboard, deployApiGatewayDashboardCondition), + }) + ) + ); + + // S3 Object Lambda dashboard + const s3ObjectLambdaDashboard = new OperationalInsightsDashboard( + Stack.of(this), + "S3ObjectLambdaOperationalInsightsDashboard", + { + enabled: props.conditions.deployUICondition, + backendLambdaFunctionName: imageHandlerLambdaFunction.functionName, + cloudFrontDistributionId: s3ObjectLambdaArchitecture.imageHandlerCloudFrontDistribution.distributionId, + namespace: Aws.REGION, + } + ); + Aspects.of(s3ObjectLambdaDashboard).add( + new ConditionAspect( + new CfnCondition(this, "DeployS3ObjectLambdaDashboardCondition", { + expression: Fn.conditionAnd( + props.deployCloudWatchDashboard, + props.conditions.enableS3ObjectLambdaCondition, + Fn.conditionNot(props.conditions.useExistingCloudFrontDistributionCondition) + ), + }) + ) + ); + + // Existing distribution dashboard + const existingDistributionDashboard = new OperationalInsightsDashboard( + Stack.of(this), + "ExistingDistributionOperationalInsightsDashboard", + { + enabled: props.conditions.deployUICondition, + backendLambdaFunctionName: imageHandlerLambdaFunction.functionName, + cloudFrontDistributionId: existingDistribution.distributionId, + namespace: Aws.REGION, + } + ); + Aspects.of(existingDistributionDashboard).add( + new ConditionAspect( + new CfnCondition(this, "DeployExistingDistributionDashboardCondition", { + expression: Fn.conditionAnd( + props.deployCloudWatchDashboard, + props.conditions.useExistingCloudFrontDistributionCondition + ), + }) + ) + ); - Aspects.of(operationalInsightsDashboard).add(new ConditionAspect(props.deployCloudWatchDashboard)); + // Set the operational dashboard to the API Gateway one by default for backward compatibility + // The actual deployed dashboard will depend on the conditions + this.operationalDashboard = apiGatewayDashboard.dashboard; } } diff --git a/source/constructs/lib/back-end/s3-object-lambda-architecture.ts b/source/constructs/lib/back-end/s3-object-lambda-architecture.ts index eb39f1f4c..3e3940a95 100644 --- a/source/constructs/lib/back-end/s3-object-lambda-architecture.ts +++ b/source/constructs/lib/back-end/s3-object-lambda-architecture.ts @@ -242,6 +242,17 @@ export class S3ObjectLambdaArchitecture { { Enabled: false }, ], }); + + // Add trusted key groups for signed URLs if enabled + const keyGroupIds = props.trustedKeyGroupIds.split(",").map((id: string) => id.trim()).filter(Boolean); + cfnDistribution.addOverride( + "Properties.DistributionConfig.DefaultCacheBehavior.TrustedKeyGroups", + Fn.conditionIf( + props.conditions.enableSignedUrlsCondition.logicalId, + keyGroupIds, + Aws.NO_VALUE + ) + ); scope.olDomainName = Fn.conditionIf( props.conditions.useExistingCloudFrontDistributionCondition.logicalId, props.existingDistribution.distributionDomainName, diff --git a/source/constructs/lib/common-resources/common-resources-construct.ts b/source/constructs/lib/common-resources/common-resources-construct.ts index 7d1b3a9c0..a5838bcb5 100644 --- a/source/constructs/lib/common-resources/common-resources-construct.ts +++ b/source/constructs/lib/common-resources/common-resources-construct.ts @@ -26,6 +26,7 @@ export interface Conditions { readonly disableS3ObjectLambdaCondition: CfnCondition; readonly isLogRetentionPeriodInfinite: CfnCondition; readonly useExistingCloudFrontDistributionCondition: CfnCondition; + readonly enableSignedUrlsCondition: CfnCondition; } /** @@ -71,6 +72,9 @@ export class CommonResources extends Construct { useExistingCloudFrontDistributionCondition: new CfnCondition(this, "UseExistingCloudFrontDistributionCondition", { expression: Fn.conditionEquals(props.useExistingCloudFrontDistribution, "Yes"), }), + enableSignedUrlsCondition: new CfnCondition(this, "EnableSignedUrlsCondition", { + expression: Fn.conditionEquals(props.enableSignedUrls, "Yes"), + }), }; this.secretsManagerPolicy = new Policy(this, "SecretsManagerPolicy", { diff --git a/source/constructs/lib/serverless-image-stack.ts b/source/constructs/lib/serverless-image-stack.ts index 19e088a4a..0de820fd5 100644 --- a/source/constructs/lib/serverless-image-stack.ts +++ b/source/constructs/lib/serverless-image-stack.ts @@ -199,6 +199,20 @@ export class ServerlessImageHandlerStack extends Stack { allowedPattern: "^$|^E[A-Z0-9]{8,}$", }); + const enableSignedUrlsParameter = new CfnParameter(this, "EnableSignedUrlsParameter", { + type: "String", + description: `Would you like to enable CloudFront signed URLs for private content access? Select 'Yes' to require signed URLs. This will restrict access to images to only requests with valid time-limited signed URLs. You must provide TrustedKeyGroupIds.`, + allowedValues: ["Yes", "No"], + default: "No", + }); + + const trustedKeyGroupIdsParameter = new CfnParameter(this, "TrustedKeyGroupIdsParameter", { + type: "String", + description: + "Comma-separated list of CloudFront key group IDs to trust for signed URLs (e.g., abcd1234-5678-90ab-cdef-1234567890ab). Required if 'Enable Signed URLs' is set to 'Yes'. You must create key groups in CloudFront and upload your public keys before deployment.", + default: "", + }); + /* eslint-disable no-new */ new CfnRule(this, "ExistingDistributionIdRequiredRule", { ruleCondition: Fn.conditionEquals(useExistingCloudFrontDistribution.valueAsString, "Yes"), @@ -211,10 +225,21 @@ export class ServerlessImageHandlerStack extends Stack { ], }); + new CfnRule(this, "SignedUrlsKeyGroupRequiredRule", { + ruleCondition: Fn.conditionEquals(enableSignedUrlsParameter.valueAsString, "Yes"), + assertions: [ + { + assert: Fn.conditionNot(Fn.conditionEquals(trustedKeyGroupIdsParameter.valueAsString, "")), + assertDescription: + "If 'EnableSignedUrls' is set to 'Yes', 'TrustedKeyGroupIds' must be provided.", + }, + ], + }); + const solutionMapping = new CfnMapping(this, "Solution", { mapping: { Config: { - AnonymousUsage: "Yes", + AnonymousUsage: "No", DeployCloudWatchDashboard: "Yes", SolutionId: props.solutionId, Version: props.solutionVersion, @@ -250,6 +275,8 @@ export class ServerlessImageHandlerStack extends Stack { enableS3ObjectLambda: enableS3ObjectLambdaParameter.valueAsString, useExistingCloudFrontDistribution: useExistingCloudFrontDistribution.valueAsString as YesNo, existingCloudFrontDistributionId: existingCloudFrontDistributionId.valueAsString, + enableSignedUrls: enableSignedUrlsParameter.valueAsString as YesNo, + trustedKeyGroupIds: trustedKeyGroupIdsParameter.valueAsString, }; const commonResources = new CommonResources(this, "CommonResources", { diff --git a/source/constructs/lib/types.ts b/source/constructs/lib/types.ts index 9f2d2037f..c52c613b2 100644 --- a/source/constructs/lib/types.ts +++ b/source/constructs/lib/types.ts @@ -20,4 +20,6 @@ export interface SolutionConstructProps { readonly enableS3ObjectLambda: string; readonly useExistingCloudFrontDistribution: YesNo; readonly existingCloudFrontDistributionId: string; + readonly enableSignedUrls: YesNo; + readonly trustedKeyGroupIds: string; } diff --git a/source/constructs/test/__snapshots__/constructs.test.ts.snap b/source/constructs/test/__snapshots__/constructs.test.ts.snap index 62c7565a3..cbde5bc2c 100644 --- a/source/constructs/test/__snapshots__/constructs.test.ts.snap +++ b/source/constructs/test/__snapshots__/constructs.test.ts.snap @@ -175,7 +175,7 @@ exports[`Dynamic Image Transformation for Amazon CloudFront Stack Snapshot 1`] = "Mappings": { "Solution": { "Config": { - "AnonymousUsage": "Yes", + "AnonymousUsage": "No", "DeployCloudWatchDashboard": "Yes", "SharpSizeLimit": "", "SolutionId": "S0ABC", diff --git a/source/image-handler/image-handler.ts b/source/image-handler/image-handler.ts index 6dc7899b7..d65e524c4 100644 --- a/source/image-handler/image-handler.ts +++ b/source/image-handler/image-handler.ts @@ -728,6 +728,8 @@ export class ImageHandler { return "png"; case ImageFormatTypes.WEBP: return "webp"; + case ImageFormatTypes.TIF: + return "tiff"; case ImageFormatTypes.TIFF: return "tiff"; case ImageFormatTypes.HEIF: diff --git a/source/image-handler/image-request.ts b/source/image-handler/image-request.ts index a8d729652..dbf75ec19 100644 --- a/source/image-handler/image-request.ts +++ b/source/image-handler/image-request.ts @@ -26,6 +26,7 @@ import { QueryParamMapper } from "./query-param-mapper"; dayjs.extend(customParseFormat); dayjs.extend(utc); + type OriginalImageInfo = Partial<{ contentType: string; expires: string; @@ -73,13 +74,16 @@ export class ImageRequest { ImageFormatTypes.JPEG, ImageFormatTypes.PNG, ImageFormatTypes.WEBP, + ImageFormatTypes.TIF, ImageFormatTypes.TIFF, ImageFormatTypes.HEIF, ImageFormatTypes.GIF, ImageFormatTypes.AVIF, ]; - imageRequestInfo.contentType = `image/${imageRequestInfo.outputFormat}`; + imageRequestInfo.contentType = imageRequestInfo.outputFormat === ImageFormatTypes.TIF + ? ContentTypes.TIFF + : `image/${imageRequestInfo.outputFormat}`; if ( requestType.includes(imageRequestInfo.requestType) && acceptedValues.includes(imageRequestInfo.outputFormat) diff --git a/source/image-handler/lib/enums.ts b/source/image-handler/lib/enums.ts index 41beb7585..f9cc72a2d 100644 --- a/source/image-handler/lib/enums.ts +++ b/source/image-handler/lib/enums.ts @@ -22,6 +22,7 @@ export enum ImageFormatTypes { JPEG = "jpeg", PNG = "png", WEBP = "webp", + TIF = "tif", TIFF = "tiff", HEIF = "heif", HEIC = "heic", diff --git a/source/image-handler/test/image-handler/format.spec.ts b/source/image-handler/test/image-handler/format.spec.ts index cef42b631..de1af6e3c 100644 --- a/source/image-handler/test/image-handler/format.spec.ts +++ b/source/image-handler/test/image-handler/format.spec.ts @@ -109,6 +109,29 @@ describe("modifyImageOutput", () => { expect(resultFormat).toEqual(ImageFormatTypes.JPEG); }); + it("Should return an image in TIFF format when outputFormat is TIF", async () => { + // Arrange + const request: ImageRequestInfo = { + requestType: RequestTypes.DEFAULT, + bucket: "sample-bucket", + key: "sample-image-001.png", + edits: { grayscale: true, flip: true }, + outputFormat: ImageFormatTypes.TIF, + originalImage: image, + }; + const imageHandler = new ImageHandler(s3Client, rekognitionClient); + const sharpImage = sharp(request.originalImage, { failOnError: false }).withMetadata(); + const toFormatSpy = jest.spyOn(sharp.prototype, "toFormat"); + const result = await imageHandler["modifyImageOutput"](sharpImage, request).toBuffer(); + + // Act + const resultFormat = (await sharp(result).metadata()).format; + + // Assert + expect(toFormatSpy).toHaveBeenCalledWith("tiff"); + expect(resultFormat).toEqual(ImageFormatTypes.TIFF); + }); + it("Should return an image in the same format when outputFormat is not provided", async () => { // Arrange const request: ImageRequestInfo = { diff --git a/source/image-handler/test/image-request/setup.spec.ts b/source/image-handler/test/image-request/setup.spec.ts index 3c45017dd..4fe8b9264 100644 --- a/source/image-handler/test/image-request/setup.spec.ts +++ b/source/image-handler/test/image-request/setup.spec.ts @@ -132,6 +132,37 @@ describe("setup", () => { expect(imageRequestInfo).toEqual(expectedResult); }); + it("Should pass when a default image request with TIF format is provided and populate the ImageRequest object with the proper values", async () => { + // Arrange + const event = { + path: "/eyJidWNrZXQiOiJ2YWxpZEJ1Y2tldCIsImtleSI6InZhbGlkS2V5IiwiZWRpdHMiOnsidG9Gb3JtYXQiOiJ0aWYifX0=", + }; + process.env.SOURCE_BUCKETS = "validBucket, validBucket2"; + + // Mock + mockS3Commands.getObject.mockResolvedValue({ Body: mockImageBody }); + + // Act + const imageRequest = new ImageRequest(s3Client, secretProvider); + const imageRequestInfo = await imageRequest.setup(event); + const expectedResult = { + requestType: "Default", + bucket: "validBucket", + key: "validKey", + edits: { toFormat: "tif" }, + outputFormat: "tif", + originalImage: mockImage, + cacheControl: "max-age=31536000,public", + contentType: "image/tiff", + }; + // Assert + expect(mockS3Commands.getObject).toHaveBeenCalledWith({ + Bucket: "validBucket", + Key: "validKey", + }); + expect(imageRequestInfo).toEqual(expectedResult); + }); + it("Should pass when a thumbor image request is provided and populate the ImageRequest object with the proper values", async () => { // Arrange const event = { path: "/filters:grayscale()/test-image-001.jpg" }; diff --git a/source/image-handler/test/thumbor-mapper/filter.spec.ts b/source/image-handler/test/thumbor-mapper/filter.spec.ts index bdbef4dbd..13a2f99ea 100644 --- a/source/image-handler/test/thumbor-mapper/filter.spec.ts +++ b/source/image-handler/test/thumbor-mapper/filter.spec.ts @@ -274,6 +274,20 @@ describe("filter", () => { expect(edits).toEqual(expectedResult); }); + it("Should pass if the filter is successfully translated from Thumbor:quality() for TIF", () => { + // Arrange + const edit = "filters:quality(50)"; + const filetype = ImageFormatTypes.TIF; + + // Act + const thumborMapper = new ThumborMapper(); + const edits = thumborMapper.mapFilter(edit, filetype); + + // Assert + const expectedResult = { tiff: { quality: 50 } }; + expect(edits).toEqual(expectedResult); + }); + it("Should pass if the filter is successfully translated from Thumbor:quality()", () => { // Arrange const edit = "filters:quality(50)"; diff --git a/source/image-handler/thumbor-mapper.ts b/source/image-handler/thumbor-mapper.ts index ff39c07c9..d5ebb405c 100644 --- a/source/image-handler/thumbor-mapper.ts +++ b/source/image-handler/thumbor-mapper.ts @@ -200,11 +200,12 @@ export class ThumborMapper { const toSupportedImageFormatType = (format: ImageFormatTypes): ImageFormatTypes => { if ([ImageFormatTypes.JPG, ImageFormatTypes.JPEG].includes(format)) { return ImageFormatTypes.JPEG; + } else if ([ImageFormatTypes.TIF, ImageFormatTypes.TIFF].includes(format)) { + return ImageFormatTypes.TIFF; } else if ( [ ImageFormatTypes.PNG, ImageFormatTypes.WEBP, - ImageFormatTypes.TIFF, ImageFormatTypes.HEIF, ImageFormatTypes.GIF, ImageFormatTypes.AVIF,