diff --git a/CLAUDE.md b/CLAUDE.md index 6240807..0e663d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,7 +372,14 @@ on boot if any required var is empty. - **S3 + CloudFront**: `encoded-bucket` holds finished transcoded output, served publicly via CloudFront using Origin Access Control (OAC) — the bucket itself blocks all public access; only CloudFront's OAC principal - can read it. + can read it. The distribution carries a **response headers policy** adding + permissive CORS headers (`*`, `GET`/`HEAD`/`OPTIONS`), and `OPTIONS` is in + the behaviour's allowed/cached methods so CloudFront answers preflights + itself: HLS is fetched by JavaScript, so a playlist or segment served + without `Access-Control-Allow-Origin` is discarded by the browser. The + bucket also carries its own equivalent CORS rule, which is what the + LocalStack stack relies on — there is no CloudFront there and + `PLAYBACK_BASE_URL` points the player straight at S3. - **S3 raw uploads**: `raw-uploads-bucket` is a separate, private bucket for pre-transcode uploads — deliberately kept apart from `encoded-bucket` so raw source video is never reachable through the public CDN. CORS is diff --git a/infrastructure/main.go b/infrastructure/main.go index c2440f5..3736a4c 100644 --- a/infrastructure/main.go +++ b/infrastructure/main.go @@ -426,6 +426,42 @@ func main() { return err } + // HLS is played by JavaScript (hls.js fetches the .m3u8 playlist + // and every .ts/.m4s segment with XHR), so the CDN has to answer + // with CORS headers or the browser drops the response — the + // "CORS Missing Allow Origin" failure. The S3 origin sends none of + // its own, so CloudFront adds them here, at the edge, for cached + // and uncached responses alike. + corsHeaders, err := cloudfront.NewResponseHeadersPolicy(ctx, "encoded-bucket-cors-headers", &cloudfront.ResponseHeadersPolicyArgs{ + Comment: pulumi.String("CORS headers so browsers can fetch HLS playlists and segments"), + CorsConfig: &cloudfront.ResponseHeadersPolicyCorsConfigArgs{ + AccessControlAllowCredentials: pulumi.Bool(false), + AccessControlAllowHeaders: &cloudfront.ResponseHeadersPolicyCorsConfigAccessControlAllowHeadersArgs{ + Items: pulumi.ToStringArray([]string{"*"}), + }, + AccessControlAllowMethods: &cloudfront.ResponseHeadersPolicyCorsConfigAccessControlAllowMethodsArgs{ + Items: pulumi.ToStringArray([]string{"GET", "HEAD", "OPTIONS"}), + }, + // Playback is public and unauthenticated, so any origin may + // read it. A wildcard also keeps Origin out of the cache + // key: with a fixed list CloudFront would have to vary the + // cached response on the request's Origin header. + AccessControlAllowOrigins: &cloudfront.ResponseHeadersPolicyCorsConfigAccessControlAllowOriginsArgs{ + Items: pulumi.ToStringArray([]string{"*"}), + }, + // Content-Range/Content-Length are what a player reads back + // when it seeks with a Range request. + AccessControlExposeHeaders: &cloudfront.ResponseHeadersPolicyCorsConfigAccessControlExposeHeadersArgs{ + Items: pulumi.ToStringArray([]string{"Content-Length", "Content-Range", "Date", "ETag"}), + }, + AccessControlMaxAgeSec: pulumi.Int(3000), + OriginOverride: pulumi.Bool(true), + }, + }) + if err != nil { + return err + } + distribution, err = cloudfront.NewDistribution(ctx, "encoded-bucket-cdn", &cloudfront.DistributionArgs{ Enabled: pulumi.Bool(true), Comment: pulumi.String("Edge caching for encoded-bucket assets"), @@ -442,10 +478,14 @@ func main() { DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{ TargetOriginId: pulumi.String(originId), ViewerProtocolPolicy: pulumi.String("redirect-to-https"), - AllowedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), - CachedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), - Compress: pulumi.Bool(true), - CachePolicyId: pulumi.String(cachingOptimizedPolicyId), + // OPTIONS is listed so CloudFront answers CORS preflights + // itself from the response headers policy below; without it + // a preflight is rejected with 403 before the policy runs. + AllowedMethods: pulumi.ToStringArray([]string{"GET", "HEAD", "OPTIONS"}), + CachedMethods: pulumi.ToStringArray([]string{"GET", "HEAD", "OPTIONS"}), + Compress: pulumi.Bool(true), + CachePolicyId: pulumi.String(cachingOptimizedPolicyId), + ResponseHeadersPolicyId: corsHeaders.ID(), }, Restrictions: &cloudfront.DistributionRestrictionsArgs{ GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{ @@ -1274,6 +1314,27 @@ func main() { corsOrigins = pulumi.StringArray{pulumi.Sprintf("http://%s", cmsAlb.DnsName)} } + // The encoded bucket needs its own CORS rule for the LocalStack stack, + // where there is no CloudFront in front of it and PLAYBACK_BASE_URL + // points the player straight at S3. Behind CloudFront the response + // headers policy already covers playback, but the rule is harmless + // there and keeps a direct-to-bucket player working either way. + _, err = s3.NewBucketCorsConfigurationV2(ctx, "encoded-bucket-cors", &s3.BucketCorsConfigurationV2Args{ + Bucket: bucket.ID(), + CorsRules: s3.BucketCorsConfigurationV2CorsRuleArray{ + &s3.BucketCorsConfigurationV2CorsRuleArgs{ + AllowedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), + AllowedOrigins: pulumi.ToStringArray([]string{"*"}), + AllowedHeaders: pulumi.ToStringArray([]string{"*"}), + ExposeHeaders: pulumi.ToStringArray([]string{"Content-Length", "Content-Range", "Date", "ETag"}), + MaxAgeSeconds: pulumi.Int(3000), + }, + }, + }) + if err != nil { + return err + } + _, err = s3.NewBucketCorsConfigurationV2(ctx, "raw-uploads-bucket-cors", &s3.BucketCorsConfigurationV2Args{ Bucket: rawUploadsBucket.ID(), CorsRules: s3.BucketCorsConfigurationV2CorsRuleArray{