LoginSignup
10
3

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

Last updated at Posted at 2023-07-15

はじめに

AWSが運営しているAWSブログを流し見していたところ、ちょうどこんなシステムを作ってみたいと思い描いていた内容と合致するソリューションが手順・サンプル付きで紹介されていたので、少しハマったところも含めて手順をまとめてみようと思います。

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

紹介するソリューションについて

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

署名付きURLとは

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

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

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

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

署名付きURL.png

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

構成

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

01_-architecture_diagram_02.png

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

手順

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

Amazon Cognitoの作成

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

ユーザプールの作成

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

設定項目が多いですが、今回はブログ記事で記載されている設定以外はほぼデフォルトの値で進めていきます。

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

項目 設定 備考
プロバイダーのタイプ 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ポリシーの作成

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

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

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

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

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

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

IAMポリシーの作成(展開してください)
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:::<バケット名>/*"
        }
    ]
}

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」に変更してください。

アップロード用Lambdaコード(展開してください)
アップロード用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

作成後は「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」に変更してください。

ダウンロード用Lambdaコード(展開してください)
ダウンロード用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

作成後は「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ロールの作成

今回は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

静的・動的コンテンツ向けAPIの作成

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

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

Monosnap_20230715_140040.png

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

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

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

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

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

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

Monosnap_20230715_141149.png

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

Monosnap_20230715_141504.png

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

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

Monosnap_20230715_142327.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バケット名を指定
今回は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

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

名前 マッピング元 キャッシュ
proxy method.request.path.proxy 空欄
メソッドレスポンス

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

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

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

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

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

Monosnap_20230715_153441.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

アップロードとダウンロードの署名付きURLと関連情報の取得APIの作成

/api」以下リソースとメソッドを作成するため、「アクション」→「リソースの作成」から以下のように設定します。
※設定としてはあまり変わらないため、以下表でまとめて紹介します。

Monosnap_20230715_160130.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

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

Monosnap_20230715_162421.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

APIデプロイ

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

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

Monosnap_20230715_174248.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

Amazon S3の追加設定

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

バケットポリシーの設定

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

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

バケットポリシーの設定
{
    "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)」より以下のように設定します。

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

env-vals.jsファイルの設定

以下自分の環境に従ってCognitoの設定を記載します。

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です。

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

entrance.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>
upload.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>
download.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>
entrance.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;
        });
    });
});
ul-dl-shared.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;
    });
};
upload.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}`);
        }
    }
});
download.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]"));
    }
});
style.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);
  }
}

ファイルの格納

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

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

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

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

アップロード、ダウンロード確認

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

エントランスページアクセス

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

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

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

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

Monosnap_20230715_190901.png

Cognitoユーザの作成

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

Monosnap_20230715_191104.png

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

Monosnap_20230715_191202.png

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

Monosnap_20230715_191242.png

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

ファイルダウンロード確認

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

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

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

Monosnap_20230715_192002.png

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

ファイルアップロード確認

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

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

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

Monosnap_20230715_192341.png

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

Monosnap_20230715_192452.png

おわりに

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

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

元ブログにも記載の通り、実際の環境に導入する場合はセキュリティやエラーハンドリング等の考慮は必要となりますが、このような使い勝手の良い有用なソリューションを一般公開しているAWSの方には感謝です。

10
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3