見出し画像

Next.js + SupabaseでAuth + Storageのストレージサービスを作る方法

ビジネス向けのITツールには、業界ごとに多種多様な機能が提供されていますが、ほぼ全ての業種で必要とされているのがこの『ストレージサービス』です。
特にテレワークが流行っている現在では、共有フォルダ等よりストレージサービスを使うのが一般的ですし、あらゆる業務管理システムや営業管理システムにもストレージが付属しています。

今回は、そのようなストレージサービスを『Next.js』『Supabase』で作ってしまう方法について、ご紹介します。


Supabase事前準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していないければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。

適当なプロジェクト名とデータベースのパスワードを入れて、新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:ユーザ作成

`Authentication`を開き、ユーザを2人作ります。
メールアドレスとパスワードが必要ですが、メールアドレスはテスト用として`example.com`のものを利用します。
『example.com』は例示用に確保されているセカンドレベルドメインなので、うっかりそのまま実装しても事故が起きにくいです。

右上の`Add User`→`Create New User`を押すと、下記の画面が出て来ます。

任意のパスワードで下記のメールアドレスのユーザを作ってください

※ユーザ作成時`Auto Confirm User?`のチェックは外さないでください。

下記のように追加ができていればOKです

③:ストレージ作成

`Storage`を開き、Bucketを2つ作成します。
左上の`New Bucket`を押し、

  • private-bucket

  • shared-bucket

の2つを作成しましょう。
今回はAuth機能を扱うため、Private bucketを利用します。Public bucketのチェックはONにしないでください。

アクセス制限設定などは後ほど行うため、一旦ここまででOKです。

Next.js事前準備

①:認証機能のリポジトリをクローン

今回はこちらの記事で紹介している認証機能(Supabase Auth)を元に実装を進めたいので、
まずはこちらの記事内にあるgithubのリンク から`git clone`してリポジトリを持ってきてください。

②:起動確認

Supabase Authが正しく動くかをまず確認しましょう。

クローンしたプロジェクトの直下に`.env.local`を新規作成し、
内容を下記の通り入力します

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL={ここにProject URLを入れる}
NEXT_PUBLIC_SUPABASE_ANON_KEY={ここにProject API Anon Keyを入れる}

それぞれここに〜〜入れると書かれている部分に
Supabaseのダッシュボードの`Project Settings`の`API`から必要な情報をコピーします。

コピーが終わったら、

npm install

を実行した上で

npm run dev

を実行します。

下記の画面が表示されるか確認してください。

設定したメールアドレスでLoginを行い、Profileページが表示されたらOKです。

③:その他実装

メインの部分とは関係ない実装を先んじて行います。

components/navigation.tsx

ヘッダーに追加ページへのリンクを入れたいので編集します。

'use client';
import type { Session } from '@supabase/auth-helpers-nextjs';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import ModalCore from './modalCore';
import { ModalType } from './modal/modalType';
const Navigation = ({ session }: { session: Session | null }) => {
  const pathname = usePathname();
  const router = useRouter();
  if (session === null && pathname?.includes('/profile')) {
    router.push('/');
  }
  return (
    <header>
      <div className="flex items-center justify-between px-4 py-2 bg-white shadow-md">
        <nav className="hidden md:flex space-x-4">
          <div>
            <Link className="text-gray-600 hover:text-blue-600" href="/">
              Home
            </Link>
          </div>
          {session ? (
            <>
              <div>
                <Link
                  className="text-gray-600 hover:text-blue-600"
                  href="/profile"
                >
                  Profile
                </Link>
              </div>
              <div>
                <Link
                  className="text-gray-600 hover:text-blue-600"
                  href="/sharedStorage"
                >
                  共有ストレージ
                </Link>
              </div>
              <div>
                <Link
                  className="text-gray-600 hover:text-blue-600"
                  href="/privateStorage"
                >
                  プライベートストレージ
                </Link>
              </div>
            </>
          ) : (
            <>
              <div>
                <ModalCore modalType={ModalType.SignIn}></ModalCore>
              </div>
              <div>
                <ModalCore modalType={ModalType.SignUp}></ModalCore>
              </div>
            </>
          )}
        </nav>
      </div>
    </header>
  )
}

export default Navigation

下準備はこれでOKです。

uuidのインストール

Supabase Storageでファイル名に使えない文字があるため、ファイル名をランダムな値で生成して渡すようにしています。
下記がランダムなIDを生成するためのパッケージです。
https://www.npmjs.com/package/uuid

下記2つのコマンドを実行することでインストールしてください。

npm i uuid
npm i --save-dev @types/uuid

主要部分の実装

メインとなる、ストレージとデータのやり取りをする部分を作っていきましょう。
※存在しないフォルダ、ファイルはその時々で追加をお願いします。

プライベートストレージの実装

app/privateStorage/page.tsx

まずは全体を覆うレイアウト部分です。

import PrivateStorageApp from "@/components/privateStorageApp";

export default function PrivateStorage() {
  return (
    <>
      <h1 className="mb-4 pt-28 text-4xl text-center">プライベートストレージ</h1>
      <div className="flex-1 w-full flex flex-col items-center">
        <PrivateStorageApp />
      </div>
    </>

  );
}

components/privateStorageApp.tsx

ストレージアプリの機能に関しては、下記のように実装しています。

"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react"
import { v4 as uuidv4 } from 'uuid'

export default function PrivateStorageApp() {
  const [fileList, setFileList] = useState<string[]>([])
  const [loadingState, setLoadingState] = useState("hidden")
  const supabase = createClientComponentClient();

  const getUserID = async () => {
    let result = ""
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (user != null) {
      result = user.id;
    }
    return result
  }

  const listAllFile = async () => {
    setLoadingState("flex justify-center")
    const id = await getUserID();
    if (id === "") {
      return
    }
    const { data, error } = await supabase
      .storage
      .from('private-bucket')
      .list(id, {
        limit: 100,
        offset: 0,
        sortBy: { column: 'created_at', order: 'desc' },
      })
    if (error) {
      console.log(error)
      return
    }
    const tmpFileList = data
    const result = []
    for (let index = 0; index < tmpFileList.length; index++) {
      if (tmpFileList[index].name != ".emptyFolderPlaceholder") {
        result.push(tmpFileList[index].name)
      }
    }
    setFileList(result)

    setLoadingState("hidden")
  }

  useEffect(() => {
    (async () => {
      await listAllFile()
    })()
  }, [])


  const [file, setFile] = useState<File>()
  const handleChangeFile = (e: any) => {
    if (e.target.files.length !== 0) {
      setFile(e.target.files[0]);
    }
  };
  const onSubmit = async (
    event: any
  ) => {
    event.preventDefault();

    const id = await getUserID();
    if (id === "") {
      return
    }

    const fileExtension = file!!.name.split(".").pop()
    const { error } = await supabase.storage
      .from('private-bucket')
      .upload(`${id}/${uuidv4()}.${fileExtension}`, file!!)
    if (error) {
      alert("エラーが発生しました:" + error.message)
      return
    }
    setFile(undefined)

    await listAllFile()

  }

  const onDownload = async (e: any) => {
    const id = await getUserID();
    if (id === "") {
      return
    }
    const target = e.target as HTMLButtonElement
    const item = target.previousSibling
    const { data, error } = await supabase
      .storage
      .from('private-bucket')
      .download(`${id}/${item?.textContent}`)
    if (error) {
      console.log(error)
      return
    }
    if (data) {
      const url = window.URL.createObjectURL(data);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${item?.textContent}`;
      // リンクをクリック
      document.body.appendChild(a);
      a.click();

      // 後処理
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }
  }
  return (
    <>
      <form className="mb-4 text-center" onSubmit={onSubmit}>
        <input
          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"
          type="file"
          id="formFile"
          onChange={(e) => { handleChangeFile(e) }}
        />
        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
      <div className="w-full max-w-3xl">
        <div className={loadingState} aria-label="読み込み中">
          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
        </div>
        <ul className="w-full">
          {fileList.map((item, index) => (
            <li className="w-full h-auto p-1 border-b-2 flex justify-between" key={index}>
              <div>{item}</div>
              <button onClick={(e) => onDownload(e)} className="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm w-full sm:w-auto px-2 py-1 text-center disabled:opacity-25">ダウンロード</button>
            </li>
          ))}
        </ul>
      </div>
    </>
  )
}


プライベートストレージ実装のポイント

listAllFile()

`listAllFile`関数の中で下記のようにstorageの内容を取得しています。
ポイントは『listの対象フォルダ』に『ユーザID(id)』を渡している部分です。
これによりユーザ単位でフォルダを作成し、そこでファイルを管理出来ます。
※後々このフォルダ管理がストレージのアクセス制限で役に立ちます。

const { data, error } = await supabase
      .storage
      .from('private-bucket')
      .list(id, {
        limit: 100,
        offset: 0,
        sortBy: { column: 'created_at', order: 'desc' },
      })

getUserID()

変数`id`は`getUserID`関数で、ユーザ情報全体を取得して渡しています。

const getUserID = async () => {
    let result = ""
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (user != null) {
      result = user.id;
    }
    return result
  }

onDownload()

ファイルリスト右のダウンロードボタンをクリックした際に、ファイルのダウンロードを行うための処理です。
Supabase Storageではファイル取得に関して

  • 期限付きのURLを発行する

  • 直接ファイルをダウンロードする

の2つのパターンが存在しますが、
URL発行だとこのURLが誤って公開されることで、誰でもダウンロードができる状態に一時的になってしまう可能性があります。
直接ダウンロードすることでより、安全にファイルのやり取りを行うことが可能なため、今回はこちらを利用しています。

const onDownload = async (e: any) => {
    const id = await getUserID();
    if (id === "") {
      return
    }
    const target = e.target as HTMLButtonElement
    const item = target.previousSibling
    const { data, error } = await supabase
      .storage
      .from('private-bucket')
      .download(`${id}/${item?.textContent}`)
    if (error) {
      console.log(error)
      return
    }
    if (data) {
      const url = window.URL.createObjectURL(data);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${item?.textContent}`;
      // リンクをクリック
      document.body.appendChild(a);
      a.click();

      // 後処理
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }
  }
const { data, error } = await supabase
      .storage
      .from('private-bucket')
      .download(`${id}/${item?.textContent}`)

上記で渡された`data`はblob形式であるため、ダウンロードを可能にするために、
blobからURLを生成し、aタグに渡してjsから一時的にクリックさせています。

if (data) {
      const url = window.URL.createObjectURL(data);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${item?.textContent}`;
      // リンクをクリック
      document.body.appendChild(a);
      a.click();

      // 後処理
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }

共有ストレージの実装

共有ストレージ側は実装自体は、プライベートストレージの『ユーザIDフォルダ』を『固定フォルダ(shared-folder)』に変更したものなので特に説明はありません。

import SharedStorageApp from "@/components/sharedStorageApp";
export default function SharedStorage() {
  return (
    <>
      <h1 className="mb-4 pt-28 text-4xl text-center">共有ストレージ</h1>
      <div className="flex-1 w-full flex flex-col items-center">
        <SharedStorageApp />
      </div>
    </>

  );
}
"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react"
import { v4 as uuidv4 } from 'uuid'

export default function SharedStorageApp() {
  const [fileList, setFileList] = useState<string[]>([])
  const [loadingState, setLoadingState] = useState("hidden")
  const supabase = createClientComponentClient();

  const listAllFile = async () => {
    setLoadingState("flex justify-center")
    const { data, error } = await supabase
      .storage
      .from('shared-bucket')
      .list("shared-folder", {
        limit: 100,
        offset: 0,
        sortBy: { column: 'created_at', order: 'desc' },
      })
    if (error) {
      console.log(error)
      return
    }
    const tmpFileList = data
    const result = []
    for (let index = 0; index < tmpFileList.length; index++) {
      if (tmpFileList[index].name != ".emptyFolderPlaceholder") {
        result.push(tmpFileList[index].name)
      }
    }
    setFileList(result)

    setLoadingState("hidden")
  }

  useEffect(() => {
    (async () => {
      await listAllFile()
    })()
  }, [])


  const [file, setFile] = useState<File>()
  const handleChangeFile = (e: any) => {
    if (e.target.files.length !== 0) {
      setFile(e.target.files[0]);
    }
  };
  const onSubmit = async (
    event: any
  ) => {
    event.preventDefault();

    const fileExtension = file!!.name.split(".").pop()
    const { error } = await supabase.storage
      .from('shared-bucket')
      .upload(`shared-folder/${uuidv4()}.${fileExtension}`, file!!)
    if (error) {
      alert("エラーが発生しました:" + error.message)
      return
    }
    setFile(undefined)

    await listAllFile()

  }

  const onDownload = async (e: any) => {
    const target = e.target as HTMLButtonElement
    const item = target.previousSibling
    const { data, error } = await supabase
      .storage
      .from('shared-bucket')
      .download(`shared-folder/${item?.textContent}`)
    if (error) {
      console.log(error)
      return
    }
    if (data) {
      const url = window.URL.createObjectURL(data);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${item?.textContent}`;
      // リンクをクリック
      document.body.appendChild(a);
      a.click();

      // 後処理
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }
  }
  return (
    <>
      <form className="mb-4 text-center" onSubmit={onSubmit}>
        <input
          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"
          type="file"
          id="formFile"
          onChange={(e) => { handleChangeFile(e) }}
        />
        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
      <div className="w-full max-w-3xl">
        <div className={loadingState} aria-label="読み込み中">
          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>
        </div>
        <ul className="w-full">
          {fileList.map((item, index) => (
            <li className="w-full h-auto p-1 border-b-2 flex justify-between" key={index}>
              <div>{item}</div>
              <button onClick={(e) => onDownload(e)} className="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm w-full sm:w-auto px-2 py-1 text-center disabled:opacity-25">ダウンロード</button>
            </li>
          ))}
        </ul>
      </div>
    </>

  )

}

これで実装に関しては終わりました。

Storageのアクセス制限の設定

実装が終わったので、実際にアプリにアクセスしてみましょう

npm run dev

して、user1@example.comでログインします。

すると、共有ストレージ、プライベートストレージというメニューが追加されているのがわかるかと思います。

早速共有ストレージにアクセスしてファイルのアップロードをしてみましょう。

ファイルを送信してみると下記のようにエラーが発生してしまっているのがわかるかと思います。


内容を直訳すると『新しい行がRLSのポリシーに違反している』という内容になります。
実際指摘されている通り、何のルールも設定していない状態で、プライベートなストレージにアップロードを行おうとしたため、Supabase側でポリシー違反(ポリシーが存在しない)をしてしまっている状況です。

これを解消するためには、適切なポリシーを共有ストレージとプライベートストレージへそれぞれ設定する必要があります。

共有ストレージのポリシー設定

ポリシーの設定をするために`SQL Editor`を開き、`New query`を押しましょう。
Queryの編集画面へ、まずは下記を入力します。

create policy "Individual user Access For SharedBucket"
on storage.objects for select
to authenticated
using (
  bucket_id = 'shared-bucket'
);

これはデータ取得(`select`)の際、認証済み(`authenticated`)ユーザにしか取得を許さないという設定です。
入力後右下の`Run`を押して実行しましょう。

次にアップロード時のポリシーも設定します。

create policy "Allow authenticated uploads For SharedBucket"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'shared-bucket' and
  (storage.foldername(name))[1] = 'shared-folder'
);

こちらは`select`が`insert`にかわり、アップロードするフォルダを"shared-folder"に制限することで、万が一にも共有ストレージの中の特定のフォルダ以外へのアクセスをさせない設定にしています。
こちらも同様に`Run`で実行します。

すると、`Storage`メニューの`Policies`で添付画像のように設定したポリシーが確認できるかと思います。


この状態で再度共有ストレージにアクセスし、ファイルの送信ができればOKです。
アップロードが完了するとリストで表示されます。

また、設定に問題がなければダウンロードを押したらブラウザからファイルのダウンロードが行われます。

同様にプライベートストレージのポリシーも設定しましょう。

プライベートストレージのポリシー設定

プライベートストレージ側もアップロード(`insert`)のポリシーは同じような形で作成します。

create policy "Allow authenticated uploads For PrivateBucket"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'private-bucket' and
  (storage.foldername(name))[1] = auth.uid()::text
);

ユーザID名と同じフォルダー名を作成し利用するため、フォルダ名の制限が`auth.uid()::text`となっていることに注意してください。

データ取得(`select`)側に関しては、自分がアップロードしたファイルに制限するため、ファイルのowner_idがログインユーザと同じであることを確認しています。

create policy "Individual user Access For PrivateBucket"
on storage.objects for select
to authenticated
using (
  bucket_id = 'private-bucket' and  
  auth.uid()::text = owner_id
);

こちらも共有ストレージと同様に設定すると添付画像のようになります。


プライベートストレージの実装確認

実際に利用できるかどうか試してみましょう。

2つのブラウザウィンドウを作成し、user1@example.comとuser2@example.comにそれぞれログインさせます。

どちらも共有ストレージを確認すると、先程共有ストレージのポリシー設定時にアップロードしたファイルが確認出来ます。


プライベートストレージにそれぞれアクセスすると、どちらも現状何も表示されない状態です。


まずはuser1でファイルをアップロードします。


ダッシュボード上で見ると、ユーザIDのフォルダが作成され、その中にファイルがアップロードされていることがわかります。

user2側に移りリロードしてもuser1がアップロードした画像は確認出来ません。

次にuser2でファイルをアップロードします。



すると、ダッシュボード上で別のユーザIDのフォルダが作成され、その中に画像がアップロードされたことがわかります。

このようにプライベートストレージ側も、自分のファイルが他人に見られることのない安全な状況を作ることが出来ました。

Supabaseを使うことで、セキュアなサービスも安全・簡単に開発できる

これで、シンプルなストレージサービスの作成は完了です。
この技術を応用することで、より複雑なチーム単位でのストレージ作成や、ユーザ間のファイル移動などを実装することも可能です。

プライベートなデータを扱うサービスは、開発しようと思った際、多くの人がセキュリティ周りの複雑さやリスクに尻込みしてしまいがちです。
しかしSupabaseでは、今回ご紹介したとおり、簡単にアクセス権限等が設定できるようになっていますし、
途中の工程にもあった通り、ルールの設定が行われていない場合等には警告等も発してくれます。

ぜひこのような有用なサービスを用いて、セキュリティが求められるようなアプリ開発にも挑戦してみてください。

なお、今回のgithubリポジトリはこちらとなります。

またSupabase Storageで公開されている、アクセス制限のサンプルも記載しておきます。ぜひこちらも御覧ください。

お問合せ&各種リンク

presented by



サポートしていただくと、筆者のやる気がガンガンアップします!