「ユーザーが任意のファイルをアップロードする」の実装パターン

こんにちは。[あのちっく](https://twitter.com/anoChick)です。
[commmune Advent Calendar 2023](https://qiita.com/advent-calendar/2023/commmune) 17日目の記事はファイルアップロード機能を作る話です。
どうぞよろしくお願いします。


# 大方針: ブラウザからクラウドストレージに直接アップロードする。
Webアプリケーションで、ユーザーの投稿に画像や動画を含めたい場合ってありますよね。
一昔前は```multipart/form-data```を使ってサーバーにデータを送信する実装が一般的でしたが、[Cloud RunのHTTP/1 リクエストの最大サイズ32MiB](https://cloud.google.com/run/quotas?hl=ja)や[Vercelのリクエストボディ最大サイズ4.5MB](https://vercel.com/docs/functions/serverless-functions/runtimes)など、ライトなインフラ構成を選択すると、制限に引っかかってしまうケースが多く、この方法はあまり採用されなくなりました。

代わりに推奨されているのが「クラウドストレージに直接アップロードする」という方法です。

:::note info
今回は、Google Cloud StorageとNode.jsをベースに説明をしますが、その他のクラウドストレージ・サーバーランタイムでも同様なことが出来るはずです。
:::

# 署名付きURL
ユーザーがクラウドストレージに直接アップロードする方法の具体として「署名付きURL」を使った方式が存在します。
これは、アプリケーションサーバーがGCP等のストレージAPIを介して任意のリソースについての限定的な権限を請求し[署名付きURL]を受け取る。そしてそのURLを知る全員が権限を有するものとする。という方式です。

https://cloud.google.com/storage/docs/access-control/signed-urls?hl=ja

処理の流れとしては、
1. ユーザーが任意のファイルについての署名付きURLの発行をアプリケーション・サーバーに依頼する
2. アプリケーションサーバーはGCPから署名付きURLを受け取り、ユーザーに返す
3. ユーザーはアプリケーションサーバーから受け取った署名付きを使ってGCPのストレージに直接ファイルをアップロードする

という感じです。

## ファイルサイズを制限する
ユーザーに任意のリソースのアップロードを認可するとして、あまりに大きなサイズのファイルをアップロードされてしまうと大変困ります。そのため、GoogleCloudStorageには、```x-goog-content-length-range```というヘッダーを与えることで署名付きURL発行時に許容するコンテンツサイズの範囲を設定することが出来ます。

これにより、ユーザーがアップロードするファイルサイズの制限をアプリケーションサーバーで行うことが出来ます。
以下はファイルサイズを制限して署名付きURLを生成するプログラム例です。


```typescript
import firebaseAdmin from "./firebaseAdmin";

const bucket = firebaseAdmin.storage().bucket("crater");

const [signedUrl] = await bucket.file(filePath).getSignedUrl({
  action: "write",
  contentType: contentType,
  expires: Date.now() + 1000 * 5 * 60, // 5 minutes
  extensionHeaders: {
    "x-goog-content-length-range": `${contentLength},${contentLength}`,
  },
});
```

## CORS設定も忘れず行う
自分で管理するWebアプリケーションとGoogleCloudStorageのHTTPエンドポイントは、クロスオリジンの関係にあたるため、CORSの制限にかかります。
そのため、CORS構成を設定してあげる必要があります。

https://cloud.google.com/storage/docs/using-cors?hl=ja

設定例は以下のとおりです。

```cors_settings.json
[
  {
    "origin": [
      "http://localhost:3000",
      "https://example.com",
    ],
    "responseHeader": ["x-goog-content-length-range", "content-type"],
    "method": ["*"],
    "maxAgeSeconds": 300
  }
]
```
```shell
> gsutil cors set cors_setting.json gs://[bucketname]
```

# アップロードしたファイルをアプリケーション上で管理できるようにする
ここまでは署名付きURLという方式を使ってクライアントからクラウドストレージに直接ファイルアップロードする方法を紹介しましたが、それだけだとアプリケーションの機能として不十分なケースがあるので、ファイルについてのメタ情報をDBに保存することについて解説したいと思います。

## どのユーザーがアップロードしたかを記録する
まず欲しい情報は「そのファイルをアップロードしたのは誰か？」という情報です。
ユーザーがアップロードしたファイル一覧を表示したいケース、削除したいケース等に対応するには、データベース上に「UserFile」等の名前でアップロードしたファイルと対応するレコードを追加するようにすると便利になります。

## オリジナルファイル情報を記録する
また、クラウドストレージにファイルをアップロードする際にファイル名はURL Safeかつ一意なファイル名に変換する必要があります。

ですが、なんらかのファイルピッカーを使ってファイルをアップロードした場合、あとから編集画面を見たときにそのファイルが何なのかわからなくなってしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22392/5a1babf2-91f6-a0b4-fde6-d87527b27979.png)
例えば、上の画像の場合、オリジナルのファイル名は[スクリーンショット 2023-08-12 193817.png]ですが、GoogleCloudStorageのバケット内では[af11034e-17de-42a3-a35c-6bd9c41c1cd3.png]というファイル名で保存していると、このオリジナルファイル名をあとから知るすべがなくなってしまいます。
そのため、オリジナルファイルについての情報もDBに保存しておきます。

以下がUserFileのprismaでの定義例です。
```prisma

model UserFile {
  id               String @id @default(cuid())
  path             String @db.Text
  fileName         String @db.Text
  originalFileName String @db.Text
  contentType      String
  contentLength    BigInt
  userId           String

  user User @relation(fields: [userId], references: [id])

  createdAt              DateTime                 @default(now())
  updatedAt              DateTime                 @default(now()) @updatedAt
}
```

## アプリケーション上のファイルへの参照はUserFileで扱う
前項で説明したように、オリジナルファイル名などのファイルのメタ情報を画面上で利用したくなるケースを考慮し、他のモデルからのファイルへの参照はリソースURLではなくUserFileにするのが好ましいです。

```typescript
const userFileSchema = z.object({
  id: z.string(),
  path: z.string(),
  fileName: z.string(),
  originalFileName: z.string(),
  contentType: z.string(),
  contentLength: z.number(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const createPostForm = z.object({
  title: z.string(),
  body: z.string(),
  image: userFileSchema.nullable()
})

```
余談ですが、ファイルアップロードごにリサイズ等の後処理をする場合、後処理のステータスをUserFileで管理するようにしておくと、「リサイズ処理が終わっていればリサイズ済みファイルを表示し、そうでない場合はオリジナルファイルを表示する」というような制御もできるようになります。
ただし、最近は画像のリサイズ処理はCDN側で行うのが主流になってきたかと思います。


# おわり
以上です。
署名付きURLを使ったファイルアップロードは、最初は戸惑うことがあるかもしれませんが、慣れてしまうと結構簡単なのでとてもおすすめです。