S3 Presigned URLを使ってハマった話

社内システムでアバター機能を作る必要があり、s3を利用して実装することにした。

## tl;dr
以下の点で躓きました。
1. ローカルとデプロイ環境では設定が異なる
 → ECSが自動的に環境変数を処理してくれる
2. CORS設定
 → AWSコンソールで設定する必要あり
3. 署名方式
 → クライアントで送るとき複合化をする必要があった

## なんでpresigned url?
### クライアントで直接送りたい
サーバーで画像の処理を行うと、何台しかない状態でオーバーヘッドの起きる可能性がある。
その反面、クライアントはユーザーの数ほど存在するため、画像の処理をクライアントに任せたいと考えた。

### 外部からs3にアクセスする方法
調べると、以下のような選択肢があった。
1. Public
 → 誰でもアクセスできてしまう。今回は社内システムなので外部に公開したくない
2. AWS SDK
 → IAM権限を持っているユーザーだけ
3. AWS CLI
 → IAM権限を持っているユーザーだけ
4. S3サイトホスト
 → 静的サイトを作っているわけではない
5. CloudFront
 → 設定したCloudFrontからはアクセス可能、ダイレクトに繋がらない
6. Presigned URL
 → 今回のケースにバッチリ

### presigned urlとは？
pre(先に) + signed(署名した) + url
予めにs3に接続できるように署名したurlを作る仕組みである。

ざっくりした言い方なので公式を参考にした方が良いですね。
> バケットポリシーを更新せずに、Amazon S3 内のオブジェクトへの時間制限付きのアクセス権を付与するには、署名付き URL を使用できます

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html

## 構成

今回は以下のような実装を考えた。

クライアントでs3に画像をアップロードしようとすると、
① まずはサーバーにpresigned urlを発行するようにリクエスト
② サーバーはsdkを利用してs3からpresigned urlをもらう
③ クライアントはサーバーからpresigned urlをもらってリサイズした画像をアップロードする。

![スクリーンショット 2024-08-25 19.14.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3754242/ffdc2617-dfdd-2a43-530a-3703d9679f5a.png)

## ハマった点
### 1. ローカル環境とデプロイ環境の違い
ローカル環境の設定ではawsからのconfidentialをenvファイルに書いたりして、railsでs3オブジェクトを読み込んだ。
しかし、デプロイ環境ではどこに環境変数を入れて、どのタイミングにs3オブジェクトが読み込まれるのかわからなかった。

https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/ECS/Client.html

正解はsdk側にあった。サーバーにはecsを使っていて、sdkが自動的にcredentialの情報を入れてくれた。SSMとかで入力するわけでもなかった。

awsの仕組みをちゃんと理解しないとわからなかった...

### 2. CORS
一番ハマったところである。
`preflightで失敗しています、headerにCORSの設定入れてね`というエラーがずっと出ていたが、どれだけヘッダーをいじっても治らなかった。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/cors.html

結果的にはAWSのコンソールにCORS設定のJSONを書く必要があった。

しかし、書いても上手くいかないときもあった。特に開発中ここに`localhost:3000`を許可していいものか、の問題もあった。結局、実装中はCORSを全開放したが、セキュリティ的にもっといい方法を考えたい。

![スクリーンショット 2024-08-25 19.28.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3754242/1aa1f5e3-4276-e3ed-2f43-3b5a6a167db2.png)

### 3. 署名方法
CORSまで突破したら全てOKだと思っていたが、まだPUTができなかった。
エラーには署名方式が悪いと書いてあったため、UUIDのバージョンをv4にしたりしたが、それでも同じようなエラーが出ていた。

色々調べて、複合化をして送ることで直すことができた。今回の実装では`encodeURIComponent`を使用した。

## 実装例
以下が今回実装したvueとrailsの実装例である。

``` ts
// リサイズにはcropperjsを使っている
// customeInstanceは名前通りカスタムなものを使っているのでご注意ください。
const handleAvatarUpload = async (): Promise<void> => {
  try {
    const croppedCanvas = cropper.value.getCroppedCanvas({
      width: 256,
      height: 256,
    });

    croppedCanvas.toBlob(async (blob: Blob) => {
      const file = new File([blob], imageFile.value.name, {
        type: imageFile.value.type,
      });

      // presigned URLを取得
      const presignedData = await customInstance({
        url: `サーバーのエンドポイント`,
        method: 'PATCH',
        params: { filename: encodeURIComponent(file.name) },
      });

      // ファイルのアップロード
      const response = await fetch(presignedData.presigned_url, {
        method: 'PUT',
        body: file,
        headers: {
          ...presignedData.headers,
          'Content-Type': file.type,
        },
      });
    }, imageFile.value.type);
  } catch (e) {
    console.error('Upload error:', e);
  }
};

```

``` ruby
  def update_avatar_url
    user = User.find(params[:id])

    bucket_name = 'バケット名'
    s3_client = Aws::S3::Client.new(region: 'リージョン')
    signer = Aws::S3::Presigner.new(client: s3_client)

    rand_path = SecureRandom.uuid.delete('-')
    key = ['avatar', rand_path, user.id, params[:filename]].join('/')

    url, headers = signer.presigned_request(
      :put_object,
      bucket:       bucket_name,
      key:          key,
      acl:          'private',
      expires_in:   3000,
      content_type: 'image/jpeg',
    )

    user.update!(avatar_url: url)

    render json: {
      presigned_url: url,
      headers:       headers,
      public_url:    "https://#{bucket_name}.s3.amazonaws.com/#{key}"
    }, status: :ok
  end
```