ちょっと話題の記事

WebブラウザからAmazon S3に直接ファイルをアップロードする

2013.04.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

WebブラウザからAmazon S3へのクロスドメインアップロード

今回は、Amazon S3のCORSの仕様に準じたクロスドメインアクセス機能を利用して、WebブラウザからS3へ直接ファイルをアップロードするサンプルアプリを作成してみたいと思います。

CORSに関しては別の記事にまとめていますので、そちらを参考にして下さい。

開発環境

今回の開発環境は下記の通りです。アプリケーションサーバはScalatraで作成しました。

  • OSX 10.8.3 Mountain Lion
  • Google Chrome 25
  • Scala 2.9.2
  • sbt 0.12.2
  • Scalatra 2.2
  • TypeScript 0.8.3

ソースコードはGitHubで公開しています。

ファイルアップロード処理の流れ

サンプルアプリの実装の前に、S3へのアップロードについて必要な知識を押さえておきたいと思います。

ブラウザからS3にファイルをアップロードする処理の流れは以下の通りです。

  1. アプリケーションサーバーから認証情報を取得
  2. 取得した認証情報を利用してS3にファイルをアップロード

上記手順について順に見ていきます。

認証

S3のREST APIへの認証情報の渡し方

通常、S3のREST APIの認証はHTTPのAuthorizationフィールドにAWSのシークレットアクセスキーを使用して計算した認証情報をセットしてリクエストを送信します。しかし、今回はWebブラウザからのリクエストのためシークレットアクセスキーをクライアント側に持たせる訳にはいきません。したがって、シークレットアクセスキーを利用したハッシュの生成などの認証情報の計算はアプリケーションサーバ側で行って、その結果を元にクライアントでリクエストを組み立てて送信する必要があります。

S3のREST APIは、HTTPのAuthorizationフィールドに認証情報を持たせる代わりにクエリストリングに持たせる方法をサポートしています。この方法は、本来サードパーティのクライアントに対してS3のリソースにアクセスを許可する場合に利用することを想定しているようですが、今回のようにWebブラウザから直接アクセスする場合にも有効です。この方法であればクライアント側はサーバー側で作成したURLに対してそのままアクセスすればいいため、実装の手間が省ける上に認証がらみの処理をサーバー側に集中させることができます。今回はこちらの方法を利用することにします。

認証情報

クエリストリングによる認証の場合、下記の情報をクエリストリングでS3のREST APIに渡す必要があります。なお、リクエストヘッダのAuthorizationフィールドを使用する認証とは必要になる情報が異なりますので注意が必要です。

AWSAccessKeyId

AWSのアクセスキーのIDです。

Expires

シークレットアクセスキーを使用して計算した認証情報が失効する日時です。エポック日付からの経過秒の整数値で表現します。

Signature

認証に使用する署名です。AWSのシークレットアクセスキーを使用して生成します。

Signature

クエリストリングによる認証で使用するSignatureは下記の手順で組み立てます。

  1. S3のリソースにアクセスするためのリクエストの情報から、定められたフォーマットの文字列作成する。
  2. 作成した文字列のUTF-8エンコーディングのバイナリデータを署名対象のメッセージ、AWSのシークレットアクセスキーのUTF-8エンコーディングのバイナリデータを秘密鍵として、HMACアルゴリズムでMAC値を算出する。HMACで利用するハッシュ関数はSHA1。
  3. 算出したMAC値をBase64エンコードして文字列化する。
  4. Base64化した値をURLエンコードする。

以上の手順で計算された文字列がSignatureとしてS3のREST APIの認証で使用されます。こちらの手順においても、最後にBase64化した値をURLエンコードする必要がある点がリクエストキーのAuthrizationフィールドを使用する場合と異なっています。

署名対象の文字列

上記手順で最初に作成している署名対象の文字列のフォーマットは以下の情報を順に並べて改行コード(LF)で区切ったものです。

  • HTTPメソッド
  • Content-MD5
  • Content-Type
  • 認証情報の失効日時
  • AWSのカスタムリクエストヘッダ
  • 操作対象のリソース情報

S3のドキュメントでは下記のように定義されています。StringToSignというのが署名対象の文字列のことです。

StringToSign = HTTP-VERB + "\n" + Content-MD5 + "\n" + Content-Type + "\n" + Expires + "\n" + CanonicalizedAmzHeaders + CanonicalizedResource;

Content-MD5とContent-Typeに関しては、実際にリクエストを送信する際にヘッダでフィールドが使われない場合には空文字の値を指定する必要があります。このため、Expireの後の改行コードが必ず4つ目の改行コードになります。

ファイルアップロード

ローカルストレージのファイルにアクセスする

Webブラウザからローカルストレージのファイルにアクセスするために、ブラウザのFile APIを利用します。File APIが実装されているブラウザであれば、inputタグのtype属性をfileに指定する事でファイル選択ダイアログを表示するボタンを表示することができます。

アップロード処理

WebブラウザからS3に対してファイルをアップロードする方法としては、S3のREST APIのうち、POSTメソッドのオペレーションを利用する方法とPUTメソッドのオペレーションを利用する方法の2種類があります。今回はPUTメソッドを利用しますが、POSTメソッドを利用する方法の場合、通常リクエストヘッダで送信する情報を全てフォームデータで送信する必要があります。

また、このアクセスはクロスドメインで行う事になります。これに関しては、S3がCORSによるクロスドメインアクセスをサポートしていますので、それを利用します。

以上を踏まえて実際にサンプルアプリを作成してみたいと思います。

S3にCORSの設定をする

では、実装の前にS3に適当なバケットを作成してCORSの設定を行います。S3でのCORSの設定については、弊社横田の記事を参考にして下さい。

CORS(Cross-Origin Resource Sharing)によるクロスドメイン通信の傾向と対策

今回のCORSの設定は以下の通りです。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <AllowedHeader>Content-Type</AllowedHeader>
        <AllowedHeader>x-amz-acl</AllowedHeader>
        <AllowedHeader>Origin</AllowedHeader>
    </CORSRule>
</CORSConfiguration>

設定の内容をCORSの仕様と照らし合わせて簡単に見ていきます。

AllowedOrigin

AllowedOriginは、クロスドメインアクセスを許可するドメインを指定します。S3はこのフィールドの設定を、CORSにおけるリクエストヘッダのOriginフィールドのチェックと、レスポンスヘッダのAccess-Control-Allow-Originフィールドの出力に利用しています。今回はワイルドカードを指定してドメインの制限をしていません。

AllowedMethod

AllowedMethodは、クロスドメインアクセス時に利用を許可するHTTPメソッドを指定します。このフィールドの設定は、リクエストのHTTPメソッド、リクエストヘッダのAccess-Control-Request-Methodフィールドのチェックと、レスポンスヘッダのAccess-Control-Allow-Methodsフィールドの出力に利用されています。今回は、GETとPUTを指定して利用を許可しています。なお、preflightリクエストで利用されるOPTIONSメソッドは、S3のCORS設定で指定しなくても暗黙的に許可されるようです。

MaxAgeSeconds

MaxAgeSecondsは、ブラウザ側でpreflightリクエストの結果をキャッシュすることが可能な時間を指定します。単位は秒です。このフィールドの設定は、preflight時のレスポンスヘッダのAccess-Control-Max-Ageフィールドの出力に利用されています。

AllowedHeader

AllowedHeaderは、クロスドメインアクセス時にリクエストで利用を許可するヘッダを指定します。このフィールドの設定は、リクエストヘッダ、preflight時のリクエストヘッダのAccess-Control-Request-Headersフィールドのチェックと、レスポンスヘッダのAccess-Control-Allow-Headersフィールドの出力に利用されています。Origin, Content-TypeはpreflightリクエストヘッダのAccess-Control-Request-Headersフィールドに含まれるため、これらを許可しないとpreflightに失敗します。また、x-amz-aclはAWSのカスタムヘッダでACLに関する情報を格納します。今回はアップロードしたアイテムのACLを指定しませんので省略可能です。

アプリケーションサーバーでの認証情報を含んだURLの作成処理

2013/4/15追記。
認証情報を含んだURLの作成処理は、AWS SDK for Javaで提供されているため自前で実装する必要がありません。詳しくは、下記のエントリをご覧下さい。

WebブラウザからAmazon S3に直接ファイルをアップロードする – AWS SDK for Javaを使う

認証情報を含んだURLを返すRESTオペレーション

S3にファイルをアップロードするために、まずはリソースのアップロード先のURLをアプリケーションサーバーで生成してクライアントに返します。以下は、認証情報を含むURLを返すRESTオペレーションのソースコードです。

SignController.scala

get("/put") {
  logger info "GET /sign/"

  val fileInfo = for {
    objectName <- params.getAs[String]("name")
  } yield S3FileInfo(targetBucketName, objectName, params.getAs[String]("type"))

  fileInfo match {
    case Some(fileInfo: S3FileInfo) => {
      val s3SignHelper = new S3PUTSignHelper
      val url = s3SignHelper.s3ObjectUrl(fileInfo)
      Ok(Map("url" -> url))
    }
    case None => halt(400, "invalid params.")
  }
}

上のコードでは、nameでファイル名、typeでMIME Typeをパラメータとして受け取り、S3にアップロードするためのURLを作成して返しています。

URLを作成するモジュール

URLを作成している部分は下記のモジュールが担当しています。

AWSHelper.scala

class S3PUTSignHelper
  extends S3URLProvider
  with PUTMethodProvider
  with AWSCredentialsPropertiesProvider

...

case class S3FileInfo(bucketName: String, fileName: String, mimeType: Option[String])

S3PUTSignHelperは3つのトレイトから構成されていますが、URLの作成処理はS3URLProviderトレイトに実装されています。S3URLProviderのs3ObjectUrlメソッドはS3FileInfo型のパラメータを受け取ってURLを返します。S3FileInfoケースクラスは、アップロード対象の情報を格納します。mimeTypeがOption型になっているのは、S3のREST APIに対するGETメソッドでのアクセスの場合にはMIME Typeは必要ないためです。

URLの作成

実際にURL作成処理を行っているS3URLProviderトレイトの実装は下記の通りです。

object S3URLProvider {
  private val protocol = "http"
  private val s3Domain = "s3.amazonaws.com"
  private val awsAccessKeyIdKey = "AWSAccessKeyId"
  private val expiresKey = "Expires"
  private val signatureKey = "Signature"
  private val expireSeconds = 300
}

trait S3URLProvider { self: HttpMethodProvider with AWSCredentialsProvider =>

  import S3URLProvider._
  import S3SignatureProvider._
  import jp.classmethod.s3corstest.utils.URLUtil._

  def s3ObjectUrl(
    fileInfo: S3FileInfo,
    requestHeaderMap: Map[String, String]): String = {

    require(fileInfo != null)
    
    val expires = new Date().getTime / 1000 + expireSeconds
    val signature = s3Signature(httpMethod, fileInfo, expires, requestHeaderMap, credentials.secretKey)

    val params = Map(
        awsAccessKeyIdKey -> credentials.accessKey,
        expiresKey -> expires,
        signatureKey -> signature)
    val url = createUrlString(protocol, s3Domain, None, Seq(fileInfo.bucketName, fileInfo.fileName), params)

    URLEncoder.encode(url, "UTF-8")
  }

  def s3ObjectUrl(fileInfo: S3FileInfo): String = s3ObjectUrl(fileInfo, Map.empty[String, String])
}

このトレイトで実装されているs3ObjectUrlメソッドが、URL作成の主な実装です。少し細かく見ていきます。

Expires

22行目では一連の処理で作成されるプリサイン済みのURLの失効日時の情報を作成しています。Expiresはエポック日付からの経過秒で表されますので、現在のエポック日付からの経過秒に300秒を足して、5分後にURLが失効するように設定しています。

Signature

23行目ではSignatureを作成しています。この処理は、13行目でインポートしているS3SignatureProviderシングルトンオブジェクトのs3Signatureメソッドで行っています。これについては後で確認します。

URLの作成

29行目ではこれまで作成した情報からURLの文字列を組み立てています。25-28行目のMapはURLのクエリストリングを定義しており、AWSAccessKeyId、Expires、Signatureの3つを設定しています。S3のリソースのURLはhostedスタイルにしています。最後にURLをURLエンコードしています。

なお、23行目のs3Signatureメソッドの第一引数で渡しているhttpMethodは、ScalatraのHttpMethodクラスのサブクラスの型で自分型アノテーションで指定しているHttpMethodProviderトレイトのメンバです。このサブトレイトのPUTMethodProviderをS3PUTSignHelperにミックスインしています。これは、toStringメソッドを呼び出すとHTTPメソッドの文字列を返します。ここでは「PUT」という文字列が返されます。

AWSHelper.scala

trait HttpMethodProvider {
  protected def httpMethod: HttpMethod
}

trait PUTMethodProvider extends HttpMethodProvider {
  protected val httpMethod = Put
}

同じく23行目のs3Signatureメソッドの第五引数で渡しているcredentialsは、AWSのAccessKeyIdとSecretKeyの情報をpropertiesファイルから読み込んで保持しているオブジェクトです。

Signatureの作成

Signatureを作成する部分の実装です。

AWSHelper.scala object S3SignatureProvider { private val secretAlgorithm = "HmacSHA1"

def s3Signature( httpMethod: HttpMethod, fileInfo: S3FileInfo, expires: Long, headers: Map[String, String], secretKey: String): String = {

def createStringToSign: String = { val headerStrs = for { (key, value) <- headers } yield key + ":" + value val contentMD5 = "" val contentType = fileInfo.mimeType getOrElse "" val resource = "/" + fileInfo.bucketName + "/" + fileInfo.fileName val params = Seq(httpMethod.toString, contentMD5, contentType, expires.toString) ++ headerStrs :+ resource params.mkString("\n") } val stringToSign = createStringToSign signatureByString(stringToSign, secretKey) } private def signatureByString(stringToSign: String, secretKey: String) = { val key = new SecretKeySpec(secretKey.getBytes("UTF-8"), secretAlgorithm) val mac = Mac.getInstance(key.getAlgorithm) mac.init(key) val binary = mac.doFinal(stringToSign.getBytes("UTF-8")) val base64 = new BASE64Encoder().encode(binary) URLEncoder.encode(base64, "UTF-8") } } [/scala]

s3Signatureメソッド中でネストして定義されているcreateStringToSignメソッドが署名の対象となる文字列を作成しています。これは、先程説明した通り、署名対象の情報の文字列表現をLFで区切ったものです。今回はContent-MD5を使っていませんので、空文字をセットしています。

作成した署名対象の文字列をsignatureByStringメソッドで署名してSignatureを生成しています。署名の処理は、これも先程説明した通り、署名対象の文字列に対してHMAC-SHA1のハッシュを計算し、Base64エンコードしてさらにURLエンコードしています。

以上で、認証情報を含んだURLが作成できました。

クライアントの実装

Webブラウザ側では、先程作成した認証情報を含んだURLをアプリケーションサーバーから取得して、そのURLでS3にファイルをアップロードします。クライアント側で一連のアップロード処理を行うコンポーネントの実装です。

main.ts

class UploadService {
    
    constructor(public hostUrl: string) {
    }
        
    upload(file: File, resultHandler: () => void, 
        errorHandler: (errorMessage: string) => void,
        progressHandler: (progress: number) => void) {
        $.ajax({
            url: this.hostUrl + "sign/put",
            type: "GET",
            data: { "name": file.name, "type": file.type }
        })
        .then(data => {  
            console.log("Start upload file.")
            
            var deferred = $.Deferred()
            var xhr = this.createCORSRequest("PUT", decodeURIComponent(data["url"]))
            xhr.onload = e => deferred.resolve(e)
            xhr.onerror = e => deferred.reject(e)
            xhr.upload.onprogress = e => deferred.notify(e)
            xhr.send(file)
            
            return deferred
        })
        .progress(e => {
            var progressValue = 0.0
            if (e.lengthComputable) {
                progressValue = e.loaded / e.total
            }
            progressHandler(progressValue)
        })
        .done(e => resultHandler()) 
        .fail((jqHXR, textStatus, errorThrow) => errorHandler(errorThrow))
    }
    
    createCORSRequest(method: string, url: string) {
        var xhr: any = new XMLHttpRequest();
        if ("withCredentials" in xhr) {
            xhr.open(method, url, true);
        } else if (typeof XDomainRequest != "undefined") {
            xhr = new XDomainRequest();
            xhr.open(method, url);
        } else {
            xhr = null;
        }
        return xhr;
    }
}

uploadメソッド内で一連のアップロード処理を実行しています。uploadメソッド内の処理のうち、最初のAjax通信でアップロード先のURLをサーバーから取得しています。

次に、S3に対してPUTメソッドでそのURLにアクセスしてアップロードを実行しています。取得したURLはAWSの認証情報も含んでいるため、そのままPUTメソッドでアクセスするだけです。ローカルストレージから読み込んだファイルのアップロードそのものに関しては、type属性がfileのinputで指定したファイルのFileオブジェクトの参照を、XMLHttpRequestオブジェクトのsendメソッドに渡すだけです。

なお、ここではjQueryのAjaxを利用していません。XMLHttpRequestオブジェクトのupload.onprogressでアップロードの進捗率を取得したいのですが、jQueryのAjaxを利用するとXMLHttpRequestオブジェクトが隠蔽されてうまく進捗率が取得できないので、jQueryを使わずにAjax通信を行っています。また、クロスドメインアクセスのためにCORSのpreflightが発生しますが、その辺りはブラウザが必要な処理を行ってくれますので意識する必要はあまりありません。

クライアント側は大した実装していませんが、これでS3にファイルをアップロードできるようになりました。

動作確認

サンプルアプリを動作させた際のイメージです。

s3-cors01

ボタンを押下すると、ローカルストレージのファイル選択ダイアログが表示されます。

s3-cors02

ダイアログでファイルを選択すると、先程実装したS3へのファイルアップロード処理が実行されます。

s3-cors03

これでアップロード完了です。

s3-cors04

S3のManagement Consoleからアップロード先に指定したバケットを確認すると、アップロードされているのが確認できます。

s3-cors06

Webブラウザのネットワークコンソールを確認すると、CORSによるクロスドメインアクセスが成功している事を確認できます。

s3-cors05

まとめ

WebブラウザからS3にファイルをアップロードする処理は、CORSとS3の認証さえ押さえてしまえば割り合い手軽に実装できました。もちろん同じ要領でS3からリソースを取得することも可能です。

また、弊社のAWSチームのメンバーから聞いたところによると、WebブラウザからS3のリソースへのアクセスはアプリケーションサーバー経由で行われることが多く、サイズの大きいファイルを扱うとファイルのダウンロードに時間がかかるそうです。それだけでなく、アプリケーションサーバーがEC2の場合は、EC2のデータ転送料が他のAWSのサービスに比べて高いために利用料金が高くなってしまうようです。

WebブラウザとS3でファイルをやりとりする場合は、S3のCORSサポートをうまく利用すればメリットがありそうです。ただし、Cloud Frontを利用する場合には、preflightで問題が発生するようですので注意が必要になりそうです。これに関しては、下記のスライドが分かりやすいので参考にして下さい。

CloudFront経由でのCORS利用

参考サイト

Amazon S3公式ドキュメント

Direct Browser Uploading – Amazon S3, CORS, FileAPI, XHR2 and Signed PUTs

How to directly upload files to Amazon S3 from your client side web app

CloudFront経由でのCORS利用