1 連絡・概要
- 課題2の共有リンク(学内のみ有効-OneDrive)
- 未提出の学生は、早急に提出ください。
- 採点フィードバック前に再提出した場合などは、和田宛にTeamsチャットで一報をください (作品共有も更新する関係上)。なお、再提出した場合、Teamsの仕様上、ファイル名に自動で番号が付与されますが (課題で指定しているファイル名からズレますが)、それについては問題ありません。
- 次回、第08回講義の定着確認に関連する「小テスト」を実施します (12月上旬の内容ですみません…)。
- 本日はスペシャルコンテンツとして株式会社 Affinity
Nexaの山本様からの講演があります。
- 皆さんの背後から講演風景を撮影しますが、ご了承ください (顔が写り込まないように写真撮影します)。
1.1 Supabase プロジェクトの稼働状況の確認
以下の内容は、学修単位科目の「授業時間外学習」として取り組んでください。不明点や疑問点があれば Teams で連絡ください。オンラインもしくは対面でサポートします。
前回講義で解説したように、Freeプランで作成した Supabase のプロジェクトは 7日間、非アクティブ状態が継続すると (=データベースや認証機能などに対するアクセスがないと)「paused」という状態 になってしまいます。この状態では、Supabase の API は機能しないので注意してください。
▼▼ paused になってしまった Supabase プロジェクトの例
「paused」になってしまった場合は、こちら(セクションの最後の赤枠コラム) を参照して回復させてください。
2 バックエンドにおけるアクセス制御
前回講義では、フロントエンド側で SupabaseClinet
(=@supabase/supabase-js) を利用した「アクセス制御
(ログイン機能・認証)」を実装しました。これにより、未認証のユーザ (=ログインしていないユーザ)
は /admin や /admin/posts/new などの 管理者用のページにアクセスできない仕組み を構築しました。
しかし、現状では何らかの HTTPクライアント (例えば httpYac や curl
コマンド など) を使って直接的に DELETE /api/admin/posts/xxxxxx
をリクエストすると 誰でも投稿記事を削除
できてしまいます。つまり、現状、バックエンド側では「アクセス制御ができていない状態」
(=ウェブアプリとして大きな問題がある状態) となっています。
今回講義の前半では、この問題を解決すべく バックエンド側の認証 / 認可の処理を実装 していきます。
2.1 バックエンドの認証戦略
フロントエンドのログインページ (/login) では「ID
(=メールアドレス)」と「パスワード」を使った認証
(つまり「あなたが、本当にあなたであること」の確認) をしました。
バックエンド (ウェブAPI) においても、同様に、IDとパスワードを使った認証は 可能 です。しかし、その場合、ウェブAPIにリクエストを送るたびに、ID・パスワードの添付が必要となります。HTTPS通信により暗号化は確保されますが、頻繁に認証情報を送信することはセキュリティ上のリスク となります。また、バックエンドにおいても、「IDとパスワードが正しい組み合わせであるか?」を頻繁にデータベースに問い合わせること は大きな負荷となります (DBに対する問い合わせは、処理時間上のボトルネックとなります) 。
以上のようなことから、一般的なウェブAPIでは ID と パスワード を使った認証・認可ではなく、トークン (JWT: Json Web Token) による認証・認可の仕組みが採用されます。前回講義でも触れたように、認証に関する文脈で「トークン」とは 認証済みであることを証明する電子証明書 となります。つまり「トークンを保持していること」が「認証済みのユーザであること」を保証します。
さらに、前回講義で実際に確認してもらったように、トークン (JWT) は デコードが可能、かつ、署名による検証 (内容が改竄されていないことの確認) が可能 という特徴があり、データベースに問い合わせるという負荷の大きな処理を必要とせずに デコードと署名検証という軽量な処理だけで、「ユーザ認証」と「ユーザ情報に基づく認可」が可能になります。
以下、バックエンド (=ウェブAPI) における「トークンを使用した認証・認可」の具体的な方法について解説していきます。
2.2 バックエンドプログラムの変更
記事やカテゴリの追加や編集、削除など /api/admin/ 配下の API
に対して、ログインしているユーザ (=トークンを所持しているユーザ)
からのリクエストのみを受け付けるように処理を変更していきます。
トークン (JWT) は、HTTPリクエストのヘッダの Authorizationフィールド
に設定されている前提とします (トークンを Authorizationフィールド
に設定する具体的な方法は後述)。以下は、投稿記事の新規作成API (/api/admin/posts
に対するPOSTリクエスト) に認証・認可処理を組み込んだ例 (抜粋) となります。
// 省略
import { supabase } from "@/utils/supabase"; // ◀ 追加
export const POST = async (req: NextRequest) => {
// JWTトークンの検証・認証 (失敗したら 401 Unauthorized を返す)
const token = req.headers.get("Authorization") ?? "";
const { data, error } = await supabase.auth.getUser(token);
if (error)
return NextResponse.json({ error: error.message }, { status: 401 });
// 以下、投稿記事テーブルにレコードを追加する処理 (変更なし)
try {
const requestBody: RequestBody = await req.json();
// 省略
} catch () {
// 省略
}
};まず、トークンのデコードと署名検証のために 第02行目 で SupabaseClinet
をインポートしています。第06行目 では、HTTPヘッダの
Authorization フィールドの値を取得し、変数 token
に格納しています。もし、ヘッダに Authorization
が含まれていない場合、token には空文字 ("") が格納されます。
第07行目 の supabase.auth.getUser(token) では
トークンの署名の検証 が行なわれます。もし問題 (=署名が無効だったり、トークンの期限が切れている など) があれば、戻り値の
error に「エラーオブジェクト」が格納されます。そして、第08行目の
if文 によって「401 Unauthorized」をレスポンスするようにしています
(投稿記事テーブルにレコードを追加する処理は実行されません)。
401 Unauthorized
リクエストに有効な認証情報が含まれていない、または、認証情報が無効であることを示すステータスコード
一方で、トークンの検証が成功した場合、auth.getUser(token) の戻り値の
error は null
となり、投稿記事テーブルへのレコード追加処理が実行されます。
なお、ここでは使用していませんが、auth.getUser(token) の戻り値である
data には トークン (=JWT) からデコードされた情報
が格納されます。例えば data.user.id でID (UID)、data.user.email
でメールアドレスが取得できます。これらは、Supabase の認証機能に関するテーブルのカラム
(以下の図を参照) のデータとなります (厳密に言えば
トークンが発行された時点の情報のスナップショットになります) 。
2.3 フロントエンドプログラムの変更
つづいて、ウェブAPIに対するHTTPリクエストのヘッダの Authorizationフィールド に「トークン」を付加するように
フロントエンド側のプログラム に手を加えていきます。記事の新規作成ページ
(src/app/admin/posts/new/page.tsx) に対する変更例 (抜粋) を以下に示します。
- 全体コードについてはこちらを参照してください。
// 省略
import { useAuth } from "@/app/_hooks/useAuth";
// 省略
const Page: React.FC = () => {
// 省略
const { token } = useAuth(); // トークンの取得
// 省略
// フォームの送信処理
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
try{
// 省略
if (!token) {
window.alert("予期せぬ動作:トークンが取得できません。");
return;
}
// 省略
const res = await fetch(requestUrl, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
Authorization: token, // ◀ 追加
},
body: JSON.stringify(requestBody),
});
// 省略
} catch (error) {
// 省略
}
// 省略
}
// 省略
};
export default Page;第02行目では、前回講義で作成したカスタムフック
useAuth をインポートしています。第07行目では useAuth から
token を取得しています。もし、useAuth で何らかの問題があれば token
には null が格納されるので、フォームの送信処理時に、第14行目
のif文によってアラートが表示されます。
第24行目では、HTTPリクエストのヘッダに Authorization
を追加しています。これより、HTTPリクエストのヘッダは次のようになります。
Authorization: eyJhbGciOiJIUzI1....
Content-Type: application/json
Authorization ヘッダについて
一般的な JWT の送信 (RFC
6750で定義された標準的な方式) では、次のように Authorizationフィールド に
Bearer というプレフィックス (接頭辞)
をつけます。また、それにあわせてバックエンドでは Bearer
という文字列を取り除いたうえでトークンのみを取得する文字列処理が必要になります。
Authorization: Bearer eyJhbGciOiJIUzI1....
しかし、この授業では、プレフィックス (Bearer)
関連の処理が煩雑になるので省略しています
(作法に従っていないというだけで動作や機能上の問題はありません)。
なお、Basic認証では Basic、Digest認証では Digest
というプレフィックスにつけます。
2.3.1 演習 ( 15分)
認証済みのユーザだけに記事の新規作成を「許可」するようにバックエンドとフロントエンドのプログラム (以下の2つ) を変更してください。
src/app/api/admin/posts/route.tssrc/app/admin/posts/new/page.tsx
また、実際にアプリを実行して「意図したように動作すること」を確認してください。さらに、次のようにフロントエンドから 不正なトークンを与えたリクエストを送信した場合 には、処理が拒否されることも確認してください。
const res = await fetch(requestUrl, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
Authorization: token + "a", // 不正なトークン
},
body: JSON.stringify(requestBody),
});2.3.2 演習 (宿題: 70分)
記事の「新規作成」と同様に、以下の API
についても管理者のみが実行できるようにプログラムを変更してください。また、それらの
API を呼び出すフロントエンドプログラムで、HTTPリクエストのヘッダに Authorization
フィールドを追加するようにしてください。さらに、それらの処理が機能することを実際に確認してください。
- POST /api/admin/posts
- PUT /api/admin/posts/[id]
- DELETE /api/admin/posts/[id]
- POST /api/admin/categories
- PUT /api/admin/categories/[id]
- DELETE /api/admin/categories/[id]
3 画像アップロード機能の実装
ここでは、投稿記事に画像 (CovarImage) を添付するための「ファイルアップロード機能」を実装していきます。
投稿記事の「タイトル」や「本文」などの文字列は Supabase の RDBシステム (PostgreSQL) に保存するように実装してきました。しかし、RDB は基本的に 「文字列」や「数値」などのデータ管理に特化して設計 されたものであり、「画像」や「PDF」などのバイナリファイルの保存や管理には適していません。バイナリデータを文字列に変換して保存することは技術的には可能ですが、パフォーマンスやストレージ利用効率の観点から推奨されません。
以上のことから、画像ファイルの保存 (アップロード) については、Supabase が提供する「ストレージ機能 (Supabase Storage)」を利用するように設計と実装をしていきます。
3.1 準備 (Supabaseの設定)
まずは、Supabase のウェブページ上で、ストレージ機能を利用するための準備 (=保存領域の設定とセキュティ設定) を行なっていきます。
Supabaseにアクセスして、ログインして (前回講義では GitHub連携でログインできるように設定しました)、ブログアプリ用に作成したプロジェクト (Next-Blog-App) を選択してください。
次に、以下に示すように「ストレージ機能 (Storage)」の設定画面に移動し、「New bucket」を選択してください。Supabase Storage において、Bucket (バケツ) とは、ファイルを整理・分類するための最上位の「コンテナ」になります (Windowsで言えば 「フォルダ」よりも上位概念である「ドライブ」のようなもの と考えてください)。
ダイアログ (下図参照) が表示されるので、Name of bucket
のテキストボックスに cover-image を入力してください。Bucketの名前には「Only
lowercase letters, numbers, dots, and
hyphens」、つまり、小文字、数字、ドット、ハイフンのみが使用可能です。
また、Public bucket のスイッチを「オン」にしてください。「Public bucket」にすることで、次のような特性を持った Bucket となります。
Public buckets are not protected. Users can read objects in public buckets without any authorization. Row level security (RLS) policies are still required for other operations such as object uploads and deletes.
Public buckets は保護されません。ユーザは認証なしでパブリックバケット内のオブジェクトを 読み取ること ができます。ただし、オブジェクトの「アップロード」や「削除」などの他の操作には、Row Level Security (RLS) のポリシー設定が必要です。
名前 と Public buckets の設定が完了したら「Create」ボタンを押下してください。
次に、管理者 (認証済みユーザ) から SupabaseClient を通じて画像のアップロードや更新、削除をできるようにするために RLSポリシー を設定していきます。以下のように「ポリシー設定画面」に進んでください。
「Get started quickly (クイックセットアップ)」を選択してください。
つづいて「Give users access to a folder only to authenticated users (=認証ユーザだけがフォルダにアクセスできるようにする)」を選択し、「Use this template」ボタンを押下してください。
テンプレートをカスタイマイズします。認証済みユーザには「全権限」を与えるために、次のように「SELECT」「INSERT」「UPDATE」「DELETE」の 全てのチェックボックスを「オン」 にしてください。そして「Review」のボタンを押下してください。
INSERT や UPDATE などの各操作に適用するポリシーを設定する SQL が確認できる画面に移動します。例えば「INSERT (ファイルの追加操作)」に対して適用するポリシーについては、次のようなものが提案されています。
CREATE POLICY "Give users authenticated access to folder xxxxxxx" ON storage.objects FOR INSERT TO public WITH CHECK (bucket_id = 'cover-image' AND (storage.foldername(name))[1] = 'private' AND auth.role() = 'authenticated');この SQL は、次の3つ条件をすべて満たす場合にのみ ストレージへのファイル挿入 (INSERT) を許可するというポリシーを作成するという内容になっています。
- アップロード先のバケット (bucket_id) が
cover-imageである - ファイルの保存先が
privateフォルダである - リクエストを行うユーザのロールが 認証済み (
authenticated) である
ざっくりと内容を確認したら「Save policy」ボタンを押下してください。
次のように4つのRLSポリシーが設定されていることを確認してください。なお、ポリシーを 再設定したいときは、三点リーダ から「 Delete policy」を選んで、一旦、4つとも削除 してから同じ操作を行ってください。
以上で、Supabase側における利用準備は完了です。
3.2 確認 (Supabaseの設定)
Supabase のストレージ機能が「適切に設定できていること」を確認していきます。手動で画像をアップロードして、その画像を取得する URL が得られることを確認していきます。
まず、テスト画像 (黄)をダウンロード
(=右クリックして「名前を付けてリンク先を保存」を選択)
して、デスクトップなどに cover-img-yellow.jpg という名前で保存してください。
次に Supabase のストレージ機能にアクセスして、バケット cover-image
を選択し、そこに private
というフォルダを新規作成していきます。
バケット cover-image を選択して
「Create folder」を選択して、private というフォルダを作成します。
作成した private フォルダを選択して
(ここでしっかりと選択しないと、以降の操作が狂うので注意) 、「Upload
files」をクリックして、さきほどダウンロードした画像ファイル
(cover-img-yellow.jpg) をアップロードしてください
(ドラッグアンドドロップでもアップロードできます)。
アップロードした画像を選択して、「Get URL」ボタンを押下してクリップボードにURLを取得してください。
ウェブブラウザのアドレスバーにURLを貼り付けて画像が取得できることを確認してください。この際、以下の
URL のように「cover-image というバケットの private
フォルダにアップした
cover-img-yellow.jpg」という階層的な構造のパスになっていることを確認しておいてください。
https://XXXX.supabase.co/storage/v1/object/public/cover-image/private/cover-img-yellow.jpgXXXXには、Supabase の Project ID に読み替えてください。
3.3 準備 (フロントエンド開発)
つづいて、ローカルのプロジェクトにおいて、画像をアップロードする準備をしていきます。
VSCode に戻って、「crypto-js」というライブラリをインストールしてください。これは、暗号化関連のライブラリで、アップロードする画像のハッシュ (=ファイルの固有の文字列) を作成するために使用します。
npm i crypto-js
npm i -D @types/crypto-js
次に、Supabase の Storage にアップロードしたファイル (画像) をアプリから参照できるようにプロジェクト直下の next.config.ts を次のように編集してください (第11行目から第15行目の内容を追加してください)。next.config.ts の役割と設定については第07回講義を参照してください。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: false,
images: {
remotePatterns: [
{ protocol: "https", hostname: "w1980.blob.core.windows.net" },
{ protocol: "https", hostname: "placehold.jp" },
{ protocol: "https", hostname: "images.microcms-assets.io" },
{
protocol: "https",
hostname: "**.supabase.co",
pathname: "/storage/v1/object/public/**",
},
],
},
};
export default nextConfig;また、以下のテスト画像をデスクトップなどにダウンロード (リンクを右クリックして「名前を付けてリンク先を保存」) しておいてください。画像の名前は変更せずに、そのまま使用してください。
3.4 画像アップロードのプロトタイプの実装
画像のアップロード機能は、記事の新規作成 (/admin/posts/new)
と編集 (/admin/posts/[id])
の2つのページに実装が必要になります。
しかし、既に、これらのページにはフォームや関連処理が色々と記述されているため、独立した別のページに「画像のアップロード機能」を実装し、そこで「処理の理解」と「動作確認」ができてから、それを各ページに移植していきたいと思います。
まずは src/app/playground/upload-img/page.tsx
を作成し、次に示す「画像を Supabase
のアップロードするだけシンプルなプログラム」を貼り付けてください。
"use client";
import { useState, ChangeEvent } from "react";
import { useAuth } from "@/app/_hooks/useAuth";
import { supabase } from "@/utils/supabase";
import { twMerge } from "tailwind-merge";
const Page: React.FC = () => {
const bucketName = "cover-image";
const [coverImageUrl, setCoverImageUrl] = useState<string | undefined>();
const [coverImageKey, setCoverImageKey] = useState<string | undefined>();
const { session } = useAuth();
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
setCoverImageKey(undefined); // 画像のキーをリセット
setCoverImageUrl(undefined); // 画像のURLをリセット
// 画像が選択されていない場合は戻る
if (!e.target.files || e.target.files.length === 0) return;
// 複数ファイルが選択されている場合は最初のファイルを使用する
const file = e.target.files?.[0];
// バケット内のパスを指定
const path = `private/${file.name}`;
// ファイルが存在する場合は上書きするための設定 → upsert: true
const { data, error } = await supabase.storage
.from(bucketName)
.upload(path, file, { upsert: true });
if (error || !data) {
window.alert(`アップロードに失敗 ${error.message}`);
return;
}
// 画像のキー (実質的にバケット内のパス) を取得
setCoverImageKey(data.path);
const publicUrlResult = supabase.storage
.from(bucketName)
.getPublicUrl(data.path);
// 画像のURLを取得
setCoverImageUrl(publicUrlResult.data.publicUrl);
};
// ログインしていないとき (=supabase.storageが使えない状態のとき)
if (!session) return <div>ログインしていません。</div>;
return (
<div>
<input
id="imgSelector"
type="file" // ファイルを選択するinput要素に設定
accept="image/*" // 画像ファイルのみを選択可能に設定
onChange={handleImageChange}
className={twMerge(
"file:rounded file:px-2 file:py-1",
"file:bg-blue-500 file:text-white hover:file:bg-blue-600",
"file:cursor-pointer",
)}
/>
<div className="text-sm break-all">coverImageKey : {coverImageKey}</div>
<div className="text-sm break-all">coverImageUrl : {coverImageUrl}</div>
</div>
);
};
export default Page;開発モード (npm run dev) でアプリを立ち上げて
/playground/upload-img
にアクセスして、次のような画面が表示されることを確認してください。ログインしていないときは、ログインしてください。
次に「ファイルを選択」ボタンを押下して、ファイル選択ダイアログを表示し、予めダウンロードしておいたcover-img-green.jpgを選択してください。そして、画面上の
coverImageKey と coverImageUrl
がどのような値になるか確認してください。
また、再度、「ファイルを選択」から cover-img-green.jpg (同じファイル) を選択しても問題がないことを確認してください。デベロッパーツールのコンソール出力にもエラー等がでていないことを確認してください。
また、Supabase のサイトからストレージにアクセスして、次のように cover-img-green.jpg が正常にアップロードされていることを確認してください。
3.4.1 演習
ほげほげ.pngやぴよぴよ.pngなど、名前に日本語を含む画像ファイルをアップロードしようとすると、どのようになるか確認してください。- 第23行目 の
`private/${file.name}`を`hoge/${file.name}`に変更して (つまり、保存先のフォルダをprivate以外のものに設定して) cover-img-green.jpg のアップロードを試みるとどのようになるか確認してください。試したあとは、元に戻しておいてください。 - 第27行目 の
upsert: trueをupsert: falseに変更して cover-img-green.jpg のアップロードを試みるとどのようになるか確認してください。試したあとは、元に戻しておいてください- Upsert とは Insert と Update を合体させた造語 で、当該ファイルが存在していなければ挿入 (Insert)、存在していれば更新 (Update) という意味になります。
3.5 プログラムの解説
ファイルアップロードは、第04行目 でインポートした SupabaseClient を使って 第25行目 で実行しています。
const { data, error } = await supabase.storage
.from(bucketName)
.upload(path, file, { upsert: true });from の引数には「バケットの名前 (実体は
第08行目 で "cover-image" を設定)」を与え、upload
の各引数には、次の値を与えます。
- 第1引数: バケット内のパスを与えます。先に設定した INSERT に関する
RLSポリシー に従って
private/から開始する必要があります。それにつづく名前には 日本語や特定の記号などを含めること ができません。ほげほげ.pngのようなファイルを選択してアップロードしようとするとInvalid key: private/ほげほげ.pngのようなエラーになります。private/以外の位置にアップロードしようとするとnew row violates row-level security policyのようなエラーとなります。
- 第2引数: 単体のファイルを与えます。アップロードできるファイルサイズに制限があります(詳細)。
- 第3引数: 各種オプションをオブジェクト形式で指定します。
upsert: ファイルが存在する場合に上書き更新を許可するかを設定します。
upload メソッドの戻り値は、data と error
になります。アップロードに失敗すると error
にエラーオブジェクトが格納され、第29行目 の
if文によってアラートを表示して処理を中断します。
if (error || !data) {
window.alert(`アップロードに失敗 ${error.message}`);
return;
}
// 画像のキー (実質的にバケット内のパス) を取得
setCoverImageKey(data.path);
const publicUrlResult = supabase.storage
.from(bucketName)
.getPublicUrl(data.path);
// 画像のURLを取得
setCoverImageUrl(publicUrlResult.data.publicUrl);アップロードに成功した場合、戻り値の data にオブジェクトの path
プロパティには、バケット内のパス が格納されています (基本的に
第22行目 で設定した値と同じになるはずです)。これを、ここでは
coverImageKey として状態 (State) にセットしています。
そして、この値 (data.path) を引数に与えて getPublicUrl
メソッドを実行すると、その画像にアクセスするための URL を得ることができます。この URL
については Supabase の都合で変更になる可能性
があるため、getPublicUrl を経由して取得するようにしてください。
また、URLが変更になる可能性を考慮し、データベースにも coverImageUrl ではなく coverImageKey のほうを保存するように変更してください (あとで)。
補足
supabase.storage.from("XXX").getPublicUrl("YYY") は
ログインしていない状態 でも呼び出すことができます。つまり、/ や
/posts/[id] などのログイン不要のページでも使用することができます。
3.6 画像アップロードのプロトタイプの改良1
アップロードしようする画像ファイルの名前に 日本語や特定記号が含まれているとアップロードできない問題 (処理が失敗する問題) を解決していきます。
解決法のひとつとして、UUIDv を生成して、ファイルネームを UUID に置き換えるという方法があります。しかし、ここでは、ファイルのハッシュ値を計算して、それをファイルネームにしたいと思います。ハッシュ値を使うことで、同じファイルに対しては、同値が得られるので 重複アップロードを防ぐことができ、ストレージの効率的な利用 が可能になります。
(プロンプト例)
ファイルのハッシュ値とは何ですか。また、クラウドストレージにアップする際、日本語を含むファイルネームは適さないので、ハッシュ値を計算して、それをファイルネームとして使用すると説明されました。噛み砕いて解説してください。
ファイルネームをハッシュ値に置き換えてアップロードするように改良したプログラムを以下に示します。
"use client";
import { useState, ChangeEvent } from "react";
import { useAuth } from "@/app/_hooks/useAuth";
import { supabase } from "@/utils/supabase";
import { twMerge } from "tailwind-merge";
import CryptoJS from "crypto-js"; // ◀ 追加
// ファイルのMD5ハッシュ値を計算する関数
const calculateMD5Hash = async (file: File): Promise<string> => {
const buffer = await file.arrayBuffer();
const wordArray = CryptoJS.lib.WordArray.create(buffer);
return CryptoJS.MD5(wordArray).toString();
};
const Page: React.FC = () => {
const bucketName = "cover-image";
const [coverImageUrl, setCoverImageUrl] = useState<string | undefined>();
const [coverImageKey, setCoverImageKey] = useState<string | undefined>();
const { session } = useAuth();
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
setCoverImageKey(undefined); // 画像のキーをリセット
setCoverImageUrl(undefined); // 画像のURLをリセット
// 画像が選択されていない場合は戻る
if (!e.target.files || e.target.files.length === 0) return;
// 複数ファイルが選択されている場合は最初のファイルを使用する
const file = e.target.files?.[0];
// ファイルのハッシュ値を計算
const fileHash = await calculateMD5Hash(file); // ◀ 追加
// バケット内のパスを指定
const path = `private/${fileHash}`; // ◀ 変更
// ファイルが存在する場合は上書きするための設定 → upsert: true
const { data, error } = await supabase.storage
.from(bucketName)
.upload(path, file, { upsert: true });
if (error || !data) {
window.alert(`アップロードに失敗 ${error.message}`);
return;
}
// 画像のキー (実質的にバケット内のパス) を取得
setCoverImageKey(data.path);
const publicUrlResult = supabase.storage
.from(bucketName)
.getPublicUrl(data.path);
// 画像のURLを取得
setCoverImageUrl(publicUrlResult.data.publicUrl);
};
// ログインしていないとき (=supabase.storageが使えない状態のとき)
if (!session) return <div>ログインしていません。</div>;
return (
<div>
<input
id="imgSelector"
type="file" // ファイルを選択するinput要素に設定
accept="image/*" // 画像ファイルのみを選択可能に設定
onChange={handleImageChange}
className={twMerge(
"file:rounded file:px-2 file:py-1",
"file:bg-blue-500 file:text-white hover:file:bg-blue-600",
"file:cursor-pointer",
)}
/>
<div className="text-sm break-all">coverImageKey : {coverImageKey}</div>
<div className="text-sm break-all">coverImageUrl : {coverImageUrl}</div>
</div>
);
};
export default Page;第06行目 で、ハッシュ値を計算するためのライブラリをインポートしています。このライブラリは準備 (フロントエンド開発)のセクションでインストールしました。
第09行目 から 第13行目
で、ファイルを引数に受け取り、戻り値としてハッシュ値 (文字列) を返す関数
calculateMD5Hash を定義しています。内容は理解する必要はありません
(割り切ってブラックボックスとして使いましょう)。なお、ここでは page.tsx
のなかに関数を定義していますが、実際には src/app/_utils/calculateMD5Hash.ts
などに分離することが望ましいです。
第31行目 で、上記の関数をコールしてハッシュ値を変数 fileHash
に格納しています。そして、そのハッシュ値を使って 第33行目
でバケット内のパスを設定しています。
改良後のプログラムを実行して、ファイルネームに日本語を含む画像でもエラーにならずアップロードできること を確認してください (Supabaseのサイトでも、問題なくアップロードされていることを確認してください)。また、同じファイルであれば 同じハッシュ値になること を確認してください。
なお、ファイルアップロードの際に、MIMEタイプ (=ファイルの種類を示す識別子) が適切に設定されるため、ファイルネームから「拡張子」が失われても問題なく機能します。
(プロンプト例)
MIMEタイプとは何ですか。拡張子との違いを教えてください。
以上で、画像のアップロードとプレビューの機能の単体実装が完了しました。あとは、これを投稿記事の新規作成ページ
(/admin/posts/new) と編集ページ (/admin/posts/[id])
に組み込めば完了となります。
ファイルセレクタの外観設定
<input type="file"/> の外観 (スタイル) を詳細に設定したいときは…
<input type="file" ...>にhidden属性を与えて「非表示」にする。- 新たにボタン要素
<button>を追加し、外観をカスタマイズする。 - このボタンが押下されたときに プログラムを使って非表示になっているinput要素に対してクリックイベント を発生させる。
という方法をとります。詳しくはこちらを参照してください。
4 宿題
投稿記事の「新規作成ページ」と「編集ページ」に画像アップロード機能を組み込み、次の点に注意しながらブログアプリを完成させてください。
- DBに保存する値を
coverImageURLからcoverImageKeyに変更してください。schema.prismaの編集が必要になります。編集後は前回講義の内容 に従って、テーブルの削除、再作成、RLS設定を実行してください。npx prisma generateの実行後は、VSCodeの再読込み (Ctrl+Shift+Pでコマンドパレットを開いて>Developer: Reload Windowを入力して「ウィンドウの再読み込み」を選択) も忘れずに実行してください。- 関連する型についても変更してください。プロジェクト全体を対象に
coverImageURLを検索して、内容を確認しながら修正してください。
- 投稿記事の詳細画面 (
/posts/[id]) では、coverImageKeyからcoverImageUrlを取得して画像を表示するようにしてください。- 画像を表示するときは
supabase.storage.from(Bucket).getPublicUrl(coverImageKey)を使って、都度、URLを取得して、それを使用してください。
- 画像を表示するときは
- 適宜、
npm run buildを実行してエラーやワーニングが生じていないことを確認してください。
なお、課題3 (最終課題) は オリジナルのウェブアプリ (=このブログアプリを土台に独自要素を追加したもので可) を考案・開発・デプロイしてもらう内容となります。なお、就活用のポートフォリオでは、実装技術に加えて、課題発見能力 (開発の着想や動機) や課題解決能力 (独創性やユニークな着眼点) なども評価されます。単純に既成品をクローンしただけのアプリでは、あまり評価されないそうです…。
5 次回講義
次回の講義では、完成したブログアプリを Vercel というクラウドプラットフォームにデプロイする方法について紹介します (このブログアプリは、バックエンド処理を必要とするので GitHubPages にはデプロイできません)。