以前、認証付きのストレージサービスの作り方をご紹介しました。
しかしながら、もしこれをリリースして世の中に出した場合、解決しなければ行けない問題がいくつかあります。
例えば『サイズ制限』がついていないため、ユーザーがスマートフォンで撮影した無圧縮の画像をそのままアップロードするかもしれません。
更に、『投稿制限』もついていないため、一人で何点でも投稿が出来てしまいます。
このままではストレージや処理を圧迫するだけでなく、ユーザービリティも損なってしまうでしょう。
なので今回は
- 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株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。
ぜひこちらもご覧ください!
- Next.js + SupabaseでTodoアプリ作成 CRUDの基本を学ぼう
- FirebaseとSupabaseの機能、料金、セキュリティ、AI観点などを含めて徹底比較
- Next.js とSupabaseで求人マッチングアプリを作る①
- 素早くアプリを作りたい! Vercel+Supabaseでプロジェクト開発
- Supabaseの機能一覧・解説 オープンソースのFirebase代替mBaaS
- Supabase CLIコマンド一覧
- Supabase RealtimeのBroadcast, Presenceを利用したキャンバス共有アプリを作る
- Supabaseのローカル開発環境を構築して開発体験を向上させつつ料金も節約
- Next.js + Supabaseでリアルタイムチャットを作ろう
- Next.js + Supabaseでソーシャルログインを実装する方法
- Next.js+Supabaseで認証機能を実装しよう【コード付き完全ガイド】
- Supabase + Next.jsで画像投稿アプリを最適化する(画像圧縮、ファイルサイズ制限、ファイルのアップロード数制限)
- OpenAI Embeddings API+ Supabase Vector Database + Next.jsでベクトル検索を実装する
- Supabase + AWS CognitoでAmplifyを利用せずサードパーティ認証する
- Supabaseでデータ更新があった時にSlack通知を送る
- Supabase Branchingでプレビュー環境を手に入れて開発体験を向上する
- Next.js + SupabaseでGraphQLを利用する方法
- SupabaseのEdge FunctionsとSendGridでメールを一斉送信する
- Supabaseで匿名認証とアカウントの本登録への昇格の方法
- Next.js + SupabaseでStorageを利用した画像投稿アプリ作成
- Next.js + SupabaseでRLSを利用して安全なアプリを作ろう。
- Next.js + SupabaseでAuth + Storageのストレージサービスを作る方法
- Next.jsとSupabaseで認証機能ありのリアルタイムチャットを作成する。
- Next.jsとSupabaseで認証つきチャットアプリを作成する(SNS風UI)
- Next.js + SupabaseでSMS認証を作成する方法
- Next.jsとSupabaseで全文検索を実装する方法
- Next.jsとSupabaseで作成したチャットアプリにお知らせ機能を実装する
- Nextjs + SupabaseでSupabaseのStorage Image Transformationsを利用する方法
- Supabase vs Neonの比較。機能や料金、セキュリティ、AIを徹底比較
- Supabaseのセルフホスティングをできるだけ安く行う方法
- Supabase + Cognitoの連携で外部連携を試す
- Supabase CLIのTesting機能を利用する
- Supabaseを利用する際に設定しておきたい項目
お問合せ&各種リンク
- お問合せ:GoogleForm
- ホームページ:https://libproc.com
- 運営会社:TodoONada株式会社
- Twitter:https://twitter.com/Todoonada_corp
- Instagram:https://www.instagram.com/todoonada_corp/
- Youtube:https://www.youtube.com/@todoonada_corp/
- Tiktok:https://www.tiktok.com/@todoonada_corp
presented by