Supabase + Next.jsで画像投稿アプリを最適化する(画像圧縮、ファイルサイズ制限、ファイルのアップロード数制限)

以前、認証付きのストレージサービスの作り方をご紹介しました。

しかしながら、もしこれをリリースして世の中に出した場合、解決しなければ行けない問題がいくつかあります。
例えば『サイズ制限』がついていないため、ユーザーがスマートフォンで撮影した無圧縮の画像をそのままアップロードするかもしれません。
更に、『投稿制限』もついていないため、一人で何点でも投稿が出来てしまいます。
このままではストレージや処理を圧迫するだけでなく、ユーザービリティも損なってしまうでしょう。

なので今回は

  • 10MBのファイルサイズ制限
  • 1分間に10ファイルのアップロード制限
  • 画像ファイルをアップロード前に圧縮する

といった画像投稿アプリに必要な機能の実装を行いたいと思います。

目次

Supabase事前準備

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

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

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

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

②:ユーザ作成

`Authentication`を開き、ユーザを作ります。
メールアドレスとパスワードが必要ですが、メールアドレスはテスト用として`example.com`のものを利用します。
このアドレスであれば例示用で利用でき、第三者への悪影響がありません。
※詳しくはこちらを見ていただければと思います。

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

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

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

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

③:ストレージ作成

`Storage`を開き、Bucketを作成します。
左上の`New Bucket`を押し、shared-bucketという名前で作成しましょう。

  • Public bucketのチェックはON
  • 追加設定から`Restrict file upload size for bucket`をON
    • 1000000bytes(10MB)以上のファイルをアップロードできないよう制限します。
  • `Allowed MIME types`をimage/*に(許可するファイルを画像に制限します。)

のような設定で作成します(添付画像参照)

これで画像のファイルサイズ制限は対応できました。

④:ストレージのポリシー作成

作成したBucketを

  • ユーザのみアップロードできる
  • 画像へのアクセスはURLを知っていれば誰でもできる

状態にするためポリシーを作成します。

Supabaseのダッシュボード→`Storage``Policies`をクリックします。
各ポリシー設定を下記のようにします。

`shared-bucket`

`Other policies under storage.objects`

`Policies under storage.buckets`

Next.jsの実装

不要ファイルの削除・それに伴う対応

まず、元リポジトリにあるプライベートストレージ関係の実装は不要なため削除します。

削除するファイル・フォルダは以下です

  • app/privateStorageフォルダ
  • components/privateStorageApp.tsx

また、ヘッダーのプライベートストレージを削除するため、
`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>
                <ModalCore modalType={ModalType.SignIn}></ModalCore>
              </div>
              <div>
                <ModalCore modalType={ModalType.SignUp}></ModalCore>
              </div>
            </>
          )}
        </nav>
      </div>
    </header>
  )
}

export default Navigation

共有ストレージの変更

共有ストレージ側の実装を今回の要件に合わせて変更します。
また、画像圧縮を行うため、クライアント側で画像圧縮を行うことができる`compressorjs`をインストールします。
https://www.npmjs.com/package/compressorjs

npm install compressorjs

(Edge Functionsでアップロードした画像を圧縮する方式も考えましたが二度手間なので上げるまえに圧縮するのが良いと思います。サーバ側で画像圧縮に使えるライブラリで手軽に使えるものがあまりないのも理由の一つです。)

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

export default function ImageApp() {
  const public_url =
    "https://{projectID}.supabase.co/storage/v1/object/public/shared-bucket/shared-folder/";
  const supabase = createClientComponentClient();
  const [urlList, setUrlList] = useState<string[]>([]);
  const [loadingState, setLoadingState] = useState("hidden");

  const diffOverMinute = (timestamp1: Date, timestamp2: Date) => {
    const difference = timestamp1.getTime() - timestamp2.getTime();

    // 差が60000ミリ秒(= 1分)以上であるか確認
    if (difference >= 60000) {
      return true;
    } else {
      return false;
    }
  };

  const limitUploadCount = async () => {
    const { data, error } = await supabase.storage
      .from("shared-bucket")
      .list("shared-folder", {
        limit: 10,
        offset: 0,
        sortBy: { column: "created_at", order: "desc" },
      });

    if (error) {
      console.log(error);
      return;
    }

    if (data.length == 10) {
      const isDiffOverMinute = diffOverMinute(
        new Date(data[0].created_at),
        new Date(data[9].created_at)
      );

      return isDiffOverMinute;
    } else {
      return true;
    }
  };

  const listAllImage = async () => {
    const tempUrlList: string[] = [];
    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;
    }

    for (let index = 0; index < data.length; index++) {
      if (data[index].name != ".emptyFolderPlaceholder") {
        tempUrlList.push(data[index].name);
      }
    }
    setUrlList(tempUrlList);
    setLoadingState("hidden");
  };

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

  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 isDiffOverMinute = await limitUploadCount();
    // 直近1分間に画像を10ファイル以上アップロードさせない
    if (!isDiffOverMinute) {
      alert(
        "画像ファイルのアップロード制限がかかっています。時間をあけてアップロードしてください"
      );
      return;
    }

    if (file!!.type.match("image.*")) {
      // 画像圧縮
      new Compressor(file!!, {
        quality: 0.5,

        async success(result) {
          const fileExtension = result.name.split(".").pop();
          const { error } = await supabase.storage
            .from("shared-bucket")
            .upload(`shared-folder/${uuidv4()}.${fileExtension}`, result);
          if (error) {
            alert("エラーが発生しました:" + error.message);
            return;
          }
          setFile(undefined);
          await listAllImage();
        },
        error(err) {
          console.log(err.message);
        },
      });
    } else {
      alert("画像ファイル以外はアップロード出来ません。");
    }
  };
  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"
          accept="image/*"
          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="flex flex-wrap w-full">
          {urlList.map((item, index) => (
            <li className="w-1/4 h-auto p-1" key={item}>
              <a
                className="hover:opacity-50"
                href={public_url + item}
                target="_blank"
              >
                <img
                  className="object-cover max-h-32 w-full"
                  src={public_url + item}
                />
              </a>
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

まず、このURLですがこちらは各環境に合わせて`shared-folder`までのアドレスを入力してください

 const public_url =
    "https://{projectID}.supabase.co/storage/v1/object/public/shared-bucket/shared-folder/";

画像圧縮

画像圧縮からアップロードまではこちらで対応しています。
今回圧縮後のクオリティを決め打ちで0.5(半分)にしています。

// 画像圧縮
new Compressor(file!!, {
    quality: 0.5,
    
    async success(result) {
      const fileExtension = result.name.split(".").pop();
      const { error } = await supabase.storage
        .from("shared-bucket")
        .upload(`shared-folder/${uuidv4()}.${fileExtension}`, result);
      if (error) {
        alert("エラーが発生しました:" + error.message);
        return;
      }
      setFile(undefined);
      await listAllImage();
    },
    error(err) {
      console.log(err.message);
    },
});

アップロード数制限

直近10個のファイルを取得し、1個目と10個目の作成日時を比較。
その時間の差分が1分以上かそうでないかで判別し、1分に10個以上の画像をアップロードさせない仕組みを作成しています。

const diffOverMinute = (timestamp1: Date, timestamp2: Date) => {
    const difference = timestamp1.getTime() - timestamp2.getTime();

    // 差が60000ミリ秒(= 1分)以上であるか確認
    if (difference >= 60000) {
      return true;
    } else {
      return false;
    }
  };

const limitUploadCount = async () => {
    const { data, error } = await supabase.storage
      .from("shared-bucket")
      .list("shared-folder", {
        limit: 10,
        offset: 0,
        sortBy: { column: "created_at", order: "desc" },
      });
    
    if (error) {
      console.log(error);
      return;
    }
    
    if (data.length == 10) {
      const isDiffOverMinute = diffOverMinute(
        new Date(data[0].created_at),
        new Date(data[9].created_at)
      );
    
      return isDiffOverMinute;
    } else {
      return true;
    }
};

const isDiffOverMinute = await limitUploadCount();
    // 直近1分間に画像を10ファイル以上アップロードさせない
    if (!isDiffOverMinute) {
      alert(
        "画像ファイルのアップロード制限がかかっています。時間をあけてアップロードしてください"
      );
      return;
    }

挙動の確認

では実際に動かして画像投稿アプリのファイルサイズ制限、画像の圧縮、1分間のファイルアップロード数の制限が動いているか確認しましょう。

まず、下記コマンドでアプリを起動します

npm run dev

user1@example.comでログインすると下記のように`共有ストレージ`にアクセスできるようになります。

`共有ストレージ`をクリックして開いておきましょう。

ファイルサイズ制限

ファイルサイズ制限が機能している確認を行いますが、画像圧縮が入っている関係上、結構大きめのサイズでないと確認が難しいため、下記のような絶対に圧縮しても10MB以下にならなそうな画像を見つけましょう。
https://esahubble.org/media/archives/images/publicationtiff10k/heic1901a.tif
※179MBの画像です。
※拡張子がtifのままだと利用できないため、pngやjpgに変換する必要があります。

こちらの画像をアップロードしようとすると下記のようにサイズ制限に引っかかった旨のアラートが表示されます。

画像の圧縮

こちらの2.2MBの画像をアップロードして画像サイズが縮小されているか確認してみます。

送信した後、Supabaseのストレージへアップロードされた画像を見ると、2.2MB→1.85MBに縮小されていることがわかります。

ファイルアップロード数の制限

一つずつ画像をアップロードしていると面倒なので、Supabaseのダッシュボードから直接アップロードします。
ファイルを10個まとめて選択し、ストレージへドラッグアンドドロップしましょう。

この状態でファイルをアプリからアップロードしようとすると、下記のように時間を空けてアップロードすることを促すアラートが出ます。

こちらで今回の実装ができていることが確認できました。
画像投稿アプリは突き詰めるとさらに考慮すべき部分がありますが、シンプルな要件であればSupabaseや既存のJSのライブラリを利用してサクッと実装できるため、試してみるとよいかもしれません。

その他参考資料など

今回のgithubはこちらになります。

またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。
ぜひこちらもご覧ください!

SupabaseのTIPSリンク集

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次