2024-3I プログラミング3 第11回 講義資料

2025年01月16日(木)1・2時限

1 連絡・概要

1.1 Supabase の状態確認

前回講義で解説したように、Freeプランで作成した Supabase のプロジェクトは 7日間、非アクティブ状態が継続すると (=データベースや認証機能などに対するアクセスがないと)「paused」という状態 になってしまいます。この状態では、Supabase の API は機能しないので注意してください。

「paused」になってしまった場合は、こちら(セクションの最後の赤枠コラム) を参照して回復させてください。

2 バックエンドにおけるアクセス制御

前回講義では、フロントエンド側で SupabaseClinet (=@supabase/supabase-js) を利用した「アクセス制御 (ログイン機能・認証)」を実装しました。これにより、未認証のユーザ (=ログインしていないユーザ) は /admin/admin/posts/new などの 管理者用のページにアクセスできない仕組み を構築しました。

しかし、現状では何らかの HTTPクライアント (例えば ThunderClinetcurl コマンド など) を使って直接的に 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 {
    // 省略
  } catch () {
    // 省略
  }
};

まず、トークンのデコードと署名検証のために 第02行目 で SupabaseClinet をインポートしています。第06行目 では、HTTPヘッダの Authorization フィールドの値を取得し、変数 token に格納しています。もし、ヘッダに Authorization が含まれていない場合、token には空文字 ("") が格納されます。

第07行目supabase.auth.getUser(token) では トークンの署名の検証 が行なわれます。もし問題 (=署名が無効だったり、トークンの期限が切れている など) があれば、戻り値の error にエラーオブジェクトが格納されます。そして、第08行目の if文 によって「401 Unauthorized」をレスポンスするようにしています (記事の追加処理は実行されません)。

401 Unauthorized

リクエストに有効な認証情報が含まれていない、または、認証情報が無効であることを示すステータスコード

一方で、トークンの検証が成功した場合、auth.getUser(token) の戻り値の errornull となり、投稿記事テーブルへのレコード追加処理が実行されます。

なお、ここでは使用していませんが、auth.getUser(token) の戻り値である data には トークン (=JWT) からデコードされた情報 が格納されます。例えば data.user.id でID (UID)、data.user.email でメールアドレスが取得できます。これらは、Supabase の認証機能に関するテーブルのカラム (以下の図を参照) のデータとなります (厳密に言えば トークンが発行された時点の情報のスナップショットになります) 。

img

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つ) を変更してください。

また、実際にアプリを実行して「意図したように動作すること」を確認してください。さらに、次のようにフロントエンドから 不正なトークンを与えたリクエストを送信した場合 には、処理が拒否されることも確認してください。

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 フィールドを追加するようにしてください。さらに、それらの処理が機能することを実際に確認してください。

3 画像アップロード機能の実装

ここでは、投稿記事に画像 (CovarImage) を添付するための「ファイルアップロード機能」を実装していきます。

投稿記事の「タイトル」や「本文」などの文字列は Supabase の RDBシステム (PostgreSQL) に保存するように実装してきました。しかし、RDB は基本的に 「文字列」や「数値」などのデータ管理に特化して設計 されたものであり、「画像」や「PDF」などのバイナリファイルの保存や管理には適していません。バイナリデータを文字列に変換して保存することは技術的には可能ですが、パフォーマンスやストレージ利用効率の観点から推奨されません。

以上のことから、画像ファイルの保存 (アップロード) については、Supabase が提供する「ストレージ機能 (Supabase Storage)」を利用するように設計と実装をしていきます。

3.1 準備 (Supabaseの設定)

まずは、Supabase のウェブページ上で、ストレージ機能を利用するための準備 (=保存領域の設定とセキュティ設定) を行なっていきます。

Supabaseにアクセスして、ログインして (前回講義では GitHub連携でログインできるように設定しました)、ブログアプリ用に作成したプロジェクト (Next-Blog-App) を選択してください。

img

次に、以下に示すように「ストレージ機能 (Storage)」の設定画面に移動し、「New bucket」を選択してください。Supabase Storage において、Bucket (バケツ) とは、ファイルを整理・分類するための最上位の「コンテナ」になります (Windowsで言えば 「フォルダ」よりも上位概念である「ドライブ」のようなもの と考えてください)。

img

ダイアログ (下図参照) が表示されるので、Name of bucket のテキストボックスに cover_image を入力してください。Bucketの名前には「Only lowercase letters, numbers, dots, and hyphens」、つまり、小文字、数字、ドット、ハイフンのみが使用可能です。

アンダーバーが使用できなくなりました。cover-image に読み替えてください。

また、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) のポリシー設定が必要です。

img

名前 と Public buckets の設定が完了したら「Save」ボタンを押下してください。

次に、管理者 (認証済みユーザ) から SupabaseClient を通じて画像のアップロードや更新、削除をできるようにするために RLSポリシー を設定していきます。以下のように「ポリシー設定画面」に進んでください。

img

Get started quickly (クイックセットアップ)」を選択してください。

img

つづいて「Give users access to a folder only to authenticated users (認証ユーザーだけがフォルダにアクセスできるようにする)」を選択し、「Use this template」ボタンを押下してください。

img

テンプレートをカスタイマイズします。認証済みユーザには「全権限」を与えるために、次のように「SELECT」「INSERT」「UPDATE」「DELETE」の 全てのチェックボックスを「オン」 にしてください。そして「Review」のボタンを押下してください。

img

INSERT や UPDATE などの各操作に適用するポリシーを設定する SQL が確認できる画面に移動します。例えば「INSERT (ファイルの追加操作)」に対して適用するポリシーについては、次のようなものが提案されています。

CREATE POLICY "Give users authenticated access to folder 2upqz7_0" 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) を許可するというポリシーを作成するという内容になっています。

ざっくりと内容を確認したら「Save policy」ボタンを押下してください。

img

次のように4つのRLSポリシーが設定されていることを確認してください。なお、ポリシーを 再設定したいときは、三点リーダ から「 Delete」を選んで、一旦、4つとも削除 してから同じ操作を行ってください。

img

以上で、Supabase側における利用準備は完了です。

3.2 確認 (Supabaseの設定)

Supabase のストレージ機能が「適切に設定できたこと」を確認していきます。手動で画像をアップロードして、その画像を取得する URL が得られることを確認していきます。

まず、テスト画像 (黄)をダウンロード (右クリックして「名前を付けてリンク先を保存」を選択) して、デスクトップなどに cover-img-yellow.jpg という名前で保存してください。

次に Supabase のストレージ機能にアクセスして、バケット cover_image を選択し、そこに private というフォルダを新規作成してください。

img

作成した private フォルダを選択して、「Upload files」をクリックして、さきほどダウンロードした画像ファイル (cover-img-yellow.jpg) をアップロードしてください (ドラッグアンドドロップでもアップロードできます)。

img

アップロードした画像を選択して、「Get URL」ボタンを押下してクリップボードにURLを取得してください。

img

ウェブブラウザのアドレスバーにURLを貼り付けて画像が取得できることを確認してください。この際、以下の URL のように「cover_image というバケットの private フォルダにアップした cover-img-yellow.jpg」という階層的な構造のパスになっていることを確認しておいてください。

3.3 準備 (フロントエンド開発)

つづいて、ローカルのプロジェクトにおいて、画像をアップロードする準備をしていきます。

VSCode に戻って、「crypto-js」というライブラリをインストールしてください。これは、暗号化関連のライブラリで、アップロードする画像のハッシュ (=ファイルの固有の文字列) を作成するために使用します。

npm i crypto-js
npm i -D @types/crypto-js

次に、Supabase の Storage にアップロードしたファイル (画像) をアプリから参照できるようにプロジェクト直下の next.config.mjs を次のように編集してください (第11行目から第15行目の内容を追加してください)。next.config.mjs の役割と設定については第07回講義を参照してください。

/** @type {import('next').NextConfig} */
const nextConfig = {
  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";

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}
      />
      <div className="break-all text-sm">coverImageKey : {coverImageKey}</div>
      <div className="break-all text-sm">coverImageUrl : {coverImageUrl}</div>
    </div>
  );
};

export default Page;

開発モード (npm run dev) でアプリを立ち上げて /playground/upload-img にアクセスして、次のような画面が表示されることを確認してください。ログインしていないときは、ログインしてください。

img

次に「ファイルを選択」ボタンを押下して、ファイル選択ダイアログを表示し、予めダウンロードしておいたcover-img-green.jpgを選択してください。そして、画面上の coverImageKeycoverImageUrl がどのような値になるか確認してください。

また、再度、「ファイルを選択」から cover-img-green.jpg (同じファイル) を選択しても問題がないことを確認してください。デベロッパーツールのコンソール出力にもエラー等がでていないことを確認してください。

img

また、Supabase のサイトからストレージにアクセスして、次のように cover-img-green.jpg が正常にアップロードされていることを確認してください。

img

3.4.1 演習

3.5 プログラムの解説

ファイルアップロードは、第04行目 でインポートした SupabaseClient を使って 第24行目 で実行しています。

const { data, error } = await supabase.storage
  .from(bucketName)
  .upload(path, file, { upsert: true });

from の引数には「バケットの名前 (実体は 第07行目"cover_image" を設定)」を与え、upload の各引数には、次の値を与えます。

upload メソッドの戻り値は、dataerror になります。アップロードに失敗すると error にエラーオブジェクトが格納され、第28行目 の 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 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}
      />
      <div className="break-all text-sm">coverImageKey : {coverImageKey}</div>
      <div className="break-all text-sm">coverImageUrl : {coverImageUrl}</div>
    </div>
  );
};

export default Page;

第05行目 で、ハッシュ値を計算するためのライブラリをインポートしています。このライブラリは準備 (フロントエンド開発)のセクションでインストールしました。

第08行目 から 第12行目 で、ファイルを引数に受け取り、戻り値としてハッシュ値 (文字列) を返す関数 calculateMD5Hash を定義しています。内容は理解する必要はありません (割り切ってブラックボックスとして使いましょう)。なお、ここでは page.tsx のなかに関数を定義していますが、実際には src/app/_utils/calculateMD5Hash.ts などに分離することが望ましいです。

第30行目 で、上記の関数をコールしてハッシュ値を変数 fileHash に格納しています。そして、そのハッシュ値を使って 第32行目 でバケット内のパスを設定しています。

改良後のプログラムを実行して、ファイルネームに日本語を含む画像でもエラーにならずアップロードできること を確認してください (Supabaseのサイトでも、問題なくアップロードされていることを確認してください)。また、同じファイルであれば 同じハッシュ値になること を確認してください。

img

なお、ファイルアップロードの際に、MIMEタイプ (=ファイルの種類を示す識別子) が適切に設定されるため、ファイルネームから「拡張子」が失われても問題なく機能します。

img

(プロンプト例)

MIMEタイプとは何ですか。拡張子との違いを教えてください。

3.7 画像アップロードのプロトタイプの改良2

次にアップロードした画像を プレビュー表示 できるように改良していきます。画像の表示には Imageコンポーネント を使用し、その src 属性に coverImageUrl を設定します。

実装例を以下に示します。第06行目 の Imageコンポーネントのインポートと、第22行目 から 第32行目 にかけての JSX要素の追加 が主な変更点となります。

なお、外部サイトの画像を表示する際には準備 (フロントエンド開発)で行なったように next.config.mjs を適切に設定する必要があります。

"use client";
import { useState, ChangeEvent } from "react";
import { useAuth } from "@/app/_hooks/useAuth";
import { supabase } from "@/utils/supabase";
import CryptoJS from "crypto-js";
import Image from "next/image"; // ◀ 追加
// 省略
const Page: React.FC = () => {
  // 省略
  return (
    <div>
      <input
        id="imgSelector"
        type="file" // ファイルを選択するinput要素に設定
        accept="image/*" // 画像ファイルのみを選択可能に設定
        onChange={handleImageChange}
      />
      <div className="break-all text-sm">coverImageKey : {coverImageKey}</div>
      <div className="break-all text-sm">coverImageUrl : {coverImageUrl}</div>
      {/* ▼ 追加 ▼ */}
      {coverImageUrl && (
        <div className="mt-2">
          <Image
            className="w-1/2 border-2 border-gray-300"
            src={coverImageUrl}
            alt="プレビュー画像"
            width={1024}
            height={1024}
            priority
          />
        </div>
      )}
    </div>
  );
};

export default Page;

ファイルを編集し、次のように アップロードした画像の「プレビュー表示」ができること を確認してください。

img

第24行目 のCSSクラス設定で w-1/2 を設定しているため、プレビュー画像はコンテナ幅の 1/2 サイズに縮小されて表示されています。

3.8 画像アップロードのプロトタイプの改良3

次に、ファイル選択ダイアログを開くための「ボタンの外観」をカスタマイズする方法について解説していきます。

現在、画面に表示されている「ファイルを選択」というボタンはブラウザ固有のUIであり、CSSによる外観の変更 ができません。例えば、以下のように CSSクラス を設定しても、ボタンの外観は変更されません。実際に試してみてください。

<input
  id="imgSelector"
  type="file"
  accept="image/*"
  className="rounded-md bg-indigo-500 px-3 py-1 text-white" // ◀ 追加
/>

ボタンの外観を自由にカスタマイズするためには、以下のような工夫が必要となります。

  1. <input type="file" ...>hidden 属性を与えて「非表示」にする。
  2. 新たにボタン要素 <button> を追加し、外観をカスタマイズする。
  3. このボタンが押下されたときに プログラムを使って非表示になっているinput要素に対してクリックイベント を発生させる。

これにより、以下のような画面を構成できます。

img

具体的なコードを以下に示します。

プログラムを使って <input type="file" ...> のクリックイベントを発生させるために useRed というフック (第02行目でインポート) を利用することがポイントになります。

"use client";
import { useState, ChangeEvent, useRef } from "react"; // ◀ 変更
import { useAuth } from "@/app/_hooks/useAuth";
import { supabase } from "@/utils/supabase";
import CryptoJS from "crypto-js";
import Image from "next/image";

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 hiddenFileInputRef = useRef<HTMLInputElement>(null); // ◀ 追加

  const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
    setCoverImageKey(undefined);
    setCoverImageUrl(undefined);

    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}`;
    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);
    setCoverImageUrl(publicUrlResult.data.publicUrl);
  };

  if (!session) return <div>ログインしていません。</div>;

  return (
    <div>
      <input
        id="imgSelector"
        type="file"
        accept="image/*"
        onChange={handleImageChange}
        hidden={true} // ◀ 追加 (非表示に設定)
        ref={hiddenFileInputRef} // ◀ 追加 (参照を設定)
      />
      {/* ▼ 追加 ▼ */}
      <button
        // 参照を経由してプログラム的にクリックイベントを発生させる
        onClick={() => hiddenFileInputRef.current?.click()}
        type="button" 
        className="rounded-md bg-indigo-500 px-3 py-1 text-white"
      >
        ファイルを選択
      </button>
      {/* ▲ 追加 ▲ */}
      <div className="break-all text-sm">coverImageKey : {coverImageKey}</div>
      <div className="break-all text-sm">coverImageUrl : {coverImageUrl}</div>
      {coverImageUrl && (
        <div className="mt-2">
          <Image
            className="w-1/2 border-2 border-gray-300"
            src={coverImageUrl}
            alt="プレビュー画像"
            width={1024}
            height={1024}
            priority
          />
        </div>
      )}
    </div>
  );
};

export default Page;

以上で、画像のアップロードとプレビューの機能の単体実装が完了しました。あとは、これを投稿記事の新規作成ページ (/admin/posts/new) と編集ページ (/admin/posts/[id]) に組み込めば完了となります。

4 宿題

投稿記事の「新規作成ページ」と「編集ページ」に画像アップロード機能を組み込み、次の点に注意しながらブログアプリを完成させてください。

なお、課題3 (最終課題) は オリジナルのウェブアプリ (=このブログアプリを土台に独自要素を追加したもので可) を考案・開発・デプロイしてもらう内容となります。なお、就活用のポートフォリオでは、実装技術に加えて、課題発見能力 (開発の着想や動機) や課題解決能力 (独創性やユニークな着眼点) なども評価されます。単純に既成品をクローンしただけのアプリでは、あまり評価されないそうです…。

5 次回講義

次回の講義では、完成したブログアプリを Vercel というクラウドプラットフォームにデプロイする方法について紹介します (このブログアプリは、バックエンド処理を必要とするので GitHubPages にはデプロイできません)。