AWS S3にセキュアな方法でファイルをアップロードする

## やりたいこと
- モバイルアプリから、S3にファイルをアップロードしたい
    - 動画など、サイズが大きいファイルもある
- 外部からはアップロードさせたくないので、S3はパブリックアクセスをブロックする設定にしておく
- なるべくサーバーレスで構築したい

## 方式検討

### 案1: S3に直接アップロード
大抵の言語には、S3アップロードのためのSDKがあると思います。これを使うというのが一番最初に思いつく方法です。

#### メリット
一番シンプルです。

#### デメリット
今回の要件ではS3はパブリックアクセスを無効にしているため、ファイルをアップロードするためには認証情報（アクセスキー）が必要です。つまり、モバイルから直接アップロードを実装する場合、認証情報はモバイルのコード内に埋め込まなければなりません。この点がセキュリティ的に致命的です。

また、モバイルのコード内にバケット名やキーも埋め込まなければいけない点、アップロードされるファイルのバリデーションなどが（サーバー側で）できない点もデメリットです。

自分一人しか使わないアプリでもない限り、この方法は採るべきではありません。

### 案2: API Gateway + Lambda
次に思いつくのは、アップロード用のAPIを用意する方法です。
AWSでAPIを手軽に作るとなれば、まず検討するのはAPI Gateway + Lambdaの構成でしょう。
モバイルからAPIに対して、ファイルを含むリクエストを送り、Lambda内でS3にアップロードを行うという構成になります。

#### メリット
Lambdaを使うので、認証やバリデーションの処理も一緒に行えるのがメリットです。
モバイルから見るとただのAPIなので、モバイルのコードがAWSインフラに依存しない点もよいです。

#### デメリット
API Gateway, Lambdaには、それぞれ扱えるリクエストのサイズの上限があります。
API Gatewayは10MB、Lambdaは6MBです。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/limits.html#http-api-quotas

（↑「ペイロードサイズ」欄）

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution

（↑「呼び出しペイロード」欄）

今回は動画ファイルをアップロードしたいという要件があります。
動画ファイルは、物によりますが1分あたり100MB程度になるため、この方法は採用できません。

### 案3: 署名済みURLを発行する
S3には、署名済みURLという機能があり、一定時間だけ有効なURLを発行できます。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html

サーバーでアップロード用の署名済みURLを発行してモバイルに返し、モバイルはそのURLに対してPUTリクエストでファイルをアップロードする、という構成です。

URLを発行をするAPIは、案2のようにAPI Gateway + Lambdaでやってもよいですし、AppSyncのGraphQL APIを使ってもよいです。
今回は、既にAppSyncでAPIを構築していたので、AppSync + Lambdaリゾルバーという形をとることにしました。

#### メリット
LambdaやAppSyncを前段に挟むので、認証やバリデーションが行えます。
1回目のリクエストではファイル自体は送らないので、認証エラーなどの場合に余計な通信が発生しないのもメリットです。
サイズ制限も問題ありません。ファイル自体はS3に直接送るので、API GatewayやLambdaのサイズ制限を気にする必要はありません。

#### デメリット
少し複雑な構成になります。
モバイルから見ても、2回サーバーにリクエストを送らなければいけないのが煩雑かもしれません。
AWSインフラ（というかS3）への依存度も、案2と比べると上がります。S3から別のストレージに切り替える場合、モバイル側のコードも変更が必要になるでしょう。

### 結論:案3を採用
案1と案2には致命的なデメリットがあり採用できないため、消去法的に案3を採用することになります。

なお、既にECSやEC2でAPIを構築してある場合は、シンプルにそのAPIに機能追加でよいと思います。今回はインフラをサーバーレスで作っているため、こういう回りくどい方法になりました。

## 実装
上述の通り、URLを発行するAPIはAppSync + Lambdaで実装します。

まず、CDK（TypeScript）のコードから。
AppSync APIを作成する部分は割愛。
```typescript

  /**
   * ファイルアップロードURL生成用のLambda関数を作成する
   * @param api AppSync API
   * @param bucket アップロード先のS3バケット
   */
  createUploadLambda(
    api: appsync.GraphqlApi,
    bucket: Bucket
  ) {
    const lambdaRole = new iam.Role(this, "ExecutionLambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
      ],
    });
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ["s3:PutObject"],
        resources: [
          `${bucket.bucketArn}/*`,
        ],
      })
    );
    const createUploadUrlLambda = new NodejsFunction(
      this,
      "CreateUploadUrlLambda",
      {
        entry: path.join(__dirname, "lambda", "create-upload-url", "index.ts"),
        handler: "handler",
        runtime: lambda.Runtime.NODEJS_20_X,
        role: lambdaRole,
        environment: {
          REGION: cdk.Stack.of(this).region,
          BUCKET: bucket.bucketName,
          EXPIRES_IN: "3600",
        },
      }
    );

    // ここから下はAppSync特有の処理
    // AppSyncのデータソースとしてLambda関数を登録する
    const createUploadUrlDs = api.addLambdaDataSource(
      "CreateUploadUrlDs",
      createUploadUrlLambda
    );

    const createUploadUrlFunction = new appsync.AppsyncFunction(
      this,
      "createUploadUrlFunction",
      {
        api: api,
        dataSource: createUploadUrlDs,
        name: "CreateUploadUrlFunction",
        requestMappingTemplate: MappingTemplate.lambdaRequest(),
        responseMappingTemplate: MappingTemplate.lambdaResult(),
      }
    );

    new appsync.Resolver(this, "CreateUploadUrlResolver", {
      api: api,
      typeName: "Mutation",
      fieldName: "createUploadUrl",
      pipelineConfig: [createUploadUrlFunction],
      requestMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
      responseMappingTemplate: MappingTemplate.fromString(
        "$util.toJson($ctx.prev.result)"
      ),
    });
```

今回はAppSyncを使用するのでスキーマファイルも必要です。
```schema.graphql
# アップロード先のキーを決める情報
input CreateUploadUrlInput {
  file_name: String!
  resource_id: String!
}

# レスポンス
type PresignedUrl {
  bucket: String
  key: String
  presignedUrl: AWSURL
}

type Mutation {
  createUploadUrl(input: CreateUploadUrlInput!): PresignedUrl
}
```

Lambdaの中身は以下のようになります。
```lambda/create-upload-url/index.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AppSyncResolverHandler } from "aws-lambda";
import path from "node:path";

const client = new S3Client({
  region: process.env.REGION,
});

const getPresignedUrl = async (
  bucket: string,
  key: string,
  expiresIn: number
): Promise<string> => {
  const objectParams = {
    Bucket: bucket,
    Key: key,
    StorageClass: "INTELLIGENT_TIERING",
  };
  const signedUrl = await getSignedUrl(
    client,
    new PutObjectCommand(objectParams),
    { expiresIn }
  );
  console.log(signedUrl);
  return signedUrl;
};

export const handler: AppSyncResolverHandler<any, any> = async (event) => {
  const { file_name, resource_id, target } = event.arguments.input;
  const { REGION, BUCKET, EXPIRES_IN } = process.env;

  if (
    !REGION ||
    !BUCKET ||
    !EXPIRES_IN ||
    isNaN(Number(EXPIRES_IN))
  ) {
    throw new Error("invalid environment values");
  }

  const key = buildKey(file_name, resource_id);
  const expiresIn = Number(EXPIRES_IN);

  const url = await getPresignedUrl(BUCKET, key, expiresIn);

  return {
    bucket: BUCKET,
    key: key,
    presignedUrl: url,
  };
};

// keyを組み立てる
const buildKey = (file_name: string, resource_id: string): string => {
  // .mp4
  const extension = path.extname(file_name);
  // hoge
  const stem = path.basename(file_name, extension);
  // YYYYMMDDhhmmsszzz
  const ts = new Date().toISOString().replace(/\D/g, "").slice(0, -3);
  return `${resource_id}/${stem}_${ts}${extension}`;
};
```

## 動作確認
GraphQLエンドポイントに対して、以下のリクエストを投げます。
```graphql
mutation MyMutation {
  createUploadUrl(input: {file_name: "hoge.mp4", resource_id: "1234"}) {
    bucket
    key
    presignedUrl
  }
}
```

レスポンスが以下。
```json
{
    "data": {
        "createUploadUrl": {
            "bucket": "my-bucket-name",
            "key": "1234/hoge_20240524102405.mp4",
            "presignedUrl": "https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=XXXXXXXX&X-Amz-Date=20240524T102405Z&X-Amz-Expires=3600&X-Amz-Security-Token=XXXXXXXX&X-Amz-Signature=XXXXXXXX&X-Amz-SignedHeaders=host&x-amz-storage-class=INTELLIGENT_TIERING&x-id=PutObject"
        }
    }
}
```

返ってきたpresignedUrlに対してPUTリクエストを投げることでアップロードできます。
```bash
curl -X PUT -H "Content-Type: video/mp4" --data-binary @hoge.mp4 https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?...
```


## 参考
https://zenn.dev/ashizaki/articles/94f704734c6ae6