【presigned url】サーバーを経由せずにS3に画像・動画などのファイルをアップロードする

この記事は、[Makuake Development Team Advent Calendar 2019](https://adventar.org/calendars/4716) 3日めの記事です。
遅刻しちゃいましたが、頑張って書きましたので読んでください！

## やりたいこと、よくある課題感

**ブラウザからS3などのストレージに、サイズの大きな画像や動画をアップロードしたい。**

そうなると、サーバー側にPOSTするとタイムアウトしてしまったり、メモリを食い潰してしまったりして、サイズの上限を設定したりしないといけないなどつらいシーンが増えてくると思います。

そのためにブラウザからのダイレクトアップロードというのが検討の遡上に上ることも多いと思うのですが、

- やり方がわからない
- セキュリティ的に安全なのかがわからない ( トークン流出したりするんじゃないの？ とか )

といった問題などがあり、何となくスルーされることも多いのではないかと思います。

今回はその辺りについて、やってみたのでわかったことをまとめていこうと思います。

## この記事で前提とするレベル

- Ajaxとか非同期の通信はある程度わかる( `async` / `await` わかるぐらいを想定)
- S3 + IAM は普通に使っているレベル。`SECRET_ACCESS_KEY` ・ `ACCESS_KEY_ID` あたりの言葉が当たり前にわかる

## 使用した技術・関連キーワード

| 役割 | 技術 |
| --- | --- |
| クライアントサイド | Vue.js / axios |
| ストレージ | S3 |
| バックエンド | Ruby on Rails |

※ Vue.js については、本記事では methods に書き込むfunctionの事例を記載しており、コンポーネントレベルでのサンプルは記載しておりません。

Railsを選んだのはサンプルが豊富で試しやすそうだったからですが、S3のSDKが提供されている言語であれば基本的には同じことが実現できると思います。

同様に、CloudStorageなどでもpresigned urlにあたる機能はある ( 参考 [署名付きURL](https://cloud.google.com/storage/docs/access-control/signed-urls?hl=ja) ) ということなので、この考え方自体はS3に限らず流用可能かと思います。

### presigned url / presigned_post

`presigned(事前に署名した) url` というだけあって、予め発行しておいたURLに対してのPOSTでのアップロードを受け付ける機能です。
厳密にはURLだけではなく、合わせて発行されるトークンなどとセットで可能になります。サンプルコードの中でこれは見せられればと思います。

`presigned_post` はruby用の `aws-sdk` で、 presigned url を含む各種情報を発行するためのメソッドの名前です。

## presigned url を用いたファイルのダイレクトアップロードの手順

### AWSとRails側の準備

1. S3のbucketを作成する
2. 対象のbucketにオブジェクト追加ができるIAMを発行し、ACCESS_KEY_ID / SECRET_ACCESS_KEY を発行する
3. CORSの設定を行い、ダイレクトアップロードするドメインのみから投稿を受け付けるようにする
4. Rails側でaws-sdkをセットアップする ( `Aws::S3::...` のクラスが呼び出せるようになっていればok )
5. presigned_postを呼び出し、presigned urlを含むアップロードに必要な各種情報を返却するエンドポイントを作る

なお、僕はこの辺りの前準備は、大部分を [ブラウザからS3へのダイレクトアップロード](https://qiita.com/minsu/items/a3bfc8f8f807f6b51fdf) を参考にさせていただきました。

### クライアントサイドでやること

1. presigned urlを発行するエンドポイントを呼び出す ( Railsへのリクエスト。ファイルは送らない )
2. 返却された値を使って、当該URLに画像ファイルも含めたリクエストを送る ( S3へのリクエスト。ファイルを送る )
3. S3から返却されてきた値をparseし、保存先のURLをクライアントサイドで取得する → この値をDB等に送る

## ソースコードのサンプル(バックエンド側)

「AWSとRails側の準備」の1~4についてはオリジナリティが0のため、 [ブラウザからS3へのダイレクトアップロード](https://qiita.com/minsu/items/a3bfc8f8f807f6b51fdf) を参考URLとさせていただいて、省略させていただきます。

- 5. presigned_postを呼び出し、presigned urlを含むアップロードに必要な各種情報を返却するエンドポイントを作る

自分の場合はAPIのエンドポイントとして使いたかったため、以下のようにしました。

```Gemfile
# その他は省略
# 以下を追記して `bundle` を実行すること
gem 'aws-sdk', '~> 2'
```

```config/aws.rb
# AWSの環境変数の設定、及びS3のbucketへのアクセス方法を定数化したファイル
# 参考 https://qiita.com/minsu/items/a3bfc8f8f807f6b51fdf
# 環境変数に、準備のところで取得した値を入れてください
Aws.config.update({
  region: 'ap-northeast-1',
  credentials: Aws::Credentials.new(
    ENV['AWS_S3_ACCESS_KEY_ID'],
    ENV['AWS_S3_SECRET_ACCESS_KEY']
  ),
})

S3_BUCKET = Aws::S3::Resource.new.bucket(ENV['AWS_S3_BUCKET_NAME'])
```

```config/routes.rb
Rails.application.routes.draw do
  # その他のルーティングは省略します
  namespace :api, format: 'json' do
    get 'presigned-url', to: 's3#presigned_url'
  end
end
```

```app/controllers/api/s3_controller.rb
class Api::S3Controller < Api::ApplicationController
    def presigned_url
        # S3_BUCKET は config/aws.rb
        presigned_object = S3_BUCKET.presigned_post(
            key: "uploads/test/#{SecureRandom.uuid}/#{params[:filename]}",
            success_action_status: '201',
            acl: 'public-read'
        )
        render json: { url: presigned_object.url, fields: presigned_object.fields }
    end
end
```

この状態でアプリケーションをデプロイすると、 `sample.com/api/presigned-url` にアクセスした際に、以下のようなレスポンスを取得できます。[^1][^2]

```
{
    "url": "https://[your-bucket-name].s3.ap-northeast-1.amazonaws.com",
    "fields": {
        "key": "xxx",
        "success_action_status": "201",
        "acl": "public-read",
        "policy": "xxx",
        "x-amz-credential": "xxx",
        "x-amz-algorithm": "xxx",
        "x-amz-date": "xxx",
        "x-amz-signature": "xxx"
    }
}
```

ここで取得できた値を用いて、クライアントサイドでファイルをアップロードします。

## ソースコードのサンプル(クライアント側)

メソッドとしては以下のような感じになります。説明は主にコメントアウトで。
Vueのコンポーネントとして表現するともう少し複雑になるのですが、中核部分の実装は以下で表現できていると思います。

```sample.js
async sampleFunction () {
    // ※ eslint は standard style にしています

    // 対象のファイルを取得
    const target = getElementById('対象のinput要素のid')
    // multiple指定がないinput[type=file]のオブジェクトは、files[0]に要素が保持されている
    const file = target.files[0]

    // presigned post を使うため、対象のURLを取得する
    const baseUrl = 'https://sample.com/api/'
    const presignedObject = await this.axios.get(`${baseUrl}presigned-url?filename=${file.name}`)
        .then(response => response.data)
        .catch(e => console.log(e.message))

    // POSTする form に持たせるデータを生成する
    const formData = new FormData()
    for (const key in presignedObject.fields) {
        formData.append(key, presignedObject.fields[key])
    }

    // input[type=file] に持たせているデータを取得して送信する formData に追加する
    formData.append('file', file)

    // アップロード実施
    // presigned-url エンドポイントから取得したオブジェクトのURLと、作成したformDataを送信するとアップロードができる
    await this.axios.post(presignedObject.url, formData, {
        headers: {
            'accept': 'multipart/form-data'
        }
    })
        .then((response) => {
            // XML形式でデータが返却される。アップロードに成功すると Location というタグの中に入っているので、
            // この値をURLとして取得して使う
            const matchedObject = response.data.match(/<Location>(.*?)<\/Location>/)
            const s3Url = unescape(matchedObject[1])
        })
        .catch(e => console.log(e.message))
    }
}
```

この辺りの機能を使って、もうちょっと気の利いたことをやると、以下のような動作を実現できます。
(動画が一部見切れてるのはご容赦ください)

![Dec-04-2019 15-12-37.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94273/346fd84e-965b-79f7-c272-c4b92da1e51b.gif)

## 作ってみて、実際どうだったか？

クライアントサイドでアップロードを制御できる安心感はかなりあるなという感じでした。
タイムアウトなどを気にしながらサーバー側で大きめのファイルを扱わなくていいので、基本的にはURLだけ取り回せばいい・・・というのは実用上もありがたいところです。

課題感としては、一時的かつCORSの制御をするとはいえ、S3への書き込み権限を発行してしまうので、クライアントサイドでのファイルサイズの上限設定をしたり、攻撃に準ずる使い方に対する対策を決める必要があるな、と感じます。

サーバーを通じてアップロードする形式でも同じ課題は存在するにはするのですが、user_idなどがなくてもアップロードできる権限が発行されたり、presigned url発行後の制御が効かないという点で、監視体制の強化やいざというときの対策を決めておかないと、本番運用時にはリスクがあるところだな、と思いました。

-----

さて、Makuakeでは、一緒に働きたいエンジニアを募集しています。
いろいろと新しく面白い試みも続けておりますので、Advent calendarを読んで興味が湧いた方は、ぜひチームの募集にもお目通しください！

- [顧客体験エンジニア募集｜最高のUXとビジョンの具現化を追求してみませんか？](https://www.wantedly.com/projects/378389)

明日(というか今日)は @convto がどこかの媒体で何かをやってくれるようです。そちらの記事もお楽しみにどうぞ。

[^1]: CORSの設定で許可しているOrigin以外からは接続できませんのでご注意ください
[^2]: `xxx` で省略しているところは、基本的には影響ないかと思いますがセキュリティに関連するデータなどが入ります