前提と注意事項
この記事では以下を前提としています。
- Next.js 16.4.2(App Router)を使用しています。
- React 19.2.4 を使用しています。
@supabase/supabase-jsは 2.101 系を使用しています。- Supabase のプロジェクトを作成済みで、Next.js から Supabase に接続できる状態です。
Supabase のアカウントとプロジェクトの作成手順については、以下の記事で解説しています。
🧑💻 Supabaseのアカウント作成からプロジェクト作成までの手順Supabaseでアカウントを作成してプロジェクトを作成するところまでの手順を解説します。ritaiz.comこの記事では、次の内容は扱いません。
- ログインやテナント、権限を確認する関数(記事中の
requirePermission)の実装 documentsテーブルの定義と、行レベルセキュリティ(RLS)のポリシー- Supabase のクライアント(記事中の
supabaseとserviceClient)の作成方法 - バケットの作成手順(ダッシュボードで作成するか SQL で作成するかを含みます)
この記事のゴール
Supabase Storage の private バケットに置いたファイルを、署名付き URL を介して画面に表示し、「保存」ボタンでダウンロードできるところまでをゴールとします。
なお、この記事のコードは、受信した FAX の原本(PDF、PNG、JPEG、TIFF)をバケットに保存している画面を例にしています。
構成概要
バケットは service role だけが読み書きできるようにします。画面には、API が発行する有効期限の短い署名付き URL を出します。全体の流れは以下のとおりです。
ブラウザ
↓ GET /api/documents/[id]/file
Next.js の API(権限の確認と署名付き URL の作成)
↓ createSignedUrl(service role)
Supabase Storage(private バケット)
↓ 署名付き URL(60秒)
ブラウザ(表示またはダウンロード)private バケットを用意する
ファイルを置く private バケット documents を用意します。ここでは、バケットの設定とファイルのパスの規則を説明します。
バケットの設定
バケットは以下の設定にします。
- 公開設定は private にします。
- 許可する MIME は
application/pdf、image/tiff、image/png、image/jpegにします。 - 1ファイルあたりのサイズ上限は 50 MB にします。
このバケットは service role だけが読み書きします。そのため、storage.objects にポリシーは作りません。ポリシーがなければ、利用者の権限(anon や authenticated)ではバケットのファイルを読めません。
ファイルのパス
ファイルのパスは以下の規則にします。
{tenant_id}/{document_id}/original.{ext}テナントごと、文書ごとにディレクトリを分けて、原本を original.{ext} という名前で置きます。
署名付き URL を返す API を実装する
次に、署名付き URL を発行する API を実装します。署名付き URL とは、有効期限が付いた一時的なアクセス用の URL です。この URL を知っていれば、private バケットのファイルを期限内に開けます。
API の仕様
API は GET /api/documents/[id]/file とし、クエリパラメータ disposition で動作を切り替えます。
?disposition=inline(既定): 署名付き URL へリダイレクトします。そのため、<iframe>や<img>のsrcに、この API の URL をそのまま指定できます。?disposition=attachment:downloadオプション付きの署名付き URL を JSON で返します。この URL では、応答のContent-Dispositionがattachmentになり、ブラウザは表示せずに保存します。
行が見えない場合は 404 を返します。具体的には、別のテナントの文書である場合、削除済みの場合、原本がまだない場合です。別のテナントの利用者には、文書が存在するかどうかも見せません。
Route Handler の実装
以下のように Route Handler を実装します。処理は、権限の確認、RLS を通した行の取得、service role での署名付き URL の作成、応答の分岐の順に進みます。
import { NextRequest, NextResponse } from "next/server";
const SIGNED_URL_TTL_S = 60;
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requirePermission("documents", "read"); // ログインと権限の確認
if (!auth.ok) return auth.response;
const { tenantId, supabase, serviceClient } = auth.ctx;
const { id } = await params;
const disposition = new URL(req.url).searchParams.get("disposition") === "attachment" ? "attachment" : "inline";
// 利用者の権限(RLS)で行を読む
const { data: doc } = await supabase
.from("documents")
.select("id, title, status, storage_bucket, storage_path, original_filename")
.eq("tenant_id", tenantId)
.eq("id", id)
.is("deleted_at", null)
.maybeSingle();
if (!doc || doc.status !== "stored" || !doc.storage_path) {
return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
}
// 保存するときのファイル名。元の名前がなければ、原本の拡張子を付ける
const filename = doc.original_filename ?? `document.${doc.storage_path.split(".").pop()}`;
// 署名は service role で作る
const { data: signed, error } = await serviceClient.storage
.from(doc.storage_bucket)
.createSignedUrl(
doc.storage_path,
SIGNED_URL_TTL_S,
disposition === "attachment" ? { download: filename } : undefined
);
if (error || !signed?.signedUrl) return NextResponse.json({ error: "Internal server error" }, { status: 500 });
if (disposition === "attachment") {
return NextResponse.json({ url: signed.signedUrl, filename }, { headers: { "Cache-Control": "no-store" } });
}
return NextResponse.redirect(signed.signedUrl, { headers: { "Cache-Control": "no-store" } });
}処理の要点は以下のとおりです。
requirePermissionで、ログインと閲覧の権限を確認します。- 文書の行は、利用者の権限(RLS)で読み取ります。これにより、見えない行は取得できず、404 になります。
- 署名付き URL は、service role のクライアントで作成します。有効期限は
SIGNED_URL_TTL_Sの 60 秒です。 attachmentの場合は、createSignedUrlの第3引数にdownloadオプションを渡します。inlineの場合は何も渡しません。- 保存するときのファイル名は、元のファイル名です。元のファイル名がなければ、
document.tifのように原本の拡張子を付けます。 - どちらの応答にも
Cache-Control: no-storeを付けます。
画面にプレビューを表示する
画面では、API の URL をそのまま src に指定して、ファイルを表示します。
PDF と画像を表示する
PDF は <iframe>、PNG と JPEG は <img> で表示します。previewKind は、ファイルの種類ごとの表示方法を表す値です。PDF は pdf、PNG と JPEG は image、TIFF は none とします。
{previewKind === "pdf" && (
<iframe
src={`/api/documents/${doc.id}/file?disposition=inline`}
title={doc.title}
className="h-[70vh] w-full rounded-lg border"
/>
)}
{previewKind === "image" && (
// eslint-disable-next-line @next/next/no-img-element
<img src={`/api/documents/${doc.id}/file?disposition=inline`} alt={doc.title} className="max-h-[70vh] w-auto max-w-full" />
)}画像は next/image ではなく <img> で表示し、API の URL をそのまま src に指定しています。
TIFF と処理中の表示
TIFF は、Safari 以外の多くのブラウザで表示できません。そのため、TIFF ではプレビューの代わりに「プレビューできません。保存で開いてください」というメッセージを表示します。
また、処理中でまだ原本がない場合は、API が 404 を返します。そのまま <iframe> や <img> に指定すると、エラーの応答や壊れた画像が表示されてしまいます。そこで、画面では文書の status を見ます。stored(原本を保存済み)になるまでは、プレビューの代わりに「処理中です。しばらくしてから再読み込みしてください。」と表示します。
{doc.status !== "stored" ? (
<p className="text-sm text-gray-600">処理中です。しばらくしてから再読み込みしてください。</p>
) : previewKind === "none" ? (
<p className="text-sm text-gray-600">プレビューできません。保存で開いてください。</p>
) : (
<Preview doc={doc} previewKind={previewKind} />
)}<Preview> は、前の節の PDF と画像の表示をまとめたコンポーネントです。
保存ボタンでダウンロードする
「保存」ボタンでは、?disposition=attachment の URL を fetch で受け取り、見えない <iframe> でその URL を開いてダウンロードします。
fetch と非表示 iframe で保存する
最初から、署名付き URL へ画面ごと移動する方法もあります。しかし、この方法では、Storage がエラー(期限切れや削除済みなど)を返したときに、今の画面から離れてしまいます。
そこで、まず fetch で署名付き URL を取得します。この段階の失敗(404 や 500)は、画面にエラーとして表示できます。URL を取得できたら、見えない iframe で開きます。
async function save(id: string) {
const res = await fetch(`/api/documents/${id}/file?disposition=attachment`);
if (!res.ok) {
setError("ファイルを取得できませんでした");
return;
}
const { url } = await res.json();
startDownloadInFrame(url, 60);
}
export function startDownloadInFrame(url: string, ttlSeconds: number) {
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = url;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), (ttlSeconds + 60) * 1000);
}attachment の署名付き URL は、応答に Content-Disposition: attachment が付きます。そのため、iframe で開いても画面には表示されず、ブラウザがファイルを保存します。
iframe を後片付けする
startDownloadInFrame は、window.setTimeout で iframe を外します。外すまでの時間は、URL の有効期限に 60 秒を足した時間です。ここでは (ttlSeconds + 60) * 1000 ミリ秒、つまり 120 秒後になります。
セキュリティ上の注意点
署名付き URL の扱いに関する注意
- 署名付き URL は、有効な間は誰でも開けます。URL を知った人がファイルを保存できてしまうことは避けられません。
- そのため、ダウンロードは閲覧と同じ権限で扱います。保存だけ権限を緩めることはしません。
- 応答には
Cache-Control: no-storeを付け、URL がキャッシュされないようにします。 - 署名付き URL はログに出力しません。
有効期限を短くすると、URL が外部に漏れたときの影響を小さくできます。この記事では 60 秒にしています。
まとめ
この記事では、Supabase Storage の private バケットに置いたファイルを、署名付き URL で画面に表示し、ダウンロードする手順を解説しました。ポイントを整理すると以下のとおりです。
- バケットは private にし、service role だけが読み書きします。
storage.objectsのポリシーは作りません。 - 権限の確認は API の中で行い、文書の行は RLS で絞ります。見えない行は 404 を返します。
disposition=inlineはリダイレクトし、disposition=attachmentはdownloadオプション付きの URL を JSON で返します。- PDF は
<iframe>、画像は<img>で表示します。TIFF は保存を案内します。 - 保存は
fetchで URL を受け取り、見えない iframe で開きます。 - 署名付き URL は有効な間は誰でも開けるため、
Cache-Control: no-storeを付け、ログに出さず、ダウンロードも閲覧と同じ権限で扱います。
Supabase Storage の非公開ファイルを、Next.js の画面で安全に表示したりダウンロードさせたりしたい方にとって参考になれば幸いです。