API Gateway、Cognito、Lambda、S3を使って署名付きアップロード・ダウンロードを実装する。

# はじめに
AWSが運営している[AWSブログ](https://aws.amazon.com/jp/blogs/news/)を流し見していたところ、ちょうどこんなシステムを作ってみたいと思い描いていた内容と合致するソリューションが手順・サンプル付きで紹介されていたので、少しハマったところも含めて手順をまとめてみようと思います。

- [AWS のサーバーレスと Amazon S3 署名付き URL、クライアントサイド JavaScript で大きなサイズの複数ファイルの一括アップロード・ダウンロード機能を実現する方法](https://aws.amazon.com/jp/blogs/news/large-size-files-transferring-by-serverless-s3presignedurl-and-clientside-javascript/)

2023/7/22 今回の構成をterraformで作りました。

- 【次】[署名付きアップロード・ダウンロードの構成をterraformで作る。](https://qiita.com/sakai00kou/items/fed9b12cbbb96025e329)

# 紹介するソリューションについて
S3の機能で一時認証キーを使って一定期間のみアクセスを可能とする署名付きURLがありますが、署名付きURLを使ってCognito認証を行った上でアップロード・ダウンロードが行えるソリューションとなります。

# 署名付きURLとは
S3の機能の一つで、一定時間のみアクセス可能な一時認証キーを含めたURLを発行することで、該当のS3バケットに対してアクセス権がないユーザでもS3バケット内のファイルをダウンロードすることが可能となる機能です。

また、Lambda等で作り込みが必要ですが、アップロードでも署名付きURLで行うことが可能です。

設定も簡単で、S3のファイルを選択して「**署名付きURLで共有**」を行うことで署名付きURLを送られたユーザは一定時間、誰でもダウンロードすることが可能であるためちょっとしたファイルのやり取りに便利です。

便利である反面、署名付きURLの権限は**署名付きURLを発行したユーザ・サービスの権限となる**ことから、例えばIAMポリシーやS3バケットポリシーでユーザによる制限ができないため（制限すると署名付きURLを発行したユーザ・サービスがそもそもアクセスできなくなるため）、セキュリティを意識する場合は対策が難しい機能でもあります。

![署名付きURL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/7c793dee-5716-32fc-f46e-9e997c4a1e0b.png)

今回のソリューションの場合、ファイルアップロード・ダウンロードを行う際にAmazon Cognitoによる認証を挟むことで誰でもアップロード・ダウンロードできてしまうような状況を回避しているため、以下より紹介していきます。

# 構成
ファイル格納先にAmazon S3、APIアクセスにAmazon API Gateway、アップロード・ダウンロードの処理にAWS Lambda、アップロード・ダウンロードを行う際のユーザ認証にAmazon Cognitoを使った構成となります。

![01_-architecture_diagram_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/3551a0ba-7348-ef35-0bba-ff013e319eb7.png)

元ページにシーケンス図付きで紹介されているので詳しくは元ページを参照してください。

# 手順
私自身は`API Gateway`や`Cognito`をほとんど触ったことがなく、手順をそのままやってもうまくいかなかったり、少しハマったところもあったので、補足を加えつつ以下手順で紹介していきます。

- [Amazon Cognitoの作成](#amazon_cognitoの作成)
    - [ユーザプールの作成](#ユーザプールの作成)
    - [リソースサーバの作成](#リソースサーバの作成)
    - [アプリケーションクライアントの設定](#アプリケーションクライアントの設定)
- [Amazon S3の作成](#amazon-s3の作成)
    - [バケット作成](#バケット作成)
- [AWS Lambdaの作成](#aws-lambdaの作成)
    - [IAMポリシーの作成](#iamポリシーの作成)
    - [IAMロールの作成](#iamロールの作成)
    - [アップロード用Lambda関数の作成](#アップロード用lambda関数の作成)
    - [ダウンロード用Lambda関数の作成](#ダウンロード用lambda関数の作成)
- [Amazon API Gatewayの作成](#amazon-api-gatewayの作成)
    - [API Gateway用IAMロールの作成](#api-gateway用iamロールの作成)
    - [API Gatewayの作成](#api-gatewayの作成)
    - [APIの作成](#apiの作成)
        - [静的・動的コンテンツ向けAPIの作成](#静的動的コンテンツ向けapiの作成)
            - [Proxyリソースの作成](#proxyリソースの作成)
            - [統合リクエスト](#統合リクエスト)
            - [メソッドレスポンス](#メソッドレスポンス)
            - [統合レスポンス](#統合レスポンス)
        - [アップロードとダウンロードの署名付きURLと関連情報の取得APIの作成](#アップロードとダウンロードの署名付きurlと関連情報の取得apiの作成)
        - [オーソライザー](#オーソライザー)
            - [アップロード・ダウンロードAPIへのオーソライザー付与](#アップロードダウンロードapiへのオーソライザー付与)
            - [APIデプロイ](#apiデプロイ)
- [Amazon Cognitoの追加設定](#amazon-cognitoの追加設定)
- [Amazon S3の追加設定](#amazon-s3の追加設定)
    - [バケットポリシーの設定](#バケットポリシーの設定)
    - [CORSの設定](#corsの設定)
- [静的・動的コンテンツの格納](#静的動的コンテンツの格納)
    - [env-vals.jsファイルの設定](#env-valsjsファイルの設定)
    - [その他ファイルの設定](#その他ファイルの設定)
    - [ファイルの格納](#ファイルの格納)
- [アップロード、ダウンロード確認](#アップロードダウンロード確認)
    - [エントランスページアクセス](#エントランスページアクセス)
    - [Cognitoユーザの作成](#cognitoユーザの作成)
    - [ファイルダウンロード確認](#ファイルダウンロード確認)
    - [ファイルアップロード確認](#ファイルアップロード確認)

## Amazon Cognitoの作成
以下で、アップロード・ダウンロードを行う際のユーザ認証を行うためのCognito設定を行っていきます。

- [ユーザプールの作成](#ユーザプールの作成)
- [リソースサーバの作成](#リソースサーバの作成)
- [アプリケーションクライアントの設定](#アプリケーションクライアントの設定)

### ユーザプールの作成
「**Amazon Cognito**」のダッシュボードの「**ユーザープールを作成**」より、Cognitoによる認証方法やユーザ設定内容を設定していきます。

設定項目が多いですが、今回は[ブログ記事で記載されている設定](https://aws.amazon.com/jp/blogs/news/large-size-files-transferring-by-serverless-s3presignedurl-and-clientside-javascript/)以外はほぼデフォルトの値で進めていきます。

実際にシステムに導入する際には自分のシステム要件に合わせて任意に変更してください。

|項目|設定|備考|
|:--|:--|:--|
|プロバイダーのタイプ|Cognitoユーザープール||
|Cognitoユーザープールのサインインオプション|Eメール||
|パスワードポリシーモード|Cognitoのデフォルト||
|MFAの強制|オプションのMFA||
|MFAの方法|SMSメッセージ||
|セルフサービスのアカウントの復旧|セルフサービスのアカウントの復旧を有効化||
|ユーザーアカウント復旧メッセージの配信方法|Eメールのみ||
|自己登録|自己登録を有効化||
|Cognitoアシスト型の検証および確認|Cognitoが検証と確認のためにメッセージを自動的に送信することを許可する||
|検証する属性|Eメールのメッセージを送信、Eメールアドレスを検証||
|属性変更の確認|未完了の更新があるときに元の属性値をアクティブに保つ||
|未完了の更新があるときのアクティブな属性値|Eメールアドレス||
|必須の属性|email||
|カスタム属性|未設定|デフォルトのまま|
|Eメールプロバイダー|CognitoでEメールを送信||
|送信元のEメールアドレス|no-reply@verificationemail.com|デフォルトのメールアドレスを使用|
|返信先Eメールアドレス|空欄||
|IAMロール|新しいIAMロールを作成||
|IAMロール名|Cognito-UserPool-Role|任意の名前を指定|
|ユーザープール名|Cognito-UserPool|任意の名前を指定|
|ホストされた認証ページ|CognitoのホストされたUIを使用|チェックを付ける|
|ドメインタイプ|Cognitoドメインを使用する||
|Cognitoドメイン|https://presigned-url-ul-dl.auth.ap-northeast-1.amazoncognito.com|任意の名前を指定|
|アプリケーションタイプ|パブリッククライアント||
|アプリケーションクライアント名|Cognito-Client|任意の名前を指定|
|クライアントシークレット|クライアントのシークレットを生成しない||
|許可されているコールバックURL|https://localhost|後ほど変更するのでダミーのURLを入力|
|高度なアプリケーションクライアントの設定|未設定|デフォルトのまま|
|属性の読み取りおよび書き込み許可|未設定|デフォルトのまま|
|タグ|未設定|今回はタグなしで作成|

### リソースサーバの作成
先程作成したユーザープールを選択し、「**アプリケーションの統合**」タブの「**リソースサーバーを作成**」から作成を行っていきます。

|項目|設定|備考|
|:--|:--|:--|
|リソースサーバー名|Cognito-ResourceServer|任意の名前を指定|
|リソースサーバー識別子|Cognito-ResourceServer-Identifier|任意の名前を指定|
|カスタムスコープ|未設定|デフォルトのまま|

### アプリケーションクライアントの設定
上記と同じく「**アプリケーションの統合**」タブの画面下の「**アプリクライアントと分析**」に作成したアプリケーションクライアント名（今回はCognito-Client）が表示されているため、アプリケーションクライアント名を選択して詳細画面に入ります。

画面より「**ホストされたUI**」の「**編集**」から編集画面に入り、「**OpenID Connectのスコープ**」に「**OpenID**」が追加されていることを確認します。

もし「**OpenID**」が選択されていなければドロップダウンリストから「**OpenID**」を追加して「**設定の保存**」で保存してください。

## Amazon S3の作成
ダウンロードファイルやアップロードファイルを格納するS3を作成します。

作成自体はデフォルトのままで作成し、追加設定でフォルダ作成、静的ウェブサイトホスティングを行っていきます。

- [バケット作成](#バケット作成)

### バケット作成
バケット作成は「**Amazon S3**」の「**バケットを作成**」より以下のようにデフォルト値で作成します。

|項目|設定|備考|
|:--|:--|:--|
|バケット名|presigned-url-ul-dl-bucket|任意の名前を指定|
|AWSリージョン|ap-northeast-1||
|オブジェクト所有者|ACL無効||
|このバケットのブロックパブリックアクセス設定|パブリックアクセスをすべてブロック|
|バケットのバージョニング|無効にする||
|タグ|未設定|デフォルトのまま|
|暗号化タイプ|Amazon S3マネージドキーを使用したサーバー側の暗号化(SSE-S3)|
|バケットキー|有効にする|
|詳細設定|未設定|デフォルトのまま|

バケット作成後、作成したバケットの「**オブジェクト**」タブから「**フォルダの作成**」でアップロードファイル、ダウンロードファイルを格納するフォルダとアップロード、ダウンロード画面のコンテンツを格納するフォルダを作成します。

|フォルダ名|説明|
|:--|:--|
|contents|静的・動的コンテンツ保存|
|download|ダウンロードするファイルを保存|
|upload|アップロードするファイルを保存|

前述したアップロード、ダウンロード画面はS3でそのまま静的コンテンツとして公開するため、「**プロパティ**」タブの「**静的ウェブサイトホスティング**」の「**編集**」より「**有効にする**」を選択し、「**変更の保存**」で静的ウェブサイトホスティングできるようにします。

|項目|設定|備考|
|:--|:--|:--|
|静的ウェブサイトホスティング|有効にする||
|ホスティングタイプ|静的ウェブサイトをホストする||
|インデックスドキュメント|index.html|入力しないと保存できないため適当に入力|
|エラードキュメント|空欄||
|リダイレクトルール|空欄||

## AWS Lambdaの作成
本ソリューションでは、S3の署名付きURLをLambdaで生成するため、アップロード用、ダウンロード用それぞれLambdaで作成していきます。

- [IAMポリシーの作成](#iamポリシーの作成)
- [IAMロールの作成](#iamロールの作成)
- [アップロード用Lambda関数の作成](#アップロード用lambda関数の作成)
- [ダウンロード用Lambda関数の作成](#ダウンロード用lambda関数の作成)

### IAMポリシーの作成
作成するLambdaにS3へのアップロード、ダウンロードを許可するため、先にIAMポリシーとIAMロールを作成します。

「**Identity and Access Management (IAM)**」の「**ポリシー**」の「**ポリシーを作成**」から以下のように作成していきます。

今回は「**JSON**」を選択し、直接以下ポリシーを作成していきます。

「**ユーザーアカウント**」と「**バケット名**」は自身のアカウントに合わせて修正してください。

「**ポリシー名**」は任意で構いませんが、今回は`s3-ul-dl-lambda-policy`と言う名前のポリシー名を作成したものとして進めていきます。

また、今回は後ほど作成するアップロード用Lambda、ダウンロード用Lambdaで共通して同じポリシーを使用したかったため、`CreateLogStream`、`PutLogEvents`が記載されているブロックのリソースは、末尾を「*（アスタリスク）」としています。

<details><summary>IAMポリシーの作成（展開してください）</summary>

```json:IAMポリシーの作成
{
    "Version":"2012-10-17",
    "Statement":[
        {
            "Effect":"Allow",
            "Action":"logs:CreateLogGroup",
            "Resource":"arn:aws:logs:ap-northeast-1:<ユーザーアカウント>:*"
        },
        {
            "Effect":"Allow",
            "Action":[
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource":"arn:aws:logs:ap-northeast-1:<ユーザーアカウント>:log-group:/aws/lambda/*"
        },
        {
            "Effect":"Allow",
            "Action":"s3:ListBucket",
            "Resource":"arn:aws:s3:::<バケット名>"
        },
        {
            "Effect":"Allow",
            "Action":[
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource":"arn:aws:s3:::<バケット名>/*"
        }
    ]
}
```

</details>

### IAMロールの作成
作成したIAMポリシーを付与するIAMロールも作成しておきます。

「**Identity and Access Management (IAM)**」の「**ロール**」の「**ロールを作成**」から以下のように作成していきます。

|項目|設定|備考|
|:--|:--|:--|
|信頼されたエンティティタイプ|AWSのサービス||
|ユースケース|Lambda||
|許可ポリシー|s3-ul-dl-lambda-policy|先ほど作成したIAMポリシーを選択|
|ロール名|s3-ul-dl-lambda-role|任意の名前を指定|
|説明|未設定|デフォルトのまま|
|タグ|未設定|デフォルトのまま|

### アップロード用Lambda関数の作成
「**AWS Lambda**」の「**関数**」より「**関数の作成**」を選択し、以下のようにアップロード用Lambda関数を作成します。

実行ロールについては先程作成したIAMロールを指定します。

|項目|設定|備考|
|:--|:--|:--|
|関数の作成|一から作成||
|関数名|s3-upload-lambda|任意の名前を指定|
|ランタイム|Python 3.10||
|アーキテクチャ|x86_64||
|デフォルトの実行ロールの変更|既存のロールを使用する||
|既存のロール|s3-ul-dl-lambda-role|先程作成したIAMロールを指定|
|詳細設定|未設定|デフォルトのまま|

関数作成後、「**コード**」タブより、サンプルで表示されているPythonコードを削除し、以下コードをコピペします。

なお、もしPythonファイル名を変更する場合は、画面下「**ランタイム設定**」のハンドラを「**[Pythonファイル名].lambda_handler**」に変更してください。

<details><summary>アップロード用Lambdaコード（展開してください）</summary>

```python:アップロード用Lambdaコード
import json
import boto3
from botocore.client import Config

import os

S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"]
S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"]
DURATION_SECONDS = int(os.environ["DURATION_SECONDS"])

s3_client = boto3.client("s3", config=Config(signature_version="s3v4")) 


def lambda_handler(event, context):

    # 戻り値の初期化
    return_obj = dict()
    return_obj["body"] = dict()
    
    # バケット名の設定
    return_obj["body"]["bucket"] = S3_BUCKET_NAME
    # フォルダー名の設定
    return_obj["body"]["prefix"] = S3_PREFIX_NAME

    target_info = s3_client.generate_presigned_post(S3_BUCKET_NAME,
                                                    S3_PREFIX_NAME + "${filename}", 
                                                    Fields=None,
                                                    Conditions=None,
                                                    ExpiresIn=DURATION_SECONDS)
    
    # 取得した各情報の戻り値への設定
    return_obj["body"]["contents"] = target_info
    
    return_obj["statusCode"] = 200
    return_obj["body"] = json.dumps(return_obj["body"])

    return return_obj
```

</details>

作成後は「**Deploy**」ボタンを選択し、デプロイしておきます。

また、環境変数の設定が必要となるため、「**設定**」タブの「**環境変数**」の「**編集**」から以下環境変数を設定しておきます。

|キー|値|説明|
|:--|:--|:--|
|DURATION_SECONDS|3600|ここでは1時間の設定|
|S3_BUCKET_NAME|presigned-url-ul-dl-bucket|先程作成したS3バケット名を指定|
|S3_PREFIX_NAME|upload/|フォルダー名 +「/」(スラッシュ)|

### ダウンロード用Lambda関数の作成
アップロード用Lambda関数の作成と同様に設定していきます。

「**AWS Lambda**」の「**関数**」より「**関数の作成**」を選択し、以下のようにダウンロード用Lambda関数を作成します。

|項目|設定|備考|
|:--|:--|:--|
|関数の作成|一から作成||
|関数名|s3-download-lambda|任意の名前を指定|
|ランタイム|Python 3.10||
|アーキテクチャ|x86_64||
|デフォルトの実行ロールの変更|既存のロールを使用する||
|既存のロール|s3-ul-dl-lambda-role|先程作成したIAMロールを指定|
|詳細設定|未設定|デフォルトのまま|

関数作成後、「**コード**」タブより、サンプルで表示されているPythonコードを削除し、以下コードをコピペします。

Pythonファイル名を変更する場合は、先程と同様「**ランタイム設定**」のハンドラを「**[Pythonファイル名].lambda_handler**」に変更してください。

<details><summary>ダウンロード用Lambdaコード（展開してください）</summary>

```python:ダウンロード用Lambdaコード
import json
import datetime
import botocore
import boto3
import os

S3_BUCKET_NAME = os.environ["S3_BUCKET_NAME"]
S3_PREFIX_NAME = os.environ["S3_PREFIX_NAME"]
DURATION_SECONDS = int(os.environ["DURATION_SECONDS"])

# S3クライアント
s3_client = boto3.client("s3")

def lambda_handler(event, context):
    
    # 戻り値の初期化
    return_obj = dict()
    return_obj["body"] = dict()
    
    # バケット名の設定
    return_obj["body"]["bucket"] = S3_BUCKET_NAME
    # フォルダー名の設定
    return_obj["body"]["prefix"] = S3_PREFIX_NAME
    # ファイル (オブジェクト) 一覧の初期化
    return_obj["body"]["contents"] = []
    
    # ファイル一覧情報の取得
    response = s3_client.list_objects_v2(Bucket=S3_BUCKET_NAME, Prefix=S3_PREFIX_NAME)
    
    for content in response["Contents"]:
    
        # ファイル情報の初期化
        object = dict()
        
        # ファイルサイズの取得
        size = content["Size"]
        if(size == 0):
          # ファイルサイズが 0 の場合、その後の処理をスキップ
          continue
        
        # ファイル名の取得と戻り値への設定
        key = content["Key"]
        object["name"] = key.replace(S3_PREFIX_NAME, "").replace("/", "")
        
        # ファイルサイズの戻り値への設定
        object["size"] = "{:,} Bytes".format(size)
        
        # ファイル更新日時の取得と戻り値への設定
        # 日本のタイムゾーン (JST)
        tz_jst = datetime.timezone(datetime.timedelta(hours=9))
        # 取得日時をJSTに変換
        dt_jst = content['LastModified'].astimezone(tz_jst)
        object["lastModified"] = dt_jst.strftime('%Y/%m/%d %H:%M:%S')
        
        # 署名付き URL の取得と戻り値への設定
        object["presignedUrl"] = s3_client.generate_presigned_url(
            ClientMethod = "get_object",
            Params = {"Bucket" : S3_BUCKET_NAME, "Key" : key},
            ExpiresIn = DURATION_SECONDS,
            HttpMethod = "GET"
        )
    
        # 取得した各情報の戻り値への設定
        return_obj["body"]["contents"].append(object)
  
    return_obj["statusCode"] = 200
    return_obj["body"] = json.dumps(return_obj["body"])
    
    return return_obj
```

</details>

作成後は「**Deploy**」ボタンを選択し、デプロイしておきます。

また、環境変数の設定が必要となるため、「**設定**」タブの「**環境変数**」の「**編集**」から以下環境変数を設定しておきます。

|キー|値|説明|
|:--|:--|:--|
|DURATION_SECONDS|3600|ここでは1時間の設定|
|S3_BUCKET_NAME|presigned-url-ul-dl-bucket|先程作成したS3バケット名を指定|
|S3_PREFIX_NAME|download/|フォルダー名 +「/」(スラッシュ)|

## Amazon API Gatewayの作成
API Gatewayを作成する前に、API Gatewayで指定するIAMロールを先に作成します。

- [API Gateway用IAMロールの作成](#api-gateway用iamロールの作成)
- [API Gatewayの作成](#api-gatewayの作成)
- [APIの作成](#apiの作成)
    - [静的・動的コンテンツ向けAPIの作成](#静的動的コンテンツ向けapiの作成)
        - [Proxyリソースの作成](#proxyリソースの作成)
        - [統合リクエスト](#統合リクエスト)
        - [メソッドレスポンス](#メソッドレスポンス)
        - [統合レスポンス](#統合レスポンス)
    - [アップロードとダウンロードの署名付きURLと関連情報の取得APIの作成](#アップロードとダウンロードの署名付きurlと関連情報の取得apiの作成)
    - [オーソライザー](#オーソライザー)
        - [アップロード・ダウンロードAPIへのオーソライザー付与](#アップロードダウンロードapiへのオーソライザー付与)
        - [APIデプロイ](#apiデプロイ)

### API Gateway用IAMロールの作成
今回はAWSマネージドポリシーをアタッチして作成していきます。

「**Identity and Access Management (IAM)**」の「**ロール**」の「**ロールを作成**」から以下のように作成していきます。

|キー|値|説明|
|:--|:--|:--|
|信頼されたエンティティタイプ|AWSのサービス||
|ユースケース|API Gateway||
|許可ポリシー|AmazonAPIGatewayPushToCloudWatchLogs|デフォルトのまま|
|ロール名|s3-ul-dl-apigw-role|任意の名前を指定|
|説明|未設定|デフォルトのまま|
|タグ|未設定|デフォルトのまま|

作成したロールに付与されているポリシーだけでは足りないため、作成したロール名を指定して、「**許可**」タブの「**許可を追加**」→「**ポリシーをアタッチ**」より「**AmazonS3FullAccess**」を追加します。
※セキュリティを重視する場合はきちんと制限してください。

### API Gatewayの作成
「**API Gateway**」の「**APIを作成**」を選択し、「**APIタイプを選択**」から「**REST API**」の「**構築**」でAPI Gatewayを以下のように作成していきます。

|項目|設定|備考|
|:--|:--|:--|
|プロトコルを選択する|REST||
|新しいAPIの作成|新しいAPI||
|API名|s3-ul-dl-apigw|任意の名前を指定|
|説明|空欄||
|エンドポイントタイプ|リージョン||

### APIの作成
今回はREST APIにより、指定のパスにアクセスした際に先程作成したLambdaを実行したり、S3に格納しているコンテンツにアクセスしたりできるように設定を行います。

最終的に以下のような構成のリソースを作成していきます。

![Monosnap_20230715_135659.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/97850460-c902-b71e-6a7c-0303defe547f.png)

#### 静的・動的コンテンツ向けAPIの作成
今回のソリューションの場合、ファイルアップロード・ダウンロードを行う際、専用のページから操作してファイルアップロード・ダウンロードを行うため、ファイルアップロード・ダウンロードページの静的・動的コンテンツ用のAPIを作成していきます。

「**APIを作成**」よりAPIを作成すると、以下のような画面が表示されます。

![Monosnap_20230715_140040.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/b2b4a598-1b03-7466-7419-7e2fe3ebf47e.png)

「**アクション**」を選択することで**メソッド**（GETやPUT等の設定）や**リソース**（/webや/api等のパス）の設定を行うことができます。

さっそく「**アクション**」から「**リソースの作成**」を選択し、「**/web**」のリソースを作成します。

|項目|設定|備考|
|:--|:--|:--|
|新しいウィンドウにプロキシリソースが開きます|チェックなし||
|リソース名|web||
|リソースパス|/web|リソース名を入力すると自動的に入力される|
|API Gateway CORSを有効にする|チェックなし||

##### Proxyリソースの作成
あまりAPI Gatewayに詳しくないので、ここから先は別のやり方もあるかもしれませんが、私が構築できたやり方を紹介します。

作成した「**/web**」リソースを選択し、「**リソースの作成**」からproxyリソースを作成しますが、ここでは「**新しいウィンドウにプロキシリソースが開きます**」にチェックを行います。

チェックを行うことでリソース名、リソースパスに自動的にproxyが入力されるためそのまま「**リソースの作成**」を選択します。

![Monosnap_20230715_141149.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/b9b584e7-7481-c9a9-7d67-0a875ac3a785.png)

作成すると、「**/{proxy+}**」の配下に「**ANY**」メソッドが作成されるため、「**アクション**」から**ANYメソッドを削除します。**

![Monosnap_20230715_141504.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/17afbd48-c4ce-bdbd-0a79-b71b883c5cd9.png)

削除した上で「**/{proxy+}**」を選択してから、「**メソッドの作成**」から「**GET**」を選択します。

ただ、この状態だと、以下のように表示され、元ページのように「**AWSサービス**」が選択できません。

![Monosnap_20230715_142327.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/4bf74be6-dc36-1000-233a-11a45de3a3a0.png)

そのため、以下表のように設定を行って保存してから新たに変更していきます。

|項目|設定|備考|
|:--|:--|:--|
|統合タイプ|HTTPプロキシ||
|エンドポイントURL|https://example.com/web/{proxy}|後ほど変更されるので、仮URLを入力|
|コンテンツの処理|パススルー||
|デフォルトタイムアウトの使用|チェックする||

##### 統合リクエスト
メソッドの設定画面より、「**統合リクエスト**」を選択すると、今度は「**統合タイプ**」で「**AWSサービス**」が選択できるようになるので、以下のように設定します。

|項目|設定|備考|
|:--|:--|:--|
|統合タイプ|AWSサービス||
|AWSリージョン|ap-northeast-1||
|AWSサービス|Simple Storage Service (S3)||
|AWSサブドメイン|空欄||
|HTTPメソッド|GET||
|アクションの種類|パス上書きの使用||
|パス上書き(省略可能)|presigned-url-ul-dl-bucket/contents/{proxy}|先程作成したS3バケット名を指定<br>今回は`presigned-url-ul-dl-bucket`とする|
|実行ロール|arn:aws:iam::[AWSアカウントID]:role/s3-ul-dl-apigw-role|先程作成したAPI Gateway用IAMロールのARNを指定|
|コンテンツの処理|パススルー||
|デフォルトタイムアウトの使用|チェックする||

上記設定を行った後、「**保存**」を選択すると、以下のようなウィンドウが表示されるため、「**OK**」として進めます。

![Monosnap_20230715_152537.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/6bd84909-0950-7b4f-2a91-849772c7a312.png)

同画面上の「**URLパスパラメータ**」の「**パスの追加**」より、以下設定を追加して右側のチェックボタンを選択します。

|名前|マッピング元|キャッシュ|
|:--|:--|:--|
|proxy|method.request.path.proxy|空欄|

##### メソッドレスポンス
メソッドの実行画面より、「**メソッドレスポンス**」を選択して以下のように設定します。

|200レスポンスヘッダ名|備考|
|:--|:--|
|Content-Length|200のレスポンスヘッダのみ設定|
|Content-Type|200のレスポンスヘッダのみ設定|
|Timestamp|200のレスポンスヘッダのみ設定|

また、レスポンスとして、「**400**」と「**500**」の追加を行います。

ヘッダの設定等は不要のため、「**レスポンスの追加**」から作成するだけでOKです。

最終的に以下のような画面になるように設定します。

![Monosnap_20230715_153441.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/fcf9256b-6f08-c23e-767a-3bcc9e3d6a10.png)

##### 統合レスポンス
メソッドの実行画面より、「**統合レスポンス**」を選択して以下のように設定します。

|200レスポンスヘッダ名|マッピングの値|備考|
|:--|:--|:--|
|Content-Length|integration.response.header.Content-Length|200のレスポンスヘッダのみ設定|
|Content-Type|integration.response.header.Content-Type|200のレスポンスヘッダのみ設定|
|Timestamp|integration.response.header.Date|200のレスポンスヘッダのみ設定|

また、「**統合レスポンスの追加**」から400と500の統合レスポンスも追加します。

|HTTPステータスの正規表現|メソッドレスポンスのステータス|コンテンツの処理|
|:--|:--|:--|
|4\d{2}|400|パススルー|
|5\d{2}|500|パススルー|

最終的には以下のような画面になるように設定します。

![Monosnap_20230715_155034.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/55b4a483-de58-8c7b-bf31-c8037960ceb6.png)

#### アップロードとダウンロードの署名付きURLと関連情報の取得APIの作成
「**/api**」以下リソースとメソッドを作成するため、「**アクション**」→「**リソースの作成**」から以下のように設定します。
※設定としてはあまり変わらないため、以下表でまとめて紹介します。

![Monosnap_20230715_160130.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/0dda8ab4-723b-63ba-b16c-79878974d852.png)

|項目|api設定|upload設定|download設定|備考|
|:--|:--|:--|:--|:--|
|新しいウィンドウにプロキシリソースが開きます|チェックなし|チェックなし|チェックなし||
|リソース名|api|upload|download|uploadとdownloadはapi配下にリソースを作成する|
|リソースパス|/api|/api/upload|/api/download|リソース名を入力すると自動的に入力される|
|API Gateway CORSを有効にする|チェックなし|チェックなし|チェックなし||

リソース作成後、`/upload`と`/download`にはGETメソッドも作成するため、「**アクション**」→「**メソッドの作成**」から「**GET**」を選択し、以下のように設定します。

|項目|upload設定|download設定|備考|
|:--|:--|:--|:--|
|統合タイプ|Lambda関数|Lambda関数||
|Lambdaプロキシ統合の使用|チェックする|チェックする||
|Lambdaリージョン|ap-northeast-1|ap-northeast-1||
|Lambda関数|s3-upload-lambda|s3-download-lambda|先程作成したLambdaの名前を指定|
|デフォルトタイムアウトの使用|チェックする|チェックする||

また、メソッド保存時、指定したLambdaに対して権限を追加する旨のメッセージが表示されます。

![Monosnap_20230715_162019.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/de016230-0c04-3cf8-fb81-459cdcb2af6b.png)

「**OK**」とすることで、指定したLambdaのトリガーに「**API Gateway**」が自動的に追加され、指定のパスにアクセスしたことをトリガーとして指定のLambdaが実行されるようになります。

![Monosnap_20230715_162421.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/3be20a5e-bc55-f34e-a2f3-5c0211fb3e07.png)

#### オーソライザー
冒頭で紹介した通り、署名付きURLでアクセスするユーザは**署名付きURLを発行したユーザ・サービスの権限でアクセスする**ことになるため、IAMロールやS3バケットポリシーで制限することが難しいです。

そのため、アップロード・ダウンロードページにアクセスする際には、先程作成したCognitoによる認証を挟むように設定していきます。

「**Amazon API Gateway**」の左メニューの「**オーソライザー**」→「**新しいオーソライザーの作成**」でAPI Gatewayで使用する認証を以下のように作成します。

|項目|設定|備考|
|:--|:--|:--|
|名前|s3-ul-dl-authorizer|任意の名前を指定|
|タイプ|Cognito||
|Cognitoユーザープール|Cognito-UserPool|先程作成したCognitoユーザプール名|
|トークンのソース|Authorization|固定値を入力|
|トークンの検証|空欄||

##### アップロード・ダウンロードAPIへのオーソライザー付与
アップロード・ダウンロードのGETメソッドを選択し、「**メソッドリクエスト**」の「**認可**」より先程作成したオーソライザーを指定します。

「**認可**」設定は選択式で指定できますが、作成したオーソライザーが表示されない場合は一度画面をリロードしてください。

|項目|設定|備考|
|:--|:--|:--|
|認可|s3-ul-dl-authorizer|先程作成したオーソライザーを選択|
|OAuthスコープ|なし|デフォルト値|
|リクエストの検証|なし|デフォルト値|
|APIキーの必要性|false|デフォルト値|

![Monosnap_20230715_173527.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/95dc6c42-b9d3-7991-902c-e3f3676e7793.png)

##### APIデプロイ
API Gatewayの設定ができたら、デプロイを行って、設定を反映します。

「**アクション**」より「**APIのデプロイ**」を選択することで設定できますが、初回デプロイ時はステージが作成されていないため、以下のような画面が表示されます。

![Monosnap_20230715_174248.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/10cd9c44-b01e-4347-8df6-59f89d88a88e.png)

今回は`dev`というステージを作成してデプロイを行います。

## Amazon Cognitoの追加設定
作成済みのCognitoの設定に、先程作成したAPI Gatewayの設定を追加するため、「**Amazon Cognito**」→「**ユーザープール**」より、作成済みのユーザプール（今回の場合`Cognito-UserPool`）を選択し、「**アプリケーションの統合**」タブの「**アプリクライアントと分析**」から作成済みの「**アプリケーションクライアント名**」（今回の場合`Cognito-Client`）を選択します。

「**ホストされたUI**」の「**編集**」より「**許可されているコールバックURL**」と「**許可されているサインアウトURL**」にそれぞれ以下の設定を行います。

なお、コールバックURLにCognito作成時に設定したダミーURL（`https://localhost`）が書かれていますが、こちらは削除してください。

|URL種別|URL|
|:--|:--|
|許可されているコールバックURL|https://[API_ID].execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/web/download.html|
|許可されているコールバックURL|https://[API_ID].execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/web/entrance.html|
|許可されているコールバックURL|https://[API_ID].execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/web/upload.html|
|許可されているサインアウトURL|https://[API_ID].execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/web/entrance.html|

上記`[API_ID]`、`[ステージ名]`についてはAPI Gatewayの設定画面より、作成したAPIの「**ステージ**」の「**URLの呼び出し**」に`[API_ID]`と`[ステージ名]`が含まれたエンドポイントURLが記載されているため、記載されているURLを上記URL欄に記載してください。

![Monosnap_20230715_180520.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/071ad4f5-17b6-afa1-9375-5ef1f0c008de.png)

## Amazon S3の追加設定
作成済みのS3に対して、バケットポリシーとCross-Origin Resource Sharing (CORS)の設定を行っていきます。

- [バケットポリシーの設定](#バケットポリシーの設定)
- [CORSの設定](#corsの設定)

### バケットポリシーの設定
作成済みのS3の「**アクセス許可**」→「**バケットポリシー**」より以下のように設定します。

`[S3バケット名]`は作成したS3バケット名、`[AWSアカウントID]`は自分のAWSアカウントID、`[API_ID]`は先程調べたAPI IDを入力してください。

```json:バケットポリシーの設定
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Principal": {
                "Service": "apigateway.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::[S3バケット名]/*",
            "Condition": {
                "ArnLike": {
                    "aws:SourceArn": "arn:aws:execute-api:ap-northeast-1:[AWSアカウントID]:[API_ID]/*/GET/"
                }
            }
        }
    ]
}
```

### CORSの設定
作成済みのS3の「**アクセス許可**」→「**Cross-Origin Resource Sharing (CORS)**」より以下のように設定します。

```json:CORSの設定
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "POST"
        ],
        "AllowedOrigins": [
            "https://[API_ID].execute-api.ap-northeast-1.amazonaws.com"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]
```

## 静的・動的コンテンツの格納
それぞれのファイルを作成して作成済みS3バケットの`contents`フォルダ配下に格納します。

以下ファイルのうち、`env-vals.js`のみ環境に合わせて変数の設定が必要となります。

|ファイル名|ファイルタイプ|説明|
|:--|:--|:--|
|entrance.html|HTML|エントランスページ|
|upload.html|HTML|アップロード用ページ|
|download.html|HTML|ダウンロード用ページ|
|env-vals.js|JavaScript|環境変数ファイル<br>環境に合わせて設定が必要|
|entrance.js|JavaScript|エントランスページ用モジュール|
|ul-dl-shared.js|JavaScript|アップロード・ダウンロード共通モジュール|
|upload.js|JavaScript|アップロードページ用モジュール|
|download.js|JavaScript|ダウンロードページ用モジュール|
|style.css|CSS|スタイルシート|

- [env-vals.jsファイルの設定](#env-valsjsファイルの設定)
- [その他ファイルの設定](#その他ファイルの設定)
- [ファイルの格納](#ファイルの格納)

### env-vals.jsファイルの設定
以下自分の環境に従ってCognitoの設定を記載します。

```js:env-vals.jsファイル
/**
 * Environment variables
 * 環境変数
 */
// for Amazon Cognito configuration
export const USERPOOL_DOMAIN = "<ドメイン>";
export const USERPOOL_REGION = "<リージョン>";
export const USERPOOL_CLIENT_ID = "<クライアントID>";
export const USERPOOL_RESPONSE_TYPE = "code";
export const USERPOOL_SCOPE = "openid";

// for Amazon API Gateway configuration
export const EXECUTE_API_STAGE = "/<ステージ>";
```

分かりづらいため書き換える変数について表にまとめます。

|書き換え項目|書き換え設定値|備考|
|:--|:--|:--|
|<ドメイン>|presigned-url-ul-dl|Cognitoドメイン設定した際に入力した部分のみ指定（`***.auth.ap-northeast-1.amazoncognito.com`は不要）|
|<リージョン>|ap-northeast-1|作成したリージョン|
|<クライアントID>|Cognito-ClientのID|[**アプリケーションの統合**]タブ→「**アプリクライアントと分析**」に記載されている「**クライアントID**」
|<ステージ>|dev|API Gatewayで作成したステージ名|

### その他ファイルの設定
`env-vals.js`ファイル以外はそのままコピペで作成すればOKです。

長いので以下全て折りたたんでいるため、展開してコードをコピペしてローカルにファイル作成してください。

<details><summary>entrance.htmlコード（展開してください）</summary>

```html:entrance.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">

  <title>File Transfer - Entrance</title>

  <link href="./style.css" rel="stylesheet" media="all">
  <script src="./entrance.js" type="module"></script>
</head>
<body>
    <h1>Entrance</h1>

    <div id="welcome">
      <p>Welcome to our website. </p>
      <p>To get to the web page, click on the <span>"for Upload"</span> or <span>"for Download"</span> button.</p>
    </div>
    <div id="sign-in">
      <button id="upload" value="./upload.html">for Upload</button>
      <button id="download" value="./download.html">for Download</button>
    </div>

</body>
</html>
```

</details>

<details><summary>upload.htmlコード（展開してください）</summary>

```html:upload.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">

  <title>File Transfer - File Upload</title>

  <link href="./style.css" rel="stylesheet" media="all">
  <script src="./upload.js" type="module"></script>
</head>
<body>
  <h1>File Upload</h1>
  
  <h2 id="bucket">Bucket : </h1>
  <h2 id="folder">Folder : </h2>

  <form id="upload-form">
  <div class="flatbox-left">
    <input type="file" id="avatar" multiple>
    <label for="avatar" class="sub-button">Choose file</label>
  </div>
  <div id="chosen-file"><p>No file chosen.</p></div>
  <div class="flatbox-right">
    <div><input type="checkbox" id="zip"><label for="zip">Create ZIP</label></div>
    <div><button id="submit" disabled>Upload</button></div>
  </div>
  </form>
  
  <hr>
  <div class="flatbox-left">
    <div><button id="back" class="sub-button"><i class="arrow"></i>Back</button></div>
    <div><input type="checkbox" id="sign-out"><label for="sign-out">Sign-out</label></div>
  </div>

  <div id="spinner"></div>

</body>
</html>
```

</details>

<details><summary>download.htmlコード（展開してください）</summary>

```html:download.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">

  <title>File Transfer - File Download</title>

  <link href="./style.css" rel="stylesheet" media="all">
  <script src="./download.js" type="module"></script>
</head>
<body>
  <h1>File Download</h1>
  
  <h2 id="bucket">Bucket : </h1>
  <h2 id="folder">Folder : </h2>
  
  <table id="file-table">
    <thead>
      <tr>
        <th><input type="checkbox" id="check-all"></th>
        <th>File Name</th>
        <th>File Size</th>
        <th>Last Modified</th>
      </tr>
    </thead>
    <tbody>
    </tbody>
  </table>

  <div class="flatbox-right">
    <div><input type="checkbox" id="zip"><label for="zip">Create ZIP</label></div>
    <div><button id="submit" disabled>Download</button></div>
  </div>
  <hr>
  <div class="flatbox-left">
    <div><button id="back" class="sub-button"><i class="arrow"></i>Back</button></div>
    <div><input type="checkbox" id="sign-out"><label for="sign-out">Sign-out</label></div>
  </div>
 
  <div id="spinner"></div>

</body>
</html>
```

</details>

<details><summary>entrance.jsコード（展開してください）</summary>

```js:entrance.js
/**
 * Import module
 * モジュールの読み込み
 */
// 環境変数
import { USERPOOL_DOMAIN, USERPOOL_REGION, USERPOOL_CLIENT_ID, USERPOOL_RESPONSE_TYPE, USERPOOL_SCOPE} 
    from "./env-vals.js";



/**
 * Environment valiables
 * 環境変数
 */
const domain = USERPOOL_DOMAIN;
const region = USERPOOL_REGION;
const clientId = USERPOOL_CLIENT_ID;
const responseType = USERPOOL_RESPONSE_TYPE;
const scope = USERPOOL_SCOPE;



/**
 * DOM Content Loaded event
 * DOMコンテントロード後イベント
 */
window.addEventListener("DOMContentLoaded", () => {
    // Load AWS favicon
    new Promise((resolve) => {
        const link = document.createElement("link");
        link.href = "https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico";
        link.type = "image/x-icon";
        link.rel = "icon";
        link.onload = resolve;
        document.head.append(link);
    });

    
    /**
     * Button click event
     * アップロードとダウンロードのボタンクリックイベント
     */
    const allButtons = document.querySelectorAll("button");
    allButtons.forEach(button => {
        button.addEventListener("click", () => {
            allButtons.disabled = true;

            const url = new URL(button.value, location.href);
            const redirectUri = url.href;

            // サインイン(認証)ページ URL
            const cognitoUrl = `https://${domain}.auth.${region}.amazoncognito.com/login`
                                + `?response_type=${responseType}`
                                + `&client_id=${clientId}`
                                + `&redirect_uri=${redirectUri}`
                                + `&scope=${scope}`;

            // サインイン(認証)ページへ移動
            location.href = cognitoUrl;
        });
    });
});
```

</details>

<details><summary>ul-dl-shared.jsコード（展開してください）</summary>

```js:ul-dl-shared.js
/**
 * Import module
 * モジュールの読み込み
 */
// 環境変数
import { USERPOOL_DOMAIN, USERPOOL_REGION, USERPOOL_CLIENT_ID, EXECUTE_API_STAGE } 
    from "./env-vals.js";



/**
 * Environment valiables
 * 環境変数
 */
const domain = USERPOOL_DOMAIN;
const region = USERPOOL_REGION;
const clientId = USERPOOL_CLIENT_ID;



/**
 * HTTPリクエスト実行 (Fetch API のラッパー)
 * @param {String} uri リクエストURI
 * @param {Object} params {name: value} リクエストパラメータ
 * @return {Object} レスポンスオブジェクト
 */
const fetchWrapper = async (uri, params) => {
    let data;
    let response;

    try {
        response = await fetch(uri, params);
        data = await response.json();
    } catch (error) {
        throw new Error(`Fetch Call response failed] status: ${response.status}`);
    }

    return data;
}



/** 
 * Get signed URL and related information for upload/download
 * アップロード/ダウンロードのための署名付きURLと関連情報の取得
 * @param {string} resourcePath 呼び出すAPIのリソースパス情報
 * @returns {object} 署名付きURLと関連情報
*/
export const getTransferTargetInfo = async (resourcePath) => {
    let idToken = sessionStorage.getItem("id_token");

    // トークンがセッション情報から取得できなかった場合は処理終了
    if (idToken === null) {
        throw new Error("[Token Error] Failed to get token.");
    }

    /** アップロード/ダウンロードのための関連情報 */
    let targetInfo;

    try{
        let apiStage = EXECUTE_API_STAGE;

        // API 呼び出し実行
        targetInfo = await fetchWrapper(`${location.origin}${apiStage}${resourcePath}`, 
                                        {
                                            method: "GET",
                                            headers: {
                                                "Authorization": `Bearer ${sessionStorage.getItem("id_token")})}`
                                            }
                                        });
    } catch (error) {
        console.error(`[API Errors] ${error}`);
    }

    return targetInfo;
}



/**
 * Issue new token
    * API 呼び出し用のトークン発行
 */
const issueToken = async () => {
    console.log(`issueToken : Begin : ${new Date().toISOString()}`); // Debug log
    
    // URL クエリストリングから認証コードを取得
    const urlParams = new URLSearchParams(location.search);
    const code = urlParams.get("code");

    // トークン発行のためのパラメータ設定
    const params = new URLSearchParams();
    params.append("grant_type", "authorization_code");
    params.append("client_id", clientId);
    params.append("redirect_uri", location.href.split("?")[0]);
    params.append("code", code);

    try {
        // トークン発行の実行
        let data = await fetchWrapper(`https://${domain}.auth.${region}.amazoncognito.com/oauth2/token`,
                                        {
                                            method: "POST",
                                            headers: {
                                                "Content-Type": "application/x-www-form-urlencoded"
                                            },
                                            body: params.toString(),
                                            redirect: "follow"
                                        });

        sessionStorage.setItem("id_token", data.id_token);
    } catch (error) {
        console.error(error);
    }
}


/**
 * Check the token expired
 * トークン有効期限チェック
 * @param {string} token トークン (IDトークン)
 * @returns {boolean} true: 有効期限切れ, false: 有効期限内
 */
const checkTokenExpried = (token) => {
    // トークンからペイロードを抽出
    const base64Url = token.split(".")[1];
    const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");

    const jsonPayload = decodeURIComponent(atob(base64).split("").map((c) => {
        const dg = c.charCodeAt(0).toString(16).padStart(2, "0");
        return `%${dg.slice(-2)}`;
    }).join(""));

    // ペイロードから有効期限を抽出
    const payload = JSON.parse(jsonPayload);
    const expTime = payload.exp;

    // 有効期限チェック結果を返却
    return (expTime * 1000 <= Date.now());
}



/**
 * Lock Control Elements
 * コントロールエレメントの非活性化
 * @param {Boolean} true: 非活性化 (ロック), false: 活性化 (ロック解除)
 */
export const lockControlElements = (lock) => {
    // インプット
    const allInputElements = Array.from(document.querySelectorAll("input"));
    // ボタン
    const allButtonElements = Array.from(document.querySelectorAll("button"));
    const allElements = allInputElements.concat(allButtonElements);

    allElements.forEach(element => {
        if(element.id === "submit") {
            // サブミットボタンは通常 Disabled で別のイベントにて設定
            element.disabled = true;
        } else {
            element.disabled = lock;
        }
        
        if(element.type === "checkbox" && !lock) {
            element.checked = lock;
        }
    });


    // スピナー (データ転送処理中の円アニメーション)
    document.querySelector("#spinner").style.visibility = lock? "visible" : "hidden";
}



/**
 * Initialize commons
 * アップロード/ダウンロードページの共通的な初期化処理
 */
export const initializeCommons = async () => {
    // Load modules
    Promise.all([
        // AWS favicon
        new Promise((resolve) => {
            const link = document.createElement("link");
            link.href = "https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico";
            link.type = "image/x-icon";
            link.rel = "icon";
            link.onload = resolve;
            document.head.append(link);
        }),
        // JSZip script */
        new Promise((resolve) => {
            const script = document.createElement("script");
            script.onload = resolve;
            script.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js";
            document.head.append(script);
        })
    ]);


    /**
     * Get token for call the API
     * API呼び出し用トークンの取得実行
     */
    // トークンをセッション情報から取得 (発行済み確認)
    const idToken = sessionStorage.getItem("id_token");

    // セッション情報にトークンが存在しない、または取得したトークンが有効期限切れの場合
    if (!idToken || idToken === "undefined" || checkTokenExpried(idToken)) {
        // トークンを発行
        await issueToken();
    }


    /**
     * Back button click event
     * [Back] ボタンクリックイベント
     */
    document.querySelector("#back").addEventListener("click", () => {
        const url = new URL("./entrance.html", location.href);
        const redirectUri = url.href;

        let nextUrl;

        // [Sign-out] チェックボックスがチェックされていた場合、サインアウトする
        if(document.querySelector("#sign-out").checked){
            nextUrl = `https://${domain}.auth.${region}.amazoncognito.com/logout`
                    + `?logout_uri=${redirectUri}`
                    + `&client_id=${clientId}`;
        } else {
            nextUrl = redirectUri;
        }                    

        location.href = nextUrl;
    });
};
```

</details>

<details><summary>upload.jsコード（展開してください）</summary>

```js:upload.js
/**
 * Import module
 * モジュールの読み込み
 */
// アップロード/ダウンロード共有ファイル
import { initializeCommons, getTransferTargetInfo, lockControlElements } 
    from "./ul-dl-shared.js";



/**
 * DOM Content Loaded event
 * DOMコンテントロード後イベント
 */
window.addEventListener("DOMContentLoaded", async () => {

    // ウェブページエレメント
    const uploadForm = document.querySelector("#upload-form");
    const avatar = document.querySelector("#avatar");
    const chosenFile = document.querySelector("#chosen-file");
    const submitButton = document.querySelector("#submit");
    const zipCheck = document.querySelector("#zip");

    // 選択済みファイルリストエリアの未選択時状態の保持
    const noFileChosen = chosenFile.firstChild;



    /**
     * Initialize webpage elements
     * ウェブページの初期化
     */
    // アップロード/ダウンロード共通のページ初期化
    await initializeCommons();

    // アップロード関連情報の取得
    let targetInfo = await getTransferTargetInfo("/api/upload");

    if (!targetInfo || targetInfo === null) {
        console.error ("[Token Error] Failed to get token.");
    }

    // アップロード関連情報の設定
    if(targetInfo) {
        // バケット名、フォルダー名
        document.querySelector("#bucket").textContent += targetInfo["bucket"];
        document.querySelector("#folder").textContent += targetInfo["prefix"];
    }



    /**
     * Clear the file chosen area
     * 選択済みファイルリストエリアのクリア
     */
    const clearChosenFile = () => {
        avatar.value = "";

        while (chosenFile.firstChild) {
            chosenFile.removeChild(chosenFile.firstChild);
        }
        chosenFile.appendChild(noFileChosen);
        
        zipCheck.checked = false;
    };


    /**
     * Upload file chosen event
     * ファイル選択イベント
     */
    avatar.addEventListener("change", (event) => {
        
        // ファイルが選択された場合、選択済みファイルリストエリアに表示
        if (0 < event.target.files.length) {
            while (chosenFile.firstChild) {
                chosenFile.removeChild(chosenFile.firstChild);
            }

            const ol = document.createElement("ol");
            
            for (const file of event.target.files) {
                const li = document.createElement("li");
                li.textContent = `${file.name} | ${new Intl.NumberFormat('ja-JP').format(file.size)} Bytes`;
                ol.appendChild(li);
            }

            chosenFile.appendChild(ol);
        } else {
            clearChosenFile();
        }

        submitButton.disabled = (avatar.files.length === 0);
    });


    /**
     * Submit button click event
     * サブミットボタンクリック時イベント
     */
    uploadForm.addEventListener("submit", async (e) => {
        // 通常のサブミットイベントの停止
        e.preventDefault();

        // コントロールエレメントを非活性化
        lockControlElements(true);

        const formData = new FormData();
        const out_resultObj = {};

        let compMsg, errorMsg;

        try{
            // アップロードに必要なフォームデータの設定
            const fields = targetInfo["contents"]["fields"];
            Object.keys(fields).forEach(key => formData.append(key, fields[key]));

            
            // 署名付き URL
            const presignedUrl = targetInfo["contents"]["url"];

            if (zipCheck.checked) {
                // [Create Zip] チェックボックスがチェックされている場合、ファイルをZIPにまとめてアップロード

                const zip = new JSZip();

                // 一時的にバイナリーデータバッファーとしてファイルをZIPにまとめる
                for (const file of avatar.files) {
                    const arrayBuffer = await new Response(file).arrayBuffer();
                    zip.file(file.name, arrayBuffer);
                }
                
                const zipBlob = await zip.generateAsync({ type: "blob" });
                const zipFile = `archive-${Date.now()}.zip`

                formData.append("file", zipBlob, zipFile);

                // アップロード
                await uploadFile(formData, presignedUrl, zipFile, out_resultObj);
            } else {
                // [Create Zip] チェックボックスがチェックされていない場合、ファイルを連続でアップロード

                const subFolder = `/subfolder-${Date.now()}/`;

                formData.set("key", formData.get("key").replace("/", subFolder));

                for (const file of avatar.files) {
                    formData.append("file", file, file.name);
                    await uploadFile(formData, presignedUrl, file.name, out_resultObj);
                    formData.delete("file");
                }
            }

            // アップロード処理結果メッセージの生成
            compMsg = JSON.stringify(out_resultObj, null, " ").replace(/{|}|"|,/g,"");

        } catch (error) {
            errorMsg = error;
            console.error(error); 
        } finally {
            // 処理結果のダイアログ表示
            const endMsg = compMsg || errorMsg;
            alert(`Upload process finished.\n${endMsg}`);

            clearChosenFile();
            lockControlElements(false);
        }
    });


    /**
     * Upload file
     * ファイルのアップロード
     * @param {FormData} formData アップロードのためのフォームパラメータ
     * @param {String} presignedUrl 署名付き URL (POST)
     * @param {String} fileName アップロードファイル名
     * @param {Array} out_resultObj アップロード成否
     */
    const uploadFile = async (formData, presignedUrl, fileName, out_resultObj) => {
        out_resultObj[fileName] = "NG";

        const params = {
            method: "POST",
            body: formData
        };

        try {
            // アップロード
            const response = await fetch(presignedUrl, params);

            if(!response.ok) {
                throw new Error(`Fetch Call response failed status: ${response.status}, ${response.statusText}`);
            }
            
            out_resultObj[fileName] = "OK";
        } catch (error) {
            throw new Error(`[Upload] ${error}, ${fileName}`);
        }
    }
});
```

</details>

<details><summary>download.jsコード（展開してください）</summary>

```js:download.js
/**
 * Import module
 * モジュールの読み込み
 */
// アップロード/ダウンロード共有ファイル
import { initializeCommons, getTransferTargetInfo, lockControlElements } 
    from "./ul-dl-shared.js";



/**
 * DOM Content Loaded event
 * DOMコンテントロード後イベント
 */
window.addEventListener("DOMContentLoaded", async () => {

    // ウェブページエレメント
    const submitButton = document.querySelector("#submit");
    const allCheck = document.querySelector("#check-all");
    const zipCheck = document.querySelector("#zip");



    /**
     * Initialize webpage elements
     * ウェブページの初期化
     */
    // アップロード/ダウンロード共通のページ初期化
    await initializeCommons();

    // ダウンロード関連情報の取得
    let targetInfo = await getTransferTargetInfo("/api/download");

    if (!targetInfo || targetInfo === null) {
        console.error ("[Token Error] Failed to get token.");
    }

    // ダウンロード関連情報の設定
    if(targetInfo){
        // バケット名、フォルダー名
        document.querySelector("#bucket").textContent += targetInfo["bucket"];
        document.querySelector("#folder").textContent += targetInfo["prefix"];

        // ダウンロードファイル一覧の生成
        const fileTableBody = document.querySelector("#file-table > tbody");
        const targetContents = targetInfo["contents"] || [];

        targetContents.forEach((info) => {
            const row = fileTableBody.insertRow(-1);

            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            checkbox.name = "selected-file";
            checkbox.value = info["presignedUrl"];
            checkbox.addEventListener("change", () => {
                submitButton.disabled = !getAllFileCheckboxes().some(checkbox => checkbox.checked);
            });
            row.insertCell(-1).appendChild(checkbox);

            const anchor = document.createElement("a");
            anchor.href = info["presignedUrl"];
            anchor.textContent = info["name"];
            row.insertCell(-1).appendChild(anchor);

            row.insertCell(-1).textContent = `${info["size"]} Bytes`;
            row.insertCell(-1).textContent = info["lastModified"];
        });
    }



    /**
     * Submit button click event
     * サブミットボタンクリック時イベント
     */
    submitButton.addEventListener("click", async () => {
        // チェックされている全てのファイルのURLを取得
        const checkedFileUrls = getAllFileCheckboxes().filter(cb => cb.checked).map(cb => cb.value);

        // コントロールエレメントを非活性化
        lockControlElements(true);

        try {
            if (zipCheck.checked) {
                // [Create Zip] チェックボックスがチェックされている場合、ファイルをZIPにまとめてダウンロード

                // JSZip オブジェクト
                const zip = new JSZip();

                // 一時的にバイナリーデータとしてダウンロードしたファイルをZIPにまとめる
                const fetchPromises = checkedFileUrls.map(async (url) => {
                    let response = await downloadFile(url);
                    
                    if(response.ok) {
                        const blob = response.blob;
                        
                        const arrayBuffer = await new Response(blob).arrayBuffer();
                        const filename = url.split("?")[0].split("/").pop();

                        zip.file(filename, arrayBuffer);
                    }
                });

                try {
                    await Promise.all(fetchPromises);
                    
                    // ZIP のバイナリオブジェクト
                    const zipBlob = await zip.generateAsync({ type: "blob" });
                    
                    // リンクを生成して、ZIP ファイルをダウンロード
                    const anchor = document.createElement("a");
                    anchor.href = URL.createObjectURL(zipBlob);
                    anchor.download = `${targetInfo["prefix"].slice(0, -1)}-${Date.now()}.zip`;
                    anchor.click();
                    URL.revokeObjectURL(anchor.href);
                } catch (error) {
                    console.error(`[ZIP Error] ${error}`);
                }
            } else {
                // [Create Zip] チェックボックスがチェックされていない場合、ファイルを連続でダウンロード

                const fetchPromises = checkedFileUrls.map(async (url) => {
                    // 一時的にバイナリオブジェクトとしてダウンロード
                    let response = await downloadFile(url);
                    
                    if(response.ok) {
                        // リンクを生成して、そのままのファイルをダウンロード
                        const anchor = document.createElement("a");
                        anchor.href = URL.createObjectURL(response.blob);
                        anchor.download = url.split("?")[0].split("/").pop();
                        anchor.click();
                        URL.revokeObjectURL(anchor.href);
                    }
                });

                await Promise.all(fetchPromises);
            }
        } catch (error) {
            console.error(error);
        } finally {
            // コントロールエレメントを活性化
            lockControlElements(false);
        }
    });


    /**
     * dowload file
     * ファイルのダウンロード
     * @param {String} presignedUrl 署名付き URL (GET)
     * @returns {Object} blob: バイナリダウンロードデータ, ok: ダウンロード成否
     */
    const downloadFile = async (presignedUrl) => {
        let response;
        
        try {
            // ダウンロード
            response = await fetch(presignedUrl);

            if (!response.ok) {
                console.log(`Fetch Call response failed status: ${response.status}, ${response.statusText}`);
            }
        } catch (error) {
            throw new Error(`[Download] ${error}, ${presignedUrl}`);
        }

        return { blob: await response.blob(), ok: response.ok };
    }


    /**
     * All download checkboxes change event
     * 全ダウンロードチェックボックスのチェック時イベント 
    */
    allCheck.addEventListener("change", () => {
        getAllFileCheckboxes().forEach((checkbox) => {
            checkbox.checked = allCheck.checked;
        });

        submitButton.disabled = !allCheck.checked;
    });

    
    /**
     * Get all download checkboxes
     * 全てのダウンロードのチェックボックスエレメントを取得
     * @return {Node} 全てのダウンロードのチェックボックスエレメント
    */
    const getAllFileCheckboxes = () => {
        return Array.from(document.querySelectorAll("input[name=selected-file]"));
    }
});
```

</details>

<details><summary>style.cssコード（展開してください）</summary>

```css:style.css
@charset "utf-8";

body {
  background-color: #f5f5f5;
  font-family: sans-serif;
  margin: 30px;
  font-size: 16px;
  color: #333333;
}


h1 {
  border-bottom: solid 10px #384878;
}

h2 {
  border-bottom: solid 5px #384878;
}

hr {
  background: #999999;
  border: 0;
  border-bottom: 1px dashed #cccccc;
  margin: 20px 0;
}


div.flatbox-left, div.flatbox-right {
  width: 100%;
  height: 50px;
  display: flex;
  flex-direction: row;
  align-items: center;
}

div.flatbox-left {
  justify-content: left;
}

div.flatbox-right {
  justify-content: right;
}

div#welcome {
  text-align: center;
  font-size: 24px;
  margin: 100px;
}

div#welcome p:first-child {
  font-weight: bold;
  font-size: 30px;
}

div#welcome p span {
  font-weight: bold;
}

div#sign-in {
  width: 100%;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-evenly;
  margin-top: 150px;
  background-color: #EEEEEE;
  padding: 20px 0;
}

button, .sub-button {
  color: #ffffff;
  border-radius: 8px;
  cursor: pointer;
}

button {
  width: 200px;
  padding: 10px 15px;
  font-size: 20px;
  background-color: #384878;
  border-top:2px solid rgba(255, 255, 255, 0.2);
  border-bottom:2px solid rgba(32, 32, 32, 0.2);
  border-width: 0;
}

.sub-button {
  display: flex;
  justify-content: center;
  width: 150px;
  font-size: 18px;
  background-color: #6c7baa;
  border-top:2px solid rgba(255, 255, 255, 0.2);
  border-bottom:2px solid rgba(235, 234, 234, 0.2);
}

label.sub-button {
  padding: 8px;
}

button:disabled, input[type="file"]:disabled + label {
  background-color: #999999;
  color: #dddddd;
  cursor: not-allowed;
}

button:not(:disabled):hover, label.sub-button:not(:disabled):hover {
  opacity: 0.8;
}

button:not(:disabled):active, label.sub-button:not(:disabled):active {
  transform: translate(0,2px);
  border-bottom: none;
}

label {
  position: relative;
  padding: 10px;
}

#back {
  margin-right: 20px;  
}

i.arrow {
  width: 8px;
  height: 8px;
  border-top: 3px solid #fff;
  border-right: 3px solid #fff;
  transform: rotate(-135deg);
  margin: 4px 15px 0 -20px;  
  margin-right: 10px;
}



input[type="file"] {
  display: none;
}

input[type="checkbox"] {
  position: relative;
  top: 2px;
  transform: scale(2);
}


#chosen-file {
  position: relative;
  margin: 10px 20px 40px 20px;
}

ol{
  column-count: 2;
  counter-reset: item;
  list-style-type: none;
  padding-left: 0;
}

li{
  text-indent: -20px;
  padding-left: 20px;
}

li:before {
  counter-increment: item;
  content: counter(item)'.';
  padding-right: 10px;
  font-weight: bold;
  color: #333333;
}



table {
  position: relative;
  width: 100%;
  background-color: #f5f5f5;
  border: none;
  border: solid 1px #c0c0c0;
  border-radius: 4px;
  margin-bottom: 40px;
}

table th {
  vertical-align: middle;
  height: 40px;
  background-color: #384878;
  color: #ffffff;
  font-weight: bold;
  text-align: center;
  border-radius: 4px;
  margin: 0;
}

table td {
  color: #666666;
  line-height: 16px;
  text-align: right;
  vertical-align: middle;
  white-space: nowrap;
  border: solid 1px #c0c0c0;
  border-radius: 4px;
  padding: 5px 10px;
  margin: 0;
}

table td:nth-of-type(1) {
  text-align: center;
  border-left: none;
}

table td:nth-of-type(2) {
  text-align: left;
}

table td:fst-child {
  vertical-align: middle;
  padding-left: 10px;
}


a {
  color: #008db7;
}


#spinner {
  position: absolute;
  display: fixed;
  top: 30%;
  left: calc(50% - 15px - 50px);
  width: 100px;
  height: 100px;
  transform: tanslate(-50%, -50%);
  border: 30px solid rgba(255, 255, 255, 0.7);
  border-top: 30px solid #4b61a2;
  border-radius: 50%;
  animation: spin 2s linear infinite;
  visibility: hidden;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
```

</details>

### ファイルの格納
作成したファイルを作成済みS3バケットの`contents`配下に転送します。

転送方法は何でもいいですが、`aws s3 sync`コマンド等でまとめて転送すると簡単です。

```bash:コンテンツファイルの転送
aws s3 sync . s3://presigned-url-ul-dl-bucket/contents
```

また、ダウンロード確認を行うため、適当なファイルをS3バケットの`donwload`配下に格納しておいてください。

## アップロード、ダウンロード確認
設定としては完了したため、実際にアップロード、ダウンロードできるか確認してみます。

- [エントランスページアクセス](#エントランスページアクセス)
- [Cognitoユーザの作成](#cognitoユーザの作成)
- [ファイルダウンロード確認](#ファイルダウンロード確認)
- [ファイルアップロード確認](#ファイルアップロード確認)

### エントランスページアクセス
本ソリューションのポータル画面となるエントランスページにアクセスします。

エントランスページは、Cognitoの「**ホストされたUI**」で指定したエントランスページ用URLを実行すれば開きます。

```http:エントランスページURL
https://[API_ID].execute-api.ap-northeast-1.amazonaws.com/dev/web/entrance.html
```

以下ページが開けばOKです。

![Monosnap_20230715_190901.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/5958e4ca-852c-b5ea-136c-b258c3646d62.png)

### Cognitoユーザの作成
「**for Upload**」か「**for Download**」を選択すると、以下画面が表示されるため、初回ログイン時は「**Sign up**」でユーザを作成します。

![Monosnap_20230715_191104.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/35a8002b-8b5b-3738-8b47-f017a7493df4.png)

メールアドレスとパスワードを入力して、「**Sign up**」を選択します。

![Monosnap_20230715_191202.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/dd302c84-d1be-f455-d48b-73e88862a43c.png)

指定したメールアドレスに認証コードが送付されるため、認証コードを入力し、「**Confirm account**」を選択します。

![Monosnap_20230715_191242.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/66185527-e630-2d40-4047-3a0bc6ef02db.png)

エラーなく画面が遷移されればCognitoで認証がされた上でアップロード・ダウンロードページにアクセスしたものとなります。

### ファイルダウンロード確認
「**for Download**」よりダウンロードページにアクセスすると、作成済みS3バケットの`download`フォルダに格納されているファイルが表示されます。

チェックボックスよりダウンロードしたいファイルをチェックし、「**Download**」を選択することでダウンロードできます。

また、「**Create ZIP**」のチェックボックスにチェックすることでZIP形式でもダウンロードできます。

![Monosnap_20230715_192002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/90fb4faf-dae1-cdfd-797d-49ee919ac2ad.png)

なお、ページの「**Bucket**」や「**Folder**」に**undefined**が表示された場合は、権限やLambdaの設定が誤っている可能性があるので、もう一度本記事を見直してみてください。

### ファイルアップロード確認
「**for Upload**」よりアップロードページにアクセスすると、作成済みS3バケットの`upload`フォルダにアップロードするためのボタンが表示されています。

「**Choose file**」よりローカルのファイルを指定して、「**Upload**」を選択することで作成済みS3バケットの`upload`フォルダにファイルがアップロードされます。

もちろん「**Create ZIP**」にチェックしてアップロードすることでZIP形式でアップロードすることも可能です。

![Monosnap_20230715_192341.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/a538846b-ed0c-f0f5-017f-73f19caa752c.png)

アップロード後、該当のS3バケットにファイルが格納されていれば成功です。

![Monosnap_20230715_192452.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569912/df31a736-43c6-5282-2254-2ce8f0029d1d.png)

# おわりに
本ソリューションを利用することで、署名付きURLを使用していることをユーザ側に知られずに、且つCognitoを利用したユーザ認証により安全にアップロード、ダウンロードが可能となります。

また、ファイル格納先にS3を使用することで、API GatewayやLambdaを単体で使った場合に生じるペイロード制限（API Gateway：10MB、Lambda：6MB（同期）、256KB（非同期））を回避することができるとのことでかなり使い勝手の良いソリューションだと感じました。

[元ブログ](https://aws.amazon.com/jp/blogs/news/large-size-files-transferring-by-serverless-s3presignedurl-and-clientside-javascript/)にも記載の通り、実際の環境に導入する場合はセキュリティやエラーハンドリング等の考慮は必要となりますが、このような使い勝手の良い有用なソリューションを一般公開しているAWSの方には感謝です。