CmsMediaStorageConfiguration.java
package com.cloudforgeci.api.core.topology;
import com.cloudforgeci.api.core.SystemContext;
import com.cloudforge.core.interfaces.CmsSpec;
import com.cloudforge.core.enums.SecurityProfile;
import software.amazon.awscdk.Duration;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.services.iam.Effect;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.amazon.awscdk.services.s3.BlockPublicAccess;
import software.amazon.awscdk.services.s3.Bucket;
import software.amazon.awscdk.services.s3.BucketAccessControl;
import software.amazon.awscdk.services.s3.BucketEncryption;
import software.amazon.awscdk.services.s3.CorsRule;
import software.amazon.awscdk.services.s3.HttpMethods;
import software.amazon.awscdk.services.s3.LifecycleRule;
import java.util.List;
import java.util.Map;
/**
* Configuration for CMS media storage on S3.
*
* <p>Creates S3 buckets with appropriate policies for CMS media uploads,
* enabling scalable media storage with CDN integration.</p>
*
* <h2>Features:</h2>
* <ul>
* <li>Private bucket with block public access</li>
* <li>Versioning for recovery</li>
* <li>Lifecycle rules for old version cleanup</li>
* <li>CORS configuration for browser uploads</li>
* <li>IAM policies for CMS access</li>
* <li>Encryption based on security profile</li>
* </ul>
*
* @since 3.1.0
*/
public final class CmsMediaStorageConfiguration {
private CmsMediaStorageConfiguration() {
// Utility class
}
/**
* Create S3 bucket for CMS media storage.
*
* @param ctx the SystemContext
* @param spec the CMS specification
* @return the created S3 bucket
*/
public static Bucket createMediaBucket(SystemContext ctx, CmsSpec spec) {
// NO bucketName specified - CloudFormation auto-generates unique name
// This prevents "AlreadyExists" errors when buckets are retained from previous deployments
BucketEncryption encryption = determineEncryption(ctx);
return Bucket.Builder.create(ctx, spec.applicationId().toLowerCase() + "-media")
// NO bucketName specified - CloudFormation auto-generates unique name
.encryption(encryption)
.blockPublicAccess(BlockPublicAccess.BLOCK_ALL)
.accessControl(BucketAccessControl.PRIVATE)
.versioned(true)
.removalPolicy(determineRemovalPolicy(ctx))
.lifecycleRules(createLifecycleRules())
.cors(createCorsRules(ctx))
.build();
}
/**
* Create S3 bucket with custom configuration.
*
* @param ctx the SystemContext
* @param spec the CMS specification
* @param enableVersioning whether to enable versioning
* @param enableTransferAcceleration whether to enable transfer acceleration
* @return the created S3 bucket
*/
public static Bucket createMediaBucket(
SystemContext ctx,
CmsSpec spec,
boolean enableVersioning,
boolean enableTransferAcceleration) {
// NO bucketName specified - CloudFormation auto-generates unique name
// This prevents "AlreadyExists" errors when buckets are retained from previous deployments
BucketEncryption encryption = determineEncryption(ctx);
var builder = Bucket.Builder.create(ctx, spec.applicationId().toLowerCase() + "-media")
// NO bucketName specified - CloudFormation auto-generates unique name
.encryption(encryption)
.blockPublicAccess(BlockPublicAccess.BLOCK_ALL)
.accessControl(BucketAccessControl.PRIVATE)
.versioned(enableVersioning)
.removalPolicy(determineRemovalPolicy(ctx))
.cors(createCorsRules(ctx));
if (enableVersioning) {
builder.lifecycleRules(createLifecycleRules());
}
if (enableTransferAcceleration) {
builder.transferAcceleration(true);
}
return builder.build();
}
/**
* Determine encryption based on security profile.
*
* @param ctx the SystemContext
* @return bucket encryption type
*/
private static BucketEncryption determineEncryption(SystemContext ctx) {
SecurityProfile profile = ctx.cfc.securityProfile();
if (profile == SecurityProfile.PRODUCTION) {
return BucketEncryption.KMS_MANAGED;
}
return BucketEncryption.S3_MANAGED;
}
/**
* Determine removal policy based on security profile.
*
* @param ctx the SystemContext
* @return removal policy
*/
private static RemovalPolicy determineRemovalPolicy(SystemContext ctx) {
SecurityProfile profile = ctx.cfc.securityProfile();
if (profile == SecurityProfile.PRODUCTION) {
return RemovalPolicy.RETAIN;
}
return RemovalPolicy.DESTROY;
}
/**
* Create lifecycle rules for version cleanup.
*
* @return list of lifecycle rules
*/
private static List<LifecycleRule> createLifecycleRules() {
return List.of(
// Delete old versions after 30 days
LifecycleRule.builder()
.id("DeleteOldVersions")
.enabled(true)
.noncurrentVersionExpiration(Duration.days(30))
.build(),
// Transition old versions to cheaper storage after 7 days
LifecycleRule.builder()
.id("TransitionOldVersions")
.enabled(true)
.noncurrentVersionTransitions(List.of(
software.amazon.awscdk.services.s3.NoncurrentVersionTransition.builder()
.storageClass(software.amazon.awscdk.services.s3.StorageClass.GLACIER_INSTANT_RETRIEVAL)
.transitionAfter(Duration.days(7))
.build()
))
.build(),
// Clean up incomplete multipart uploads
LifecycleRule.builder()
.id("AbortIncompleteMultipartUpload")
.enabled(true)
.abortIncompleteMultipartUploadAfter(Duration.days(7))
.build()
);
}
/**
* Create CORS rules for browser uploads.
*
* @param ctx the SystemContext
* @return list of CORS rules
*/
private static List<CorsRule> createCorsRules(SystemContext ctx) {
// Determine allowed origins
List<String> allowedOrigins;
if (ctx.cfc.fqdn() != null && !ctx.cfc.fqdn().isBlank()) {
allowedOrigins = List.of(
"https://" + ctx.cfc.fqdn(),
"http://" + ctx.cfc.fqdn() // For development
);
} else {
// Allow all origins in development
allowedOrigins = List.of("*");
}
return List.of(
CorsRule.builder()
.allowedMethods(List.of(
HttpMethods.GET,
HttpMethods.PUT,
HttpMethods.POST,
HttpMethods.DELETE,
HttpMethods.HEAD
))
.allowedOrigins(allowedOrigins)
.allowedHeaders(List.of("*"))
.exposedHeaders(List.of(
"ETag",
"x-amz-meta-custom-header"
))
.maxAge(3600)
.build()
);
}
/**
* Create IAM policy for CMS to access media bucket.
*
* @param bucket the S3 bucket
* @return IAM policy statement
*/
public static PolicyStatement createMediaBucketPolicy(Bucket bucket) {
return PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.actions(List.of(
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:ListBucket",
"s3:GetBucketLocation"
))
.resources(List.of(
bucket.getBucketArn(),
bucket.getBucketArn() + "/*"
))
.build();
}
/**
* Create read-only IAM policy for media bucket.
*
* @param bucket the S3 bucket
* @return IAM policy statement for read-only access
*/
public static PolicyStatement createReadOnlyPolicy(Bucket bucket) {
return PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.actions(List.of(
"s3:GetObject",
"s3:GetObjectVersion",
"s3:ListBucket"
))
.resources(List.of(
bucket.getBucketArn(),
bucket.getBucketArn() + "/*"
))
.build();
}
/**
* Create environment variables for S3 media integration.
*
* @param bucket the S3 bucket
* @param spec the CMS specification
* @param cdnUrl optional CloudFront URL
* @return map of environment variables
*/
public static Map<String, String> createS3Environment(
Bucket bucket,
CmsSpec spec,
String cdnUrl) {
var env = new java.util.HashMap<String, String>();
// Common S3 variables
env.put("S3_MEDIA_BUCKET", bucket.getBucketName());
env.put("S3_MEDIA_REGION", bucket.getEnv().getRegion());
// CMS-specific variables
String cmsId = spec.applicationId();
switch (cmsId) {
case "wordpress", "woocommerce" -> {
env.put("WP_OFFLOAD_MEDIA_BUCKET", bucket.getBucketName());
env.put("WP_OFFLOAD_MEDIA_REGION", bucket.getEnv().getRegion());
if (cdnUrl != null) {
env.put("WP_OFFLOAD_MEDIA_CLOUDFRONT", cdnUrl);
}
}
case "magento" -> {
env.put("MAGENTO_MEDIA_STORAGE", "s3");
env.put("MAGENTO_MEDIA_S3_BUCKET", bucket.getBucketName());
env.put("MAGENTO_MEDIA_S3_REGION", bucket.getEnv().getRegion());
}
case "drupal" -> {
env.put("S3FS_BUCKET", bucket.getBucketName());
env.put("S3FS_REGION", bucket.getEnv().getRegion());
env.put("S3FS_USE_CNAME", cdnUrl != null ? "true" : "false");
if (cdnUrl != null) {
env.put("S3FS_DOMAIN", cdnUrl);
}
}
case "joomla" -> {
env.put("JOOMLA_S3_BUCKET", bucket.getBucketName());
env.put("JOOMLA_S3_REGION", bucket.getEnv().getRegion());
}
case "prestashop" -> {
env.put("PS_AWS_S3_BUCKET", bucket.getBucketName());
env.put("PS_AWS_S3_REGION", bucket.getEnv().getRegion());
}
default -> {
// Generic variables already set
}
}
// CDN URL if provided
if (cdnUrl != null && !cdnUrl.isEmpty()) {
env.put("MEDIA_CDN_URL", cdnUrl);
}
return env;
}
/**
* Create bucket policy for CloudFront OAI/OAC access.
*
* @param bucket the S3 bucket
* @param cloudFrontOaiArn CloudFront Origin Access Identity ARN
* @return policy statement for CloudFront access
*/
public static PolicyStatement createCloudFrontAccessPolicy(
Bucket bucket,
String cloudFrontOaiArn) {
return PolicyStatement.Builder.create()
.effect(Effect.ALLOW)
.principals(List.of(
new software.amazon.awscdk.services.iam.ArnPrincipal(cloudFrontOaiArn)
))
.actions(List.of("s3:GetObject"))
.resources(List.of(bucket.getBucketArn() + "/*"))
.build();
}
}