Amazon Web Services ブログ

AWS のサーバーレスと Amazon S3 署名付き URL、クライアントサイド JavaScript で大きなサイズの複数ファイルの一括アップロード・ダウンロード機能を実現する方法

はじめに

昨今のテクノロジーの進化は、これまで以上に、私たちがどのように働き、どのように生活するかを再定義しています。この進化の中心には、クラウドコンピューティングが存在しており、AWS はこれまで、クラウドコンピューティングのパイオニアとして、様々な機能を提供し続け、業界をリードしてきました。その機能群を支えるエコシステムの一部であるサーバーレスアーキテクチャは、スケーラブルで信頼性が高く、メンテナンスの作業負担が低いアプリケーションの開発を可能にし、ユーザーのビジネスやプロジェクトが円滑に進行するようサポートします。

AWS のサーバーレスの代表的なサービスに AWS Lambda がありますが、同じくサーバーレスの Amazon API Gateway と組み合わせて、Lambda の機能を API として提供・公開するご利用形態が多く見られます。API Gateway においては、バックエンドに Amazon S3 を組み込むことで、アクセス制御やセキュリティを確保しつつ、簡単に静的コンテンツを提供することができるようになるなど、多様なサービスのフロントドアとして機能させることができます。

Amazon API Gateway と AWS Lambda の組み合わせの課題

API Gateway を利用することで、デベロッパーは規模にかかわらず簡単に API の作成、公開、保守、モニタリング、保護が行えますが、大きなサイズのファイルなどのデータを扱うには注意が必要です。一度に送受信できるデータには制限があり、大きなファイルのアップロードやダウンロードに対応する際には、Amazon API Gateway のペイロードの制限により問題が生じる可能性があります。ペイロードの制限は、Lambdaにもあります。

この問題を解決するための一つの手段として、S3 の署名付き URL を利用する方法があります。例えば、大きなファイルのアップロードのリクエストがあった場合、Lambda はアップロード用の署名付き URL を生成し、その URL をクライアントにレスポンスします。クライアントはこの URL を使って、直接 S3 へとファイルをアップロードします。同様に、大きなファイルのダウンロードリクエストが API Gateway と Lambda に届いた場合も、Lambda は S3 の署名付き URL を生成し、それをクライアントへレスポンスします。クライアントはその URL を使って、直接 S3 からファイルをダウンロードします。このアプローチにより、高いセキュリティを維持しつつ、API Gateway と Lambda のペイロード制限を回避することが可能になり、大規模なデータ操作にも柔軟に対応可能となります。

なお、S3 にもサイズ制限がありますが、S3 がアップロード可能な 1 ファイルの最大サイズが 5 GB であるのに対して、API Gateway (REST API) のペイロードサイズは 10 MB、Lambda のペイロードサイズは 6 MB (同期) および 256 KB (非同期) です。

また、大きなサイズのファイルを扱うだけでなく、ファイルを複数一括で操作する機能を提供する場面もありますが、AWS のサービスにおいて、その直接的な機能は提供されていません。これを可能にするには、前述の署名付き URL を使った方法を応用して、クラアントサイド JavaScript を加えて、機能として実現することができます。

本ブログでは、AWS のサーバーレスのサービスの API Gateway、Lambda、S3 の署名付き URL、そしてクライアントサイドの JavaScript を使って、大きなサイズの複数ファイルを一括で、アップロードとダウンロードするソリューションサンプルを提供します。また、認証認可の機能として、Amazon Cognito を使用します。

Amazon S3 署名付き URL

署名付き URL は、S3 のアクセス権限を、一時的に他のユーザーに共有する仕組みです。アップロード用 (POST) の署名付き URL は、クライアントが直接 S3 バケットにファイルをアップロードすることを一時的に可能にします。一方、ダウンロード用 (GET) の署名付き URL は、特定のオブジェクトのダウンロードを一時的に可能にします。一時的な制限は、データの保護とプライバシーの確保に重要で、その制限の中で共有できる仕組みは複雑なデータアクセスとアクセス制御のニーズに対応します。署名付き URL は、S3 バケットのデータを安全かつ効率的に管理するための優れた手段と言えます。

ソリューションサンプル概要

アーキテクチャ図


使用するサービス

本ソリューションサンプルでは、Amazon S3、Amazon API Gateway、AWS Lambda、Amazon Cognito を使用します。

クライアントサイドでは、JavaScript を使用して、署名付き URL を通じて S3 に直接ファイルをアップロードおよびダウンロードを行います。Lambda は、署名付き URL を生成するための処理を行い、API Gatewayを経由してクライアントと通信します。

ユーザー管理の機能として Amazon Cognito を利用し、さらに Amazon API Gateway の Cognito Authorizer としてウェブページおよび API アクセスの認証認可の機能を担います。

使用するプログラム言語

  • Lambda関数:Python 3.10 (S3 の操作のために AWS SDK for Python (Boto3) を利用)
  • クライアント:JavaScript (ZIP生成のために JSZip を利用)、HTML、CSS

処理シーケンス

サインアップ

シーケンス図を表示

  1. エントランスページから、アップロードかダウンロードかを選び、サインイン (サインアップ) のページに移動します。
  2. サインアップのリンクから、サインアップのページに移動し、ID となるメールアドレスとパスワードを登録します。この時、登録したメールアドレス宛に、確認コードを記載したメールが届きます。
  3. その後、確認コードの登録ページに移動しますので、受け取った確認コードを入力します。
  4. 確認コードに間違いがなければサインアップは完了となり、2. にて指定したアップロードかダウンロードのページに移動します。

サインイン

シーケンス図を表示

  1. サインアップの際に登録した、メールアドレスとパスワードを入力して、サインインします。
  2. 入力した情報に間違いがなければサインインは成功となり、その前に指定したアップロードまたはダウンロードのページに移動します。
  3. 本ソリューションサンプルでは、アクセス可能な全利用者が、ファイルを共有するようなシステムを想定しています。

アップロード

シーケンス図を表示

  1. API の呼び出しでは、事前に登録したユーザー情報から生成された認証用の情報 (トークン) に基づき認証が行われ、成功すると API 呼び出しの認可を得ます。
  2. その後、API の処理が実行され、取得された署名付き URL やアップロードに必要な関連情報に基づき、ページの初期化が行われます。
  3. アップロードの実行では、まず、アップロードするファイルを選択 (1 から複数) します。この時、選択したファイルを自動で連続 (複数の場合) してアップロードするか、ZIP で一つのファイルにまとめてアップロードするかを選択することができます。
  4. その後、アップロードを実行して、NG の結果がなければ、アップロード成功となります。
  5. なお、アップロードしたファイルにウェブページからアクセスすることはできません。アップロードしたファイルは、管理コンソールにて確認してください。

ダウンロード

シーケンス図を表示

  1. ダウンロードの実行では、まず、ダウンロードするファイルを一覧の中から選択 (1 から複数) します。この時、選択したファイルを自動で連続 (複数の場合) してダウンロードするか、ZIP で一つのファイルにまとめてダウンロードするかを選択することができます。
  2. その後、ダウンロードを実行して、選択したファイルが実際にダウンロードされれば、ダウンロード成功となります。
  3. なお、ダウンロードできるファイルは、指定されたバケットやフォルダーに、あらかじめ保存されたファイル (オブジェクト) のみをダウンロードできます。また、単一ファイルとはなりますが、ファイル一覧のファイル名リンクからもダウンロードすることができます。

サインアウト

シーケンス図を表示

  1. アップロードかダウンロードのページからエントランスページへ移動する際に、その前にサインアウトすることを選択していた場合にのみ、サインアウトの処理が行われ、サービスと認証情報の紐づけが解除されます。
  2. なお、事前にサインアウトすることを選択していなかった場合は、単なるエントランスページへの移動となり、サインインの状態は維持されます。これにより、移動後の再度のアップロードかダウンロードへの移動では、サインインに必要となるメールアドレスとパスワードの入力は必須ではなくなります。

ソリューションサンプルの構築

以下の順番で、本ソリューションサンプルを構築します。

Amazon Cognito

ユーザープールの作成

サインインするユーザーを登録・管理するユーザープール作成します。各設定項目は基本デフォルト設定のままですが、本ソリューションサンプルでは、以下のように設定しています。

  • Cognito ユーザープールのサインインオプション:「Eメール」にチェック
  • 多要素認証 : 「オプションのMFA」にチェック
    • MFA の方法 : 「SMS メッセージ」にチェック
  • SMS : IAM ロール名 : (一意)
  • E メールプロバイダー : 「Cognito で E メールを送信」にチェック
  • ユーザープール名 : (一意)
  • ホストされた認証ページ : (一意)
  • アプリケーションクライアント名 : (一意)
  • ドメイン: Cognito ドメイン : (一意)
  • 許可されているコールバック URL : 「https://localhost」を入力 (後述の手順で変更)

ユーザープールの作成後、以下を行います。

リソースサーバーの作成

  • 作成したユーザープールの詳細ページにて、「アプリケーションの統合」タブを開き、「リソースサーバーを作成」をクリックして、作成ページに移動します。
  • 以下を設定し、そのほかはデフォルト設定のまま、リソースサーバーを作成します。
    • リソースサーバー名 : (一意)
    • リソースサーバー識別子 : (一意)

アプリケーションクライアントの設定

  • リソースサーバーの作成後、同じ「アプリケーションの統合」タブのページ下部にある「アプリケーションクライアント」に、作成したリソースサーバーが追加されています。そのリソースサーバーの名前をクリックして、詳細ページに移動します。
  • 詳細ページの中段の「ホストされた UI」右上の「編集」ボタンから編集ページに移動します。
  • 編集ページ下部の「OpenID Connect のスコープ」のドロップダウンリストから、「OpenID」を追加し、変更を保存します。

Amazon S3

バケット作成

  • バケットを作成します。各設定項目は基本デフォルト設定のままですが、ここでは以下のように設定しています。
    • バケット名 : (一意)
  • 作成後、バケットの詳細ページの「オブジェクト」タブにて、以下の 3 つのフォルダーを作成します。
    • 「contents」 : 静的・動的コンテンツ保存
    • 「download」 : ダウンロードするファイルを保存
    • 「upload」 : アップロードするファイルを保存
  • 同じく、詳細ページの「プロパティ」のページ最下部「静的ウェブサイトホスティング」の右上「編集」ボタンから編集ぺーじに移動し、を以下のように設定します。
    • 静的ウェブサイトホスティング : 「有効にする」
    • インデックスドキュメント : 「index.html」(設定有効のための仮入力)
      ※パブリックアクセスはブロックしたままで、この設定で変更されることはありません。

AWS Lambda

アップロードとダウンロードの 2 つの Lambda 関数を作成します。各設定項目は基本デフォルト設定のままですが、ここでは以下のように設定しています。

  • 基本的な情報 : 関数名 : (一意)
  • ランタイム : 「Python 3.10」を選択

アップロード

– コードソース

  • 事前に、S3 の操作を実行する S3 クライアントを生成する際に、S3 の AWS 署名バーション 4 が使えるよう設定します。
  • Lambda 関数が呼び出されたら (lambda_handler...) 、署名付き URL の生成 (generate_presigned_post) を実行します。
    • 引数として、Bucket Key を設定しますが、Key はフォルダー (プリフィクス) 名に "${filename}" をつなぎ合わせた文字列にしています。"${filename}" にすることで、アップロードされるファイル名そのままで、指定フォルダー内に保存されることになります。
    • Fields Conditions には、このサンプルでは何も設定していません。 (None)
    • ExpiresIn には有効期限を設定します。なお、署名付き URL の有効期限は、使用する認証情報により設定した期間よりも短くなる場合があります。
  • 署名付き URL と関連情報を戻り値に設定し、ステータスコード ("statusCode") を成功 (200) として、呼び出し元に返します。

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

– 環境変数

  • 「設定」タブから「環境変数」を以下のとおり設定します。
    • DURATION_SECONDS : 「3600」 (ここでは 1 時間の設定)
    • S3_BUCKET_NAME : (S3 バケット名)
    • S3_PREFIX_NAME : 「upload/」(フォルダー名 +「/」(スラッシュ))

ダウンロード

– コードソース

  • 事前に、S3 の操作を実行する S3 クライアントを生成します。
  • Lambda 関数が呼び出されたら (lambda_handler...) 、指定バケットの指定フォルダー内のファイル (オブジェクト) 一覧とその関連情報を取得 (list_objects_v2) します。
  • 取得したファイル一覧の関連情報から、ファイルごとに戻り値用に整理しながら、署名付き URL (GET) の生成 (generate_presigned_url) を実行します。
    • 引数の「ClientMethod」には署名するクライアントメソッドとしてget_objectを設定します。
    • Paramsには、"Bucket"にバケット名、"Key"にフォルダー名とファイル名をつなぎ合わせた文字列を、Dict 型で設定します。
    • ExpiresIn には有効期限を設定します。なお、署名付き URL の有効期限は、使用する認証情報により設定した期間よりも短くなる場合があります。
    • HttpMethodには、"GET"を設定します。
  • 指定フォルダー内のファイルすべての署名付き URL と関連情報を戻り値に設定し、ステータスコード ("statusCode") を成功 (200) として、呼び出し元に返します。

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

– 環境変数

  • 「設定」タブから「環境変数」を以下のとおり設定します。
    • DURATION_SECONDS : 「3600」 (ここでは 1 時間の設定)
    • S3_BUCKET_NAME : (S3 バケット名)
    • S3_PREFIX_NAME : 「download/」(フォルダー名 +「/」(スラッシュ))

IAM ロール (IAMポリシー) 設定

生成した署名付き URL へのアクセスでは、生成時の IAM プリンシパルでアクセスすることになります。そのため、ファイルアップロードおよびダウンロードのために、GetObject ListBucket PutObject の各アクションの許可を IAM ポリシーに追加しておきます。

また、アクセスできる S3 バケットを指定して制限することで、安全性を高めます。

– 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/fileUpload:"
        },
        {
            "Effect":"Allow",
            "Action":"s3:ListBucket",
            "Resource":"arn:aws:s3:::<バケット名>"
        },
        {
            "Effect":"Allow",
            "Action":[
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource":"arn:aws:s3:::<バケット名>/*"
        }
    ]
}

Amazon API Gateway

API タイプの REST API を作成します。各設定項目は基本デフォルト設定のままですが、ここでは以下のように設定しています。

  • API 名 : (一意)

リソース

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

  • この API は、S3 に保存されている静的・動的コンテンツ (HTML、JavaScript、CSS など) を、クライアントからのリクエストに応じでレスポンスします。

  • 「web/」のリソースを追加し、その中で「{proxy}+」のリソースを追加します。
  • 「GET」メソッドの作成
    • 「GET」メソッドを追加し、以下のように設定します。

  • GET メソッド : 統合リクエストの設定
    • URL パスパラメータ
      • 名前を「proxy」、マッピング元を「method.request.path.proxy」とする設定を追加します。

  • GET メソッド : メソッドレスポンスの設定
    • HTTPのステータス「200」に、「Content-Length」、「Content-Type」、「Timestamp」のレスポンスヘッダーを追加します。また、HTTPのステータスに「400」と「500」を追加します。

  • GET メソッド : 統合レスポンスの設定
    • メソッドレスポンスのステータス「200」に、以下のようなレスポンスヘッダーを追加します。また、メソッドレスポンスのステータス「400」と「500」の統合レスポンスを追加し、以下のように HTTP ステータスの正規表現を設定します。

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

  • アップロードとダウンロードの署名付き URL と関連情報の取得し、クライアントにレスポンスします。
  • 「api/」のリソースを追加し、その中に「upload」と「download」のリソースを追加します。
  • 「upload」と「download」のそれぞれのリソースに、以下のようにGETメソッドを作成します。
    • 「Lambda プロキシ統合の使用」にチェックをつけるのを忘れないようにしてください。

オーソライザー

  • アップロードとダウンロードの署名付き URL と関連情報の取得 API 向けに、以下のようにオーソライザーを作成します。「Cognito ユーザープール」は、先ほど作成したユーザープールを選択し、「トークンのソース」には、「Authorization」と入力します。

  • アップロードとダウンロード API の GET メソッドのメソッドリクエストに、認可の設定として作成したオーソライザーを設定します。以下のように選択肢が現れない場合は、ブラウザをリロードしてください。

APIのデプロイ

作成した API すべてをデプロイします。

Amazon Cognito の追加設定

Amazon Cognito にて、さきほど作成したユーザープールの「アプリケーションの統合」にて、以下の設定を行います。本ソリューションサンプルでは、「dev」ステージに API をデプロイしています。

  • 「アプリケーションクライアント」にある作成したリソースサーバーの詳細ページに移動します。
  • 「ホストされた UI」右上の「編集」をクリックして編集ページに移動し、以下のように設定します。
    • 許可されているコールバック URL
    • 許可されているサインアウト URL

Amazon S3 の追加設定

Amazon S3 にて、アクセス許可を以下のように設定します。

バケットポリシー

  • API Gateway への静的コンテンツ取得の権限付与
    • <ユーザーアカウント> <API ID> には、実際の値を設定してください。
    • バケットポリシーの例としてこちらのドキュメントも参考にしてください。

{
    "Version":"2012-10-17",
    "Statement":[
        {
            "Effect": "Allow",
            "Principal": {
                    "Service": "apigateway.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::target-storage-upload-download-mljqa8coai428icu/*",
            "Condition": {
                "ArnLike": {
                    "aws:SourceArn": "arn:aws:execute-api:ap-northeast-1:<ユーザーアカウント>:<API ID>/*/GET/"
                }
            }
        }
    ]
}

Cross-Origin Resource Sharing (CORS)

  • API Gateway のAPIのドメインに、GET と POST の権限を付与
    • <API ID> には、実際の値を設定してください。

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "POST"
        ],
        "AllowedOrigins": [
            "https://<API ID>.execute-api.ap-northeast-1.amazonaws.com"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]

静的・動的コンテンツ

ファイル構成

本ソリューションサンプルの静的・動的コンテンツは、以下のファイルで構成されています。

  • HTML
    • entrance.html : 本ソリューションサンプルの入口となるエントランスページです。
    • upload.html : アップロードページです。
    • download.htm : ダウンロードページです。
  • JavaScript
    • env-vals.js : 実行環境で個別指定となる変数の設定です。
    • entrance.js : エントランスページ用のモジュールです。
    • ul-dl-shared.js : アップロードとダウンロード共通のモジュールです。
    • upload.js : アップロードページ用のモジュールです。
    • download.js : ダウンロードページ用のモジュールです。
  • CSS
    • style.css : 全ての HTML のスタイルシートです。

コードサンプル

各コンテンツのコードサンプルを以下に示します。各処理の流れについては、前述の処理シーケンスをご参照ください。

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 (アップロードページ)

  • アップロードページです。アップロードするファイル選択のボタンとアップロードのためのボタン、エントランスページに戻るボタンを配置しています。
  • 「Create ZIP」は、アップロードする際に、選択したファイルをひとつの ZIP ファイルにまとめるためのチェックボックスです。
  • 「Sign-out」は、エントランスページに移動する際に、サインアウトも同時に行うためのチェックボックスです。

<!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 (ダウンロードページ)

  • ダウンロードページです。ダウンロードするファイル一覧とダウンロードのためのボタン、エントランスページに戻るボタンを配置しています。
  • 「Create ZIP」は、ダウンロードする際に、選択したファイルをひとつの ZIP ファイルにまとめるためのチェックボックスです。
  • 「Sign-out」は、エントランスページに移動する際に、サインアウトも同時に行うためのチェックボックスです。

<!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>

JavaScript

– env-vals.js (環境変数)

  • 実行環境で個別指定となる Cognito ユーザープールのドメインなどの設定です。
  • <ドメイン> <リージョン> <クライアントID> <ステージ> には、実際の値を設定してください。

/**
 * 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 = "/<ステージ>";

– 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 (アップロード / ダウンロード共通モジュール)

  • アップロードとダウンロード共通となる、サインインで取得した認証コードをAPI 呼び出し用トークンに交換する処理、API の呼び出しやサインアウトの処理を実装しています。
  • API の呼び出しには、リソース取得のためのインターフェイス である、Fetch API を使用しています。

/**
 * 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 (アップロードページ用モジュール)

  • アップロードページの読み込み時に、アップロードに必要となる 署名付きURL (PUT) や関連情報を取得し、ページの要素に設定します。
  • ファイルが選択された場合、ページ内の選択ボタンの下に、選択済みファイルの一覧 (ファイル名、ファイルサイズ) を表示します。
  • 「Upload」ボタンクリックで選択済みファイルを順次連続でアップロードします。「Create ZIP」チェックボックスがチェックされていた場合は、選択済みファイルをすべて読み込んだうえで ZIP ファイル (無圧縮) としてひとつにまとめてアップロードします。
  • ファイルを連続でアップロードした場合には、「subfolder-(現在時刻のミリ秒数値)」フォルダー内にすべての選択済みファイルをアップロードします。ZIP ファイルを選択した場合は、ファイル名を「archive-(現在時刻のミリ秒数値).zip」としてアップロードします。これは、アップロードするファイルの上書き回避を考慮しています。
  • ZIP ファイルでアップロードする場合、メモリー内にファイルデータを一時保持するため、メモリーの利用可能サイズに注意してください。

/**
 * 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 (ダウンロードページ用モジュール)

  • ダウンロードページの読み込み時に、ダウンロード対象となるファイルの一覧と、署名付きURL (PUT) や関連情報を取得し、ページの要素に設定します。
  • 「Download」ボタンクリックで、選択済み (チェックボックスにチェック) ファイルを順次連続でダウンロードします。「Create ZIP」チェックボックスがチェックされていた場合は、選択済みファイルをメモリー内に一時的にダウンロードし、その完了後にすべてのファイルを ZIP ファイル (無圧縮) としてひとつにまとめて、保存 (ダウンロード) します。
  • ZIP ファイルでダウンロードする場合、メモリー内にファイルデータを一時保持するため、メモリーの利用可能サイズに注意してください。

/**
 * 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]"));
    }
});

CSS

– style.css (全HTML用スタイルシート)


@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);
  }
}

ファイルのダウンロードとアップロードの確認

構築が完了したら、ファイルのアップロードとダウンロードを実行してみましょう。

エントランスページ

まずは、このソリューションサンプルの入口となるエントランスページに移動しましょう。「for Upload」(アップロード) または、「for Download」(ダウンロード) ボタンをクリックして、どちらかのページに移動します。

サインイン

アップロードまたはダウンロードページに移動する前に、サインインページに移動します。既にサインアップしている場合は、Email と Password を入力し、「Sign in」ボタンをクリックして、ページを移動します。「Sign up」リンクをクリックすると、サインアップページに移動します。パスワードを忘れた場合は、「Forgot your password?」リンクからパスワードをリセットすることもできます。

サインインの状態が継続している場合は、以下のページが表示されますので、「Sign in as (メールアドレス)」ボタンをクリックして、ページを移動します。「Sign in as a different user?」リンクをクリックすると、上記のサインインページが表示されますので、別のユーザーとしてサインインできます。

サインアップ

サインインページの「Sign up」リンクから移動します。Email と Password を入力して、「Sign up」ボタンクリックでサインアップしてください。パスワード設定のルールはこちらです。

アップロードページ

アップロードページでは、ファイルを選択して 「Upload」ボタンをクリックします。これにより、選択したファイルが  S3 バケットにアップロードされます。

アップロードが完了すると、以下のようにダイアログボックスが表示され、各ファイルがアップロード成功 (OK) であることを示しています。

アップロードしたファイルは、管理コンソールで確認できます。この例では、10 MB ~ 100 MB のサイズのファイル、合計 15 ファイル (800 MB) がアップロードできていることがわかります。

ZIP ファイルにひとつにまとめてのアップロードが成功した場合は、以下のように表示されます。

ダウンロード

ダウンロードページでは、ファイルを選択 (チェックボックスにチェック) して 「Download」ボタンをクリックします。これにより、選択したファイルが順次ダウンロードされます。

ZIP ファイルにまとめてダウンロードすることもできます。この例では、10 MB ~ 100 MB のサイズのファイル、合計 7 ファイルを ZIP でまとめたファイル (560 MB) でダウンロードできていることがわかります。


まとめ

本ブログでは、AWS のサーバーレスのサービスと Amazon S3 署名付き URL、クライアントサイド JavaScript で、セキュリティを確保しながら、大きなサイズの複数ファイルの一括アップロードとダウンロードを実現する方法を説明しました。この方法は、大量のファイルを効率的に処理する必要があるアプリケーションにとって有用な解決策の一つとなります。また、今回のソリューションサンプルで使用されているサービスへの追加設定や、ほかのサービスも加えることで、機能の拡充やさらに高いセキュリティ確保を実現することもできます。

本ブログの内容が、大きなサイズのファイルや、複数のファイルを一括で取り扱う必要があるアプリケーションやプロジェクトにおいて、効率的な管理やセキュリティ確保などの具体的なガイダンスを得られることを期待しています。それと同時に、読者の皆様のクラウドジャーニーの一助になれば、とても嬉しく思います。

免責事項

本ブログで提供したソリューションサンプルのコードや設定はあくまでもサンプルであり、基本的な動作を確認しているだけのものであることご留意ください。実際のソリューションの構築にあたっては、セキュリティの考慮やエラーハンドリング、パフォーマンスチューニングなど、実際のソリューションに求められる要件を満たすコード実装やサービス設定などが必要です。また、それに際し、AWS のドキュメントやベストプラクティスを参考にすることをお勧めします。

参考文献

AWS のドキュメント / ベストプラクティス

本ブログの執筆は、シニアソリューションアーキテクトの立花正英が担当しました。