💭

新しいファイルアップロードサービスのuploadthing[BETA]を利用した

2023/05/22に公開

TL;DR

  • uploadthing を App Router を利用して実装する方法を紹介します。
  • uploadthing は、デベロッパーの開発体験に重視しており、ファイルアップロードを簡単に実装できます。
  • uploadthing は、現状ベータ版ではありますが、無料で 2GB まで使うことができます。
  • uploadthing は、現状ベータ版のため、セキュリティなど必要な機能が不足しているため利用する際は注意が必要です。
  • uploadthing は、Create T3 App を提供している Theo Browne が開発しています。

記述したコードは以下にまとめています。

https://github.com/hayato94087/next-uploadthing-sample

uploadthing

uploadthing はファイルストレージサービスです。デベロッパーの開発体験にフォーカスしており、簡単にファイルアップロードを実装できるように、API を提供しています。

サービスサイトです。

https://uploadthing.com/

開発者ドキュメントです。

https://docs.uploadthing.com/

GitHub

https://github.com/pingdotgg/uploadthing

なぜ作られたのか

uploadthing の目標はファイルのアップロードに関して、デベロッパーがウェブ上でアプリケーションを構築しやすくすることです。昨今、ウェブアプリケーションから、データベースを操作することは容易になりましたが、ファイルのアップロードについては、実装が煩雑になる課題は解決されないままでした。この課題を解決することが uploadthing の目標です。

以下のビデオで、uploadthing が紹介されています。

https://www.youtube.com/watch?v=mxT3j-5s1Zc

また、以下のビデオで uploadthing のオープンソース化について紹介されています。

https://www.youtube.com/watch?v=uZdEsWOOhfA

だれが作っているのか

運営会社は ❓

Theo Browne と Mark Florkowski が 22021 年に設立した ping.gg によって提供されます。

https://ping.gg/

Theo って ❓

Theo は ping.gg の CEO であり、Youtube で開発に関するビデオを多く公開しています。Theo は、「State of JavaScript」において、デベロッパーがフォローする動画部門のインフルエンサーでランキング 4 位に選ばれています。

https://2022.stateofjs.com/en-US/resources/

https://twitter.com/t3dotgg

Theo が関連するツール


Theo が関連するツールとしては Create T3 App があります。Create T3 App とは、Next.js, Tailwind CSS, TypeScript, TRPC, Prisma, NextAuth などを利用しウェブアプリケーションを簡単に作成できる環境を構築するツールです。Create T3 App も Theo の思想をベースにデベロッパーが、より良い開発体験を得られるように、設計されています。

https://create.t3.gg/

なぜ利用するのか

なぜ uploadthing を利用するかについてです。

  • ファイルストレージサービスは、AWS S3 などがありますが、実装が煩雑になる課題があります。
  • uploadthing は、デベロッパーの開発体験に重視しており、ファイルアップロードを簡単に実装できます。
  • Vercel もファイルストレージサービスを提供しています。が、上限が 4MB と制限がありますが、uploadthing は 1GB(※)まで利用が可能です。(※上限サイズがどこまでなのか要確認)
  • uploadthing は現状ベータ版ではありますが、無料で 2GB まで使うことができます。

利用に関する注意点

まだ開発初期であるため、できないことが多いです。特に気になったのは下記です。

  • セキュリティ周りの機能が不十分。とっくにアップロードされたファイルは、URL がわかれば、誰でもアクセスできてしまいます。
  • 誤ったファイル削除を保護する機能がない。
  • ファイルのバージョン管理ができない。

料金プラン

  • 現状は 2 つのプロジェクトが無料で作成できます。
  • それぞれのプロジェクトはプロジェクト全体で 2GB までアップロードできます。
  • プライシングはないためすべて無料で利用できます。
  • 有料での利用は現状不可能ですが、利用者と Theo の Discord のやりとりから、問い合わせれば、2GB の上限を緩和してもらうこともできる模様です。

サポート

英語ですが、困ったことがあれば Discord で質問できます。

https://discord.com/invite/UCXkw6xj2K

それでは、実際に使ってみます。

アカウントの作成

まず、アカウントを作っていきます。uploadthingのアカウント作成は簡単で、GitHub のアカウントでログインするだけです。

  1. 「Sign in」をクリックします。

  1. 「Continue with GitHub」をクリックします。

  1. GitHub のアカウントにサインインします。

  1. 無事ログインできました。すごく簡単 🎉

Next.js の新規プロジェクト作成

Next.js で実際に試してみます。まずは動作確認するために、新規に Next.js のプロジェクトを作成します。

$ pnpm create next-app next-uploadthing-sample --typescript --eslint --src-dir --import-alias "@/*" --use-pnpm --tailwind --app

ログ

Library/pnpm/store/v3/tmp/dlx-24634      | Progress: resolved 1, reused 0, dowLibrary/pnpm/store/v3/tmp/dlx-24634      |   +1 +
Library/pnpm/store/v3/tmp/dlx-24634      | Progress: resolved 1, reused 0, dowPackages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/hayato94087/Library/pnpm/store/v3
  Virtual store is at:             Library/pnpm/store/v3/tmp/dlx-24634/node_modules/.pnpm
Library/pnpm/store/v3/tmp/dlx-24634      | Progress: resolved 1, reused 0, dowLibrary/pnpm/store/v3/tmp/dlx-24634      | Progress: resolved 1, reused 1, downloaded 0, added 1, done
Creating a new Next.js app in /Users/hayato94087/Private/next-uploadthing-sample.

Using pnpm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom
- tailwindcss
- postcss
- autoprefixer
- eslint
- eslint-config-next

Packages: +348
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/hayato94087/Library/pnpm/store/v3
  Virtual store is at:             node_modules/.pnpm
Progress: resolved 356, reused 345, downloaded 3, added 348, done

dependencies:
+ @types/node 20.1.7
+ @types/react 18.2.6
+ @types/react-dom 18.2.4
+ autoprefixer 10.4.14
+ eslint 8.40.0
+ eslint-config-next 13.4.2
+ next 13.4.2
+ postcss 8.4.23
+ react 18.2.0
+ react-dom 18.2.0
+ tailwindcss 3.3.2
+ typescript 5.0.4

Done in 13s
Initialized a git repository.

Success! Created next-uploadthing-sample at /Users/hayato94087/Private/next-uploadthing-sample

作成後に、ディレクトリに移動します。

$ cd next-uploadthing-sample

uploadthing のプロジェクトを作成

uploadthing のプロジェクトを作成します。

  1. 「Create a new app」をクリックします。

  1. 「App Name」にアプリケーション名を入力し、「Create App」をクリックします。「App URL」の入力は任意です。

  1. プロジェクトを作成できました。

uploadthing の認証情報を取得

  1. 「API Keys」をクリックします。

  1. 「Copy」をクリックして、認証情報をコピーします。

uploadthing の認証情報を環境変数に追加

uploadthing を利用するための認証情報を環境変数に追加します。

  1. 環境変数ファイルを作成します。
$ touch .env.local
  1. 認証情報をペーストします。
UPLOADTHING_SECRET=sk_live_f985d635b1303d557b665f862a0b84261521659c4cfbbb2e270e4ef89e712c11
UPLOADTHING_APP_ID=6618ard57u

パッケージのインストール

uploadthing の利用に必要なパッケージをインストールします。

$ pnpm install uploadthing @uploadthing/react react-dropzone

ログ

Packages: +5
+++++
Progress: resolved 361, reused 351, downloaded 2, added 5, done

dependencies:
+ @uploadthing/react 3.0.4
+ react-dropzone 14.2.3
+ uploadthing 3.0.4

Done in 3.6s

FileRouter を作成

ファイルのアップロードは FileRouter を通し実行されます。

利用シナリオに応じてアップロードするファイルの制約(ファイルサイズの上限、認証の有無、メタデータとして付与する情報)は異なります。例えば、アバターのアップロードとビデオのアップロードではファイルサイズの上限は異なる可能性があります。利用シナリオに応じて、求められる異なる要件を FileRouter で開発者は実装ができます。

FileRouter を作成するために空ファイルを作成します。

$ mkdir -p src/app/api/uploadthing
$ touch src/app/api/uploadthing/core.ts

以下がコードです。

app/api/uploadthing/core.ts
/** app/api/uploadthing/core.ts */
import { createUploadthing, type FileRouter } from "uploadthing/next";
const f = createUploadthing();

const auth = (req: Request) => ({ id: "fakeId" }); // Fake auth function

// FileRouterはニーズに合わせてエンドポイントを複数作成することができます。
export const ourFileRouter = {
  // FileRouterはニーズに合わせて複数作成することができます。
  imageUploader: f
    // アップロードするファイルタイプを指定します。
    .fileTypes(["image", "video"])
    // アップロードするファイルの最大サイズを指定します。
    .maxSize("1GB")
    // ユーザー認証を行います。
    .middleware(async (req) => {
      // アップロードが行われる前にサーバサイドで実行されます。
      const user = await auth(req);

      // 例えば認証に失敗した場合は、エラーを投げます。アップロードは実行されません。
      if (!user) throw new Error("Unauthorized");

      // ここで返却される値は、onUploadCompleteのコールバックで`metadata`の変数で取得が可能です。
      return { userId: user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // アップロードが完了したときに実行されるコールバック関数です。
      console.log("Upload complete for userId:", metadata.userId);

      console.log("file url", file.url);
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;

今回は画像と動画を投稿するためのエンドポイント(imageUploader)を作成しました。エンドポイントはニーズに合わせて制約条件も異なるため、複数作成できます。

FileRouter の引数について補足します。

fileTypes

許容するファイルのタイプを指定します。

    // アップロードするファイルタイプを指定します。
    .fileTypes(["image", "video"])

取れる値は"image", "video", "audio", "blob" のいずれかになります。

type AllowedFiles = "image" | "video" | "audio" | "blob";

maxSize

ファイズの最大サイズを指定します。

    // アップロードするファイルの最大サイズを指定します。
    .maxSize("1GB")


値は、"1", "2", "4", "8", "16", "32", "64", "128", "256", "512", "1024"のいずれか。単位は、"B", "KB", "MB", "GB"のいずれか。

type SizeUnit = "B" | "KB" | "MB" | "GB";
type PowOf2 = 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024;
type FileSize = `${PowOf2}${SizeUnit}`;

middleware

利用は任意で、ユーザー認証などを行います。コードはサーバサイドで実行されます。

    // ユーザー認証を行います。
    .middleware(async (req) => {
      // アップロードが行われる前にサーバサイドで実行されます。
      const user = await auth(req);

      // 例えば認証に失敗した場合は、エラーを投げます。アップロードは実行されません。
      if (!user) throw new Error("Unauthorized");

      // ここで返却される値は、onUploadCompleteのコールバックで`metadata`の変数で取得が可能です。
      return { userId: user.id };
    })

以下でユーザが認証済みか確認します。

// アップロードが行われる前にサーバサイドで実行されます。
const user = await auth(req);

// 例えば認証に失敗した場合は、エラーを投げます。アップロードは実行されません。
if (!user) throw new Error("Unauthorized");

以下で、メタデータとして付与する値を指定します。ここではユーザ ID を付与しています。この値は、onUploadComplete のコールバック関数で取得できます。

// ここで返却される値は、onUploadCompleteのコールバックで`metadata`の変数で取得が可能です。
return { userId: user.id };

onUploadComplete

アップロードが完了したときに実行されるコールバック関数です。コードはサーバサイドで実行されます。middleware のコールバック関数で返却されていた metadata にアクセスできます。以下のコードでは、アップロード完了後に、ユーザーID とファイルの URL をログとして出力しています。

    // アップロードが完了したときにサーバサイドで実行されるコールバック関数です。
    .onUploadComplete(async ({ metadata, file }) => {
      console.log("Upload complete for userId:", metadata.userId);
      console.log("file url", file.url);
    }),

onUploadComplete では、アップロード完了後の処理を実装できます。例えば、アップロード完了後に、アップロードされたファイルの URL を取得し、データベースに書き込む処理などの実装ができます。

FileRouter にアクセスするための API を作成

FileRouter にアクセスするための API を作成します。WebHook を利用しているため、必ず、app/api/ 配下に実装する必要があります。

空ファイルを作成します。

$ touch src/app/api/uploadthing/route.ts

以下がコードです。API を実装します。

app/api/uploadthing/route.ts
/** app/api/uploadthing/route.ts */
import { ourFileRouter } from "./core";
import { createNextRouteHandler } from "uploadthing/next";

// routerをエクスポートします。
export const { GET, POST } = createNextRouteHandler({
  router: ourFileRouter,
});

FileRouter をアプリケーションで利用

公式が、ファイルアップロードするための UI コンポーネントを提供しています。今回は、提供されている、UploadButton コンポーネントを利用します。注意点として、@uploadthing/react/styles.css をインポートしないとデザインが崩れてしまいます。

空ファイル作成します。

$ touch src/app/uploadButtonSample.tsx

以下がコードです。

uploadButtonSample.tsx
"use client";
 
// You need to import our styles for the button to look right. Best to import in the root /layout.tsx but this is fine
import "@uploadthing/react/styles.css";
 
import { UploadButton } from "@uploadthing/react";
import { OurFileRouter } from "./api/uploadthing/core";
 
export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <UploadButton<OurFileRouter>
        endpoint="imageUploader"
        onClientUploadComplete={(res) => {
          // Do something with the response
          console.log("Files: ", res);
          alert("Upload Completed");
        }}
        onUploadError={(error: Error) => {
          // Do something with the error.
          alert(`ERROR! ${error.message}`);
        }}
      />
    </main>
  );
}

page.tsx を修正します。

page.tsx
import UploaderButtonSample from "./uploadButtonSample";

export default function Home() {
  return (
    <main className="bg-white h-screen w-screen">
      <UploaderButtonSample/>
    </main>
  );
}

ファイルをアップロード

実際にファイルをアップロードしてみます。ローカルで開発環境を実行します。

$ pnpm dev
  1. 「Choose File」をクリックします。

2.「1.png」を選択し、ファイルをアップロードします。

  1. アップロードが完了しました。「OK」をクリックします。

  1. 実行ログを確認します。

以下のようにログが出力されています。

-  ┌ POST /api/uploadthing?actionType=upload&slug=imageUploader 200 in 384ms
   │
   └──── POST https://uploadthing.com/api/prepareUpload 200 in 343ms (cache: MISS)

[UT] SIMULATING FILE UPLOAD WEBHOOK CALLBACK http://localhost:3000/api/uploadthing?slug=imageUploader
Upload complete for userId: fakeId
file url https://uploadthing.com/f/296750f9-007e-4459-a476-8ebf47858df3_1.png
[UT] Successfully simulated callback for file 296750f9-007e-4459-a476-8ebf47858df3_1.png

注目すべきは、onUploadComplete のコールバックが呼び出され、Upload complete for userId: fakeId がログとして出力されていることです。 userId: fakeIdmiddleware で返却された値になります。

Upload complete for userId: fakeId

再度、記述しますが、onUploadComplete を利用することで、アップロード完了後の実装ができます。例えば、データベースへの書き込みなどです。

  1. 「2.png」,「3.png」,「4.png」,「5.png」のファイルをアップロードします。

  2. 次に、ダッシュボードを確認します。

項目 説明
Total Files(All time) アップロードされたすべてのファイルの数
Files Uploaded(Past Month) 今月アップロードされたファイルの数
Storage Usage(Total) ストレージの使用率、使用量
Storage Usage(Past Month) 今月のストレージの使用量
Recent Uploads 直近アップロードされたファイルの一覧
Largest Files アップロードされたファイルの一覧(ファイルサイズを軸に降順)

続いて、ドラッグアンドドロップで、ファイルをアップロードする方法を試してみます。

UploadDropzone

先程の例では、UploadButton のコンポーネントを使い、ファイルアップロードを実装しました。公式ドキュメントだと分かりにく記載されてますが、ファイルをドラッグアンドドロップで利用できるコンポーネントの UploadDropzone も提供されています。そのコンポーネントを利用して、ファイルをドラッグアンドドロップでアップロードする方法を試してみます。

$ touch src/app/uploadDropZoneSample.tsx
uploadDropZoneSample.tsx
"use client";

// You need to import our styles for the button to look right. Best to import in the root /layout.tsx but this is fine
import "@uploadthing/react/styles.css";

import { UploadDropzone } from "@uploadthing/react";
import { OurFileRouter} from "./api/uploadthing/core";

export default function Home() {

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <UploadDropzone<OurFileRouter>
        endpoint="imageUploader"
        onClientUploadComplete={(res) => {
          // Do something with the response
          console.log("Files: ", res);
          alert("Upload Completed");
        }}
        onUploadError={(error: Error) => {
          // Do something with the error.
          alert(`ERROR! ${error.message}`);
        }}
      />
    </main>
  );
}

コードを修正します。

page.tsx
-import UploaderButtonSample from "./uploadButtonSample";
+// import UploaderButtonSample from "./uploadButtonSample";
+import UploadDropZoneSample from "./uploadDropZoneSample";

export default function Home() {
  return (
    <main className="bg-white h-screen w-screen">
-      <UploaderButtonSample/>
+      {/* <UploaderButtonSample/> */}
+      <UploadDropZoneSample/>
    </main>
  );
}

実行すると以下のように、ファイルのドラッグアンドドロップができるようになります。

コンポーネントのデザインのカスタマイズ

公式で提供されている、2つのコンポーネントは、UploadButtonUploadDropzone はデザインのカスタマイズができません。この課題を解決するために、Theo は uploadthing をオープンソース化しています。

https://github.com/pingdotgg/uploadthing

公式が提供している2つのコンポーネントの実装を確認できます。

https://github.com/pingdotgg/uploadthing/blob/main/packages/react/src/component.tsx

公式が提供するフック(useUploadThing)を利用することで、自身のオリジナルのボタンを作成できます。

参考までに 1 つ作成してみました。

$ touch src/app/customUploadButtonSample.tsx

以下が実装です。ボタンの色を緑に変更しています。

src/app/customUploadButtonSample.tsx

"use client";

import { generateReactHelpers } from "@uploadthing/react/hooks";
const { useUploadThing } = generateReactHelpers<OurFileRouter>();
import { OurFileRouter } from "./api/uploadthing/core";
import { generateMimeTypes } from "uploadthing/client";

export default function Home() {
  const { isUploading, permittedFileInfo, startUpload } = useUploadThing({
    endpoint: "imageUploader",
    onClientUploadComplete: (res) => {
      // Do something with the response
      console.log("Files: ", res);
      alert("Upload Completed");
    },
    onUploadError: (error: Error) => {
      // Do something with the error.
      alert(`ERROR! ${error.message}`);
    },
  });
  const { maxSize, fileTypes } = permittedFileInfo ?? {};
  const multiple = true;

  return (
    <div className="flex flex-col items-center">
      <label className="bg-green-700 rounded-md py-2 px-3 h-[40px] w-[180px] flex items-center justify-center">
        <input
          className="hidden"
          type="file"
          multiple={multiple}
          accept={generateMimeTypes(fileTypes ?? []).join(", ")}
          onChange={(e) => {
            e.target.files && startUpload(Array.from(e.target.files));
          }}
        />
        <span className="text-white">
          {isUploading ? <Spinner /> : `Choose File${multiple ? `(s)` : ``}`}
        </span>
      </label>
      <div className="h-[1.25rem]">
        {fileTypes && (
          <p className="text-xs leading-5 text-gray-600">
            {`${fileTypes.join(", ")}`} {maxSize && `up to ${maxSize}`}
          </p>
        )}
      </div>
    </div>
  );
}

const Spinner = () => {
  return (
    <svg
      className="animate-spin h-5 w-5 text-white"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 576 512"
    >
      <path
        fill="currentColor"
        d="M256 32C256 14.33 270.3 0 288 0C429.4 0 544 114.6 544 256C544 302.6 531.5 346.4 509.7 384C500.9 399.3 481.3 404.6 465.1 395.7C450.7 386.9 445.5 367.3 454.3 351.1C470.6 323.8 480 291 480 255.1C480 149.1 394 63.1 288 63.1C270.3 63.1 256 49.67 256 31.1V32z"
      />
    </svg>
  );
};
page.tsx
// import UploaderButtonSample from "./uploadButtonSample";
-import UploadDropZoneSample from "./uploadDropZoneSample";
+// import UploadDropZoneSample from "./uploadDropZoneSample";
+import CustomUploadButtonSample from "./customUploadButtonSample";

export default function Home() {
  return (
    <main className="bg-white h-screen w-screen">
      {/* <UploaderButtonSample/> */}
-      <UploadDropZoneSample/>
+      {/* <UploadDropZoneSample/> */}
+      <CustomUploadButtonSample/>
    </main>
  );
}

実行します。

$ pnpm dev

動作確認します。ボタンが緑になっています。

その他

アップロードされたか判断する方法

クライアント端末側のネットワークの問題などで、本当にファイルがアップロードされたか、どう判断できるのかについての確認方法について記載します。

アップロード完了後に onUploadComplete で指定されたコールバック関数が呼ばれます。metadata に関連する情報を入れておくことで、コールバック関数から metadata にアクセスし、アップロードが完了したか判断できます。

    // アップロードが完了したときにサーバサイドで実行されるコールバック関数です。
    .onUploadComplete(async ({ metadata, file }) => {
      console.log("Upload complete for userId:", metadata.userId);
      console.log("file url", file.url);
    }),

アップロード時間に関わらず、コールバック関数は必ず呼ばれます。

トラブルシューティング

ファイルアップロード時にエラーが出る

実行しているポートが 3000 でない場合は、ファイルアップロード時にエラーが出ます。

  1. ポート 3001 番で起動します。
npx next dev -p 3001

  1. 「6.png」をアップロードします。

  1. 「OK」をクリックします。

  1. ログを確認します。

以下のようにエラーが出ています。これは実行するポートが 3000 番ではない場合、2023 年 5 月 18 日現在はエラーが出ます。

-  ┌ POST /api/uploadthing?actionType=upload&slug=imageUploader 200 in 276ms
   │
   └──── POST https://uploadthing.com/api/prepareUpload 200 in 256ms (cache: MISS)

[UT] SIMULATING FILE UPLOAD WEBHOOK CALLBACK http://localhost:3000/api/uploadthing?slug=imageUploader
[UT] Failed to simulate callback for file. Is your webhook configured correctly? 488b4744-07d9-4ffd-ae66-06e41298707a_6.png

認証情報のロールバック時の不具合

ダッシュボードで認証情報を更新できます。が、管理画面上だと、バグで古い認証情報がコピーできてしまうので注意してください。このバグは 2023 年 5 月 18 日現在で確認できます。

  1. API Keys をアクセスし、「Copy」をクリックし認証情報をコピーします。

以下が、コピーした認証情報です。

UPLOADTHING_SECRET=sk_live_37856f3033b7614d450d2a9e849a56f39003c0440b9657ce99810a2da5640f2a
UPLOADTHING_APP_ID=6618ard57u
  1. 「Roll」をクリックし、認証情報を更新します。

  1. 認証情報が更新されたことを、Key の下4桁が変わっていることからわかります。

  1. 「Copy」をクリックし、認証情報をコピーします。

以下が、コピーした認証情報です。

UPLOADTHING_SECRET=sk_live_37856f3033b7614d450d2a9e849a56f39003c0440b9657ce99810a2da5640f2a
UPLOADTHING_APP_ID=6618ard57u

コピーした認証情報は、下 4 桁の値も異なることから、古い情報を取得されてることがわかります。

  1. 画面をリフレッシュします。あらためて認証情報を取得します。

無事、新しい情報が取得できました。

UPLOADTHING_SECRET=sk_live_66dbbc2d47f63fc9066268496d9cf501a8019b46e2c72a2e2670c179fdae0bc1
UPLOADTHING_APP_ID=6618ard57u

まとめ

  • uploadthing をサービス内容を紹介しました。
  • uploadthing は、App Router を利用して実装する方法を紹介しました。
  • uploadthing を利用する上でのトラブルシューティングをまとめました。

記述したコードは以下にまとめています。

https://github.com/hayato94087/next-uploadthing-sample

Discussion