Rails + JavaScriptでクライアントから直接S3にファイルアップロード

Railsアプリで1GB以上のファイルをS3にアップロードする際に、普通のフォームアップロードだと504 Time outエラーになるため、Railsアプリでは認証だけして、クライアント側で分割して、直接S3にアップロードするようにました。
その過程で、かなりの期間どはまりしたので、誰かの役に立てばと思いメモを残しました。

ただ、正直かなり色々なサイトを参考にしたのですが、どこも断片的な情報しかなかった上に、STSとかIAMとか完全に理解していないので、とりあえず成功した手順だけ記載します。

# 成功した手順

前提：Railsで利用するIAMユーザ（以下、Rails用ユーザ）が作成されている
1. AWS側でIAMのroleを設定する
2. AWS側でS3のバケットのCORS設定をする
3. RubyでSTSを利用して一時的な認証情報を取得する
4. JavaScriptで取得した認証情報を元にアップロードする

以下、各手順の詳細を記述します。

## 1. AWS側でIAMのroleを設定する

正直、ここが一番わからなかったです。出来て見れば、ロールとユーザ相互に権限設定しただけなのですが、それがわかるまでかなりはまりました。

やり方としては、「IAM > ロール > 新しいロールの作成」でAmazonS3FullAccessのアクセス権を持つロールを作成します。
そして、ロールの詳細画面で「信頼関係 > 信頼関係の編集」で下記のようにRails用ユーザを追加します。
ちなみに、変えていいのはAWS属性のユーザ名のみ。Versionの日付は固定なので、変更しないこと。
また、ユーザのARNの値はユーザの詳細画面に表示されています。

```
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:user/rails_user"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```

次に、IAMのユーザ一覧でRails用ユーザの詳細を開き、「アクセス権限 > インラインポリシー > インラインポリシーの追加」で下記のようにインラインポリシーを追加します。
ここでも変えていいのはResourceのロール名のみ。ロールのARNの値もロールの詳細画面に表示されています。

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::012345678901:role/rails_source"
        }
    ]
}
```

## 2. AWS側でS3のバケットのCORS設定をする
「S3 > アクセス権 > アクセスコントロールリスト」で「Any authenticated AWS user」に書き込み権限を与えます。
次に、「S3 > アクセス権 > CORS設定」で下記のようにCORS設定を変更します。
色々なサイトに「AllowedMethodを追加する」ってことは書いてありましたが、ExposeHeaderを追加しないと、分割アップロードした時にETagが取れなくてエラーになりますので、注意してください。

```
<?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>
    <AllowedMethod>POST</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <ExposeHeader>ETag</ExposeHeader>
    <AllowedHeader>*</AllowedHeader>
  </CORSRule>
</CORSConfiguration>
```

## 3. RubyでSTSを利用して一時的な認証情報を取得する

STSは「Security Token Service」の略で一時的な認証情報（token）を発行してくれるサービスです。
今回はassume_roleという一時的にセッションをあるロールを付与したとみなしてAWSを操作出来る認証方法を利用しました。
これ以外にも、get_federation_tokenなどでも同じ様なことが出来ると思います。調べるうちにcognitoなんてサービスもありましたが、これはユーザ認証を代行してくれるサービスなんですかね。利用しなかったのでわかりませんが。

具体的には下記のようなコントローラのメソッドでtokenを取得して、ブラウザに返すようにしました。
AWS_KEYはrails用ユーザの詳細画面の認証情報タブで作成したアクセスキーID、AWS_SECRETはそのパスワード。

ここで、assume_roleで指定するrole_arnにユーザを指定してみたりしてかなりはまりました。
ユーザを指定するとここではエラーが出ませんが、ファイルアップロード時にAccess Deniedになりました。

```token_controller.rb
 def get_aws_token
   sts = Aws::STS::Client.new(
           region: 'ap-northeast-1',
           access_key_id: AWS_KEY,
           secret_access_key: AWS_SECRET
         )
   policy = JSON.generate(
              'Version' => '2012-10-17',
              'Statement' => [{
                'Effect' => 'Allow',
                'Action' => 's3:PutObject',
                'Resource' => 'arn:aws:s3:::bucket_name/folder_name/*'
              }]
            )
   res = sts.assume_role({
     role_session_name: 'some_session',
     role_arn: 'arn:aws:iam::012345678901:role/rails_source',
     policy: policy
   })
   render json: {assume_role: res}
 end
```

## 4. JavaScriptで取得した認証情報を元にアップロードする

まず、viewでaws-sdkをインクルードしておく。

``` view.html.erb
<%= javascript_include_tag 'https://sdk.amazonaws.com/js/aws-sdk-2.7.20.min.js', 'danta-turbolinks-track': 'reload' %>
```

最後に、アップロードボタンをクリックしたらtokenを取得して、成功したらAWSにアップロードするようにする。
AWS.S3.uploadは特に何もしなくても、巨大ファイルは分割してアップロードしてくれる。
ここでも、session_tokenをセットしないとエラーになるなど、credentialの渡し方でかなりはまった。

``` upload.js
$("#upload_button").click(function() {
  $.ajax({
    type: "GET",
    url: "/token/get_aws_token",
    success: function(res) {
      var token = res.assume_role.credentials;
      AWS.config.region = "ap-northeast-1";
      AWS.config.credentials = new AWS.Credentials({
        sessionToken: token.session_token,
        secretAccessKey: token.secret_access_key,
        accessKeyId: token.access_key_id
      }); 
      var file = $("#video_file")[0].files[0];
      var bucket = new AWS.S3({params: {Bucket: "bucket_name"}});
      var params = {Key: "folder_name/" + file.name, ContentType: file.type, Body: file};
      bucket.upload(params, function (err, data) {
        console.log(err); // エラーがあれば表示される
      }).on('httpUploadProgress', function(evt) {
        console.log("完了率：" + parseInt((evt.loaded * 100) / evt.total) + '%');
      });
    }
  });
}); 
```

# 補足
* STSを呼ぶ際に、NTPでrailsアプリの時間を整えておかないと、tokenの有効期限がおかしなことになってエラーになります。