Railsで（大きな）画像をS3に直接アップロード


Heroku Dev Center「Direct to S3 Image Uploads in Rails」の覚え書きです。

**環境**

- Rails 4.1.8
- heroku-toolbelt 3.16.0
- aws-sdk 1.59.0
- JQuery UI (1.11.2)
- jQuery File Upload Plugin (9.8.0)

**サンプル**

- https://github.com/usutani/direct-s3-example

**参照**

- [Direct to S3 Image Uploads in Rails | Heroku Dev Center](https://devcenter.heroku.com/articles/direct-to-s3-image-uploads-in-rails)
- [CORS(Cross-Origin Resource Sharing)について整理してみた ｜ Developers.IO](http://dev.classmethod.jp/etc/about-cors/)

# 覚え書き
## Philosophy

この記事では [jQuery-File-Upload](https://github.com/blueimp/jQuery-File-Upload)プラグインと AWS gem を用います。
画像をS3に直接アップロードできる [CarrierWaveDirect](https://github.com/dwilkie/carrierwave_direct) のような他のライブラリもありますが、クライアント側に関する低レベルの知識なしでのそれらの実装は困難です。

jQuery-File-Upload プラグインを用いると、JavaScript のコードは比較的読みやすく短いものになり、そのコードを任意の画像アップロード入力フォームとして再利用することもできます。
また、ユーザインタフェースは非常にカスタマイズ可能です。
Rails側では、AWSで事前に署名したPOSTを生成し、画像のURLをデータベースに保存します。

## Example app

このアプリでは ```User``` モデルを持ち、アバター画像をユーザー単位で S3 に保存したいと思います。

```bash
rails new direct-s3-example
cd direct-s3-example
rails generate scaffold user name avatar_url
rake db:migrate
```
## S3

ファイルを S3 に送る前に、[S3 のアカウント](https://devcenter.heroku.com/articles/s3)と、適切に構成されたバケットが必要になります。

## S3 SDK

次に Ruby で S3 とやりとりするためのライブラリが必要になります。
aws-sdk を Gemfile に追加して ```bundle install``` してください。

```ruby:Gemfile
gem 'aws-sdk'
```

ローカルの開発では、```.env``` ファイルと [Foreman](https://github.com/ddollar/foreman) を使います。
次のように環境変数を追加してください。
尚、下記の値は例ですので、S3 で設定／取得したものを書いてください。

```.env
S3_BUCKET=my-s3-development
AWS_ACCESS_KEY_ID=EXAMPLEKVFOOOWWPYA
AWS_SECRET_ACCESS_KEY=exampleBARZHS3sRew8xw5hiGLfroD/b21p2l
```

確認

```bash
$ foreman run rails runner "puts ENV['S3_BUCKET']"
my-s3-development
```

環境変数を設定できたら、コントローラーから使えるように S3 オブジェクトを用意します。

```ruby:config/initializers/aws.rb
AWS.config(access_key_id:     ENV['AWS_ACCESS_KEY_ID'],
           secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] )

S3_BUCKET = AWS::S3.new.buckets[ENV['S3_BUCKET']]
```

## Cross origin support

[（参考）CORS(Cross-Origin Resource Sharing)について整理してみた](http://dev.classmethod.jp/etc/about-cors/)

環境に合わせてバケットの CORS 設定を変更します。
適切なオリジンを ```AllowedOrigin``` に設定しましょう。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
        <AllowedMethod>POST</AllowedMethod>
        <AllowedMethod>PUT</AllowedMethod>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration>
```

## Pre-signed post

AWS ruby gem で事前に署名した(Pre-signed)POSTを生成します。

あなたのユーザーや顧客が特定のオブジェクトをあなたのバケットにアップロードしたい場合に、事前に署名したURLは役立ちます。詳しくは [Class: AWS::S3::PresignedPost](http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/S3/PresignedPost.html) をご覧ください。

users_controller.rb に pre-signed post を生成する行を追加します。

```ruby:app/controllers/users_controller.rb
  # GET /users/new
  def new
    @s3_direct_post = S3_BUCKET.presigned_post(
      key: "uploads/#{SecureRandom.uuid}/${filename}",
      success_action_status: 201,
      acl: :public_read)
    @user = User.new
  end
```

## Client side code

一時的なキャッシュとしてサーバーに頼ることはできないので、S3 にファイルを配信するために、クライアント側のコード（JavaScript）を使用しなければなりません。

HTML 5はファイルAPIを導入していますが、IE 10までサポートされませんでした。これを回避するために、jQuery File Upload を使います。これを使うには先に JQuery UI が必要になります。

- JQuery UI
- jQuery File Upload

Rails プロジェクトに JavaScript ファイルを取り込みます。

```bash
$ curl \
https://raw.githubusercontent.com/jquery/jquery-ui/master/ui/widget.js \
>> app/assets/javascripts/jquery.ui.widget.js

$ curl \
https://raw.githubusercontent.com/blueimp/jQuery-File-Upload/master/js/jquery.fileupload.js \
>> app/assets/javascripts/z.jquery.fileupload.js
```

```js:app/assets/javascripts/application.js
.
.
//= require jquery.ui.widget.js
//= require z.jquery.fileupload
//= require_tree .
```

確認

```bash
rails s
open http://localhost:3000/users/new
```

```js:JavaScriptコンソール
> console.log($().fileupload)
function ( options ) {
          var isMethodCall = typeof options === "string",
               args = slice.call( arguments, 1 ),
               returnValue = this;
//...
```

## Prepare the view

- form_for に directUpload クラスを追加
- avatar_url を f.file_field に変更

```erb:app/views/users/_form.html.erb
<%= form_for(@user, html: { class: "directUpload" }) do |f| %>
  <% if @user.errors.any? %>
.
.
<div class="field">
  <%= f.label :avatar_url %><br>
  <%= f.file_field :avatar_url %>
</div>
```

## Detecting file field on the client side

以下のものが用意できました。

- S3 バケット
- 有効な pre-signed post オブジェクト
- User モデル（avatar_url、ファイル入力）

ユーザーの画像を S3 から取得したり、avatar_url に URL を保存する必要がありますが、これは主に JavaScript での手動処理になります。

・・・省略・・・

プログレスバーのスタイルを指定します。

```css:app/assets/stylesheets/screen.css
.progress {
  max-width: 600px;
  margin:    0.2em 0 0.2em 0;
}

.progress .bar {
  height:  1.2em;
  padding: 0.2em;
  color:   white;
  display: none;
}
```

## Finished jquery-file-upload code

・・・省略・・・

## jQuery-File-Upload callbacks

タグ ```<script></script>``` を追加します。

```html:app/views/users/new.html.erb
<h1>New user</h1>

<%= render 'form' %>

<%= link_to 'Back', users_path %>

<script>
$(function() {
  $('.directUpload').find("input:file").each(function(i, elem) {
    var fileInput    = $(elem);
    var form         = $(fileInput.parents('form:first'));
    var submitButton = form.find('input[type="submit"]');
    var progressBar  = $("<div class='bar'></div>");
    var barContainer = $("<div class='progress'></div>").append(progressBar);
    fileInput.after(barContainer);
    fileInput.fileupload({
      fileInput:       fileInput,
      url:             '<%= @s3_direct_post.url %>',
      type:            'POST',
      autoUpload:       true,
      formData:         <%= @s3_direct_post.fields.to_json.html_safe %>,
      paramName:        'file',
      dataType:         'XML',
      replaceFileInput: false,
      progressall: function (e, data) {
        var progress = parseInt(data.loaded / data.total * 100, 10);
        progressBar.css('width', progress + '%')
      },
      start: function (e) {
        submitButton.prop('disabled', true);

        progressBar.
          css('background', 'green').
          css('display', 'block').
          css('width', '0%').
          text("Loading...");
      },
      done: function(e, data) {
        submitButton.prop('disabled', false);
        progressBar.text("Uploading done");

        // extract key and generate URL from response
        var key   = $(data.jqXHR.responseXML).find("Key").text();
        var url   = '//<%= @s3_direct_post.url.host %>/' + key;

        // create hidden field
        var input = $("<input />", { type:'hidden', name: fileInput.attr('name'), value: url })
        form.append(input);
      },
      fail: function(e, data) {
        submitButton.prop('disabled', false);

        progressBar.
          css("background", "red").
          text("Failed");
      }
    });
  });
});
</script>
```

# 備考

```bash
# .envの環境変数を参照するためforemanで起動
foreman run rails server
```

```bash
# 確認
aws --profile foo s3 ls s3://aaa-bbb-ccc-1/uploads/
# 全て削除（注意！）
aws --profile bar s3 rm s3://aaa-bbb-ccc-1/uploads/ --recursive
```