Lambdaを使ってS3にアップロードしたjsファイルのcontent-typeを変える

# 概要
S3にアップロードしたJSファイルのContent-Typeを変更するスクリプトを、Lambdaを使って実装しました。
この記事では、その方法を共有します。

# 動機
静的サイトをAWS S3でホスティングしようとした際、
フロントエンジニアにIAMのアクセスキーを渡してS3にサイトのソースコード（HTML, CSS, JSファイルなど）をアップロードしてもらいましたが、
JSファイルのContent-Typeが```binary/octet-stream```に設定されてしまい、サイトが正常に動作しないという問題が発生しました。

# 原因

コマンドラインからS3にファイルをアップロードする際に、Content-Type を明示的に指定しないと、AWSがファイルの拡張子に基づいて自動的にContent-Typeを推測します。
この推測が正しく行われない場合があり、特にJSファイルでこの問題が発生するようです。

# 対応
本来であれば、S3にファイルをアップロードする際に正しいContent-Typeを指定すべきですが、フロントエンジニア側の設定変更ができない状況だったため、サーバサイドで対応することにしました。

S3へのファイルアップロードをトリガーに、AWS Lambda関数を使ってJSファイルのContent-Typeを書き換える処理を実装してます。

また、このLambda関数の管理はTerraformを使用しています。

### 注意点
この実装では、S3にファイルがアップロードされるたびにLambda関数がトリガーされるため、大量のファイルをアップロードするとLambdaの実行コストが増加する可能性があるので注意が必要です。
# コード
## lambda
``` python
# lambda-replace-content_type.py

import boto3

s3 = boto3.client('s3')
def lambda_handler(event, context):
    # S3イベントからバケット名とオブジェクトキーを取得
    bucket_name = event['Records'][0]['s3']['bucket']['name']
    object_key = event['Records'][0]['s3']['object']['key']

    # Content-Typeを変更
    replace_content_type(bucket_name, object_key)

# S3にアップロードされたオブジェクトのContent-Typeを変更する関数
def replace_content_type(bucket_name, object_key):
    try:
        # 現在のオブジェクトのメタデータを取得
        response = s3.head_object(Bucket=bucket_name, Key=object_key)
        metadata = response['Metadata']

        # content-typeがbinary/octet-stream以外だった場合、正しい設定がされているとして終了
        if response['ContentType'] != 'binary/octet-stream':
            return {}

        # 拡張子によってContent-Typeを判定
        ext = os.path.splitext(object_key)[1][1:] 
        if ext == 'js':
            # 今の所JSファイルのみ問題になっているので、JSファイルの場合のみContent-Typeを変更
            content_type = 'text/javascript'
        else:
            # jsファイル以外はoctet-streamのままにしておく
            content_type = 'binary/octet-stream'

        # オブジェクトの置き換え（Content-Typeを変更）
        s3.copy_object(
            Bucket=bucket_name,
            CopySource={'Bucket': bucket_name, 'Key': object_key},
            Key=object_key,
            ContentType=content_type,  
            Metadata=metadata, 
            MetadataDirective='REPLACE'  # メタデータの置き換えを指示
       )
    except Exception as e:
        print(e)
```

## terraform

``` terraform
#####
# Lambda関数
#####

# terraform実行時にsource_fileのスクリプトをzip化する。
data "archive_file" "lambda-src-zip" {
  type        = "zip"
  source_file = "src/lambda-replace-content_type.py"
  output_path = "src/lambda-replace-content_type.zip"
}
resource "aws_lambda_function" "replace_content_type" {
  function_name = "replace_content_type"
  role          = aws_iam_role.lambda_s3_role.arn
  handler       = "lambda-replace-content_type.lambda_handler"
  runtime       = "python3.9"

  filename = data.archive_file.lambda-src-zip.output_path
  source_code_hash = data.archive_file.lambda-src-zip.output_base64sha256

}


#####
# ポリシー周り
#####

# lambdaのロール
resource "aws_iam_role" "lambda_s3_role" {
  name = "lambda_s3_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# lambdaのロールにs3のアクセスを制御するポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "lambda_s3_policy_attachment" {
  policy_arn = aws_iam_policy.lambda_s3_policy.arn
  role       = aws_iam_role.lambda_s3_role.name
}

resource "aws_iam_policy" "lambda_s3_policy" {
  name        = "lambda_s3_policy"
  description = "Policy to allow Lambda to perform GetObject, PutObject, and CopyObject actions on S3"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:CopyObject"
        ]
        Resource = [
          "arn:aws:s3:::my-bucket/*",  # ここで具体的なARNを指定
        ]
      }
    ]
  })
}


#####
# Lambdaのトリガー設定
#####

# トリガー対象のイベントを定義
resource "aws_s3_bucket_notification" "s3-notification" {
  bucket = module.static-hosting.website-bucket.name

  lambda_function {
    lambda_function_arn = aws_lambda_function.replace_content_type.arn
    # s3:ObjectCreated:CopyはLambda側で実行するため、ここでは指定しない（ループするかもしれないから）
    events              = [
        "s3:ObjectCreated:Put",
        "s3:ObjectCreated:Post",
        "s3:ObjectCreated:CompleteMultipartUpload"
    ]
  }

  depends_on = [aws_lambda_permission.allow-s3-invoke]
}

# Lambdaが特定サービス（S3とか）によって実行される際の権限を定義
resource "aws_lambda_permission" "allow-s3-invoke" {
  statement_id  = "AllowS3InvokeProd"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.replace_content_type.function_name
  principal     = "s3.amazonaws.com"
  source_arn    = "arn:aws:s3:::my-bucket"  # ここで具体的なARNを指定
}
```