署名付きURLでAWS S3にファイルをアップロードする際に気をつけること

署名付きURLを用いて、S3にファイルをアップロードした際に、なぜかアップロードしたファイルの中身だけでなく、変なヘッダーのようなものが付与された形で保存されている問題を解決した話です。

## 目次

- 署名付きURLとは?
- 発生した問題
- 原因
- 解決策

## 署名付きURLとは?

AWS S3ではバケット単位やIAMユーザ単位でアクセス制御を行うことができますが、その一環として、有効期限を設けてある特定のURLからアクセスできるようにする機能です。

本来なら、ブラウザからサーバー経由でファイルをアップロードして、S3バケットに保存するという流れが一般的かと思います。なのでサーバー側で、認証や分割ファイルの結合といった処理が行えます。

`署名付きURL` と使うとサーバーを経由せず直接S3にアップロードできるため、運用しているサーバーに負荷をかけずに楽に運用できます。実装も楽です。特定のURLにアップロードすれば良く、前者に比べるとセキュリティに弱いため、有効期限を設定できます。

数GBの大きなファイルでも自社サーバーを経由せずに直接アップロードできるため、サーバーが落ちる心配がなく安心です。

## 発生した問題

PUT権限の署名付きURLにブラウザからJSライブラリの [`superagent`](https://github.com/visionmedia/superagent) を用いてCSVファイルをアップロードしていたのですが、何故かS3にアップロードされたデータに意図しない変な情報が付与してしまいました。

```
id,name
1,pokohide
2,hogehoge
```

が署名付きURLにファイルをPUTすると、以下のような形式で保存されていました。

```
———WebKitFormBoundaryO5quBRiT4G7Vm3R7
Content-Disposition: form-data; name="message"

id,name
1,pokohide
2,hogehoge
------WebKitFormBoundaryO5quBRiT4G7Vm3R7
``` 

実際にアップロードに際し使っていたコードの例がこちら

```javascript:upload.js

import request from 'superagent'

  ...
  request
    .put(presigned_url)
    .attach('data', file)
    .('progress', (e) => { console.log(e) })
    .end((err, res) => {
      if (err) console.log(err)
      else console.log(res)
    })
```

上記のコードの `file` はINPUTから入力のあったファイルデータをそのまま渡していました。これで行けると思っていたのに・・・

## 原因

署名付きURLでファイルをアップロード(PUT)すると、S3側はPUTされた `HTTP Body` の中身をそのまま保存しているそうです。

なので、PUTする前に `HTTP Body` がどのタイミングで意図しない形式になっているかを調査しました。結果、原因は[FormData API](https://developer.mozilla.org/ja/docs/Web/API/FormData)にありました。

ブラウザが進化するにつれて複数の種類(`csv` や `image`など)のデータを一度に扱える形式(`multipart/form-data`)が生まれ、この形式でフォーム送信された場合は内部的にHTTP Bodyが `FormData` オブジェクト形式で送信されます。

この際に、もともと複数ファイルに送れるように拡張された形式であるため、今回のように一つのファイルを送信するときでも勝手にHTTPボディに

```
———WebKitFormBoundaryO5quBRiT4G7Vm3R7
Content-Disposition: form-data; name="message"

{中身}
------WebKitFormBoundaryO5quBRiT4G7Vm3R7
```

というように、意図した中身だけでなく、サーバー側でに複数ファイルが送られたときに判別できるように区切り用の識別子のようなものが付与されてしまっていました。

今回の問題は、このようにHTTPボディが勝手に改変されて、それがそのままS3に保存されていたために起きていた問題であることが判明しました。

また、もともと使っていた `superagent` というJS製のHTTPクライアントが`FormData`のラッパーであったため、何をしようにも`Content-Type: multipart/form-data`になってしまっていたことで上記の問題が引き起こされていました。なので、このライブラリとFormDataを使うことを諦め、 XMLHttpRequest でファイルをアップロードすることにしました。

※ `superagent` でも `Content-Type` を `multipart/form-data` 以外に変えれば上手く行ったかも知れませんが検証しませんでした。

### 参考URL

- [フロントエンド multipart/form-dataを理解してみよう](https://www.yoheim.net/blog.php?q=20171201)

## 解決策

```javascript:upload_with_xhr.js

...

let xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Content-Type', file.content_type)
xhr.setRequestHeader('X-FILE-NAME', file.name)

xhr.onload = function(e) {
  if (this.readyState === 4) { // DONEを検知
    console.log('upload done')
  }
}

xhr.onerror = function(e) {
  return reject(e)
}

xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) { console.log('pregress') }
}

xhr.send(file)
```

このように、`Content-Type` を `multipart/form-data` ではなく、アップロードするファイルの `Content-Type` をヘッダーに与えて、送信すればHTTPボディが変わることなく、意図した形式でデータがS3にアップロードされました。

---

## 参考URL
- [XMLHttpRequest だけを使ってファイルのアップロード - MDN](https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Submitting_forms_and_uploading_files)
- [ajaxでマルチパートを使わずにファイルアップロードする方法 - Qiita](https://qiita.com/5zm/items/43d78236a295819c239a)