Storage

S3 互換のファイル / 画像ストレージ。バケット単位で公開・非公開を分け、 署名付き URL や画像変換にも対応。RLS と統合される。

主要概念

バケットの作成

Dashboard

Storage → New bucket。Public / Private を選び、ファイルサイズ上限・MIME 制限を設定。

SQL

insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true);

insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
  'documents', 'documents', false,
  10485760,                              -- 10 MB
  array['application/pdf', 'image/png']
);

アップロード

クライアント

const file = e.target.files[0]

const { data, error } = await supabase.storage
  .from("avatars")
  .upload(`${userId}/profile.png`, file, {
    cacheControl: "3600",
    upsert: true,                  // 上書き
    contentType: file.type,
  })

if (error) console.error(error)
console.log(data.path)            // "uuid/profile.png"

進捗

// supabase-js には進捗イベントが無い → fetch で代替
// もしくは tus-js-client(resumable upload)を使う

Resumable upload(大容量)

Storage はtus.io プロトコルに対応。中断しても再開できる。50MB 以上はこちら推奨。

import * as tus from "tus-js-client"

const upload = new tus.Upload(file, {
  endpoint: `${URL}/storage/v1/upload/resumable`,
  retryDelays: [0, 3000, 5000, 10000],
  headers: {
    authorization: `Bearer ${token}`,
    "x-upsert": "true",
  },
  metadata: {
    bucketName: "videos",
    objectName: `${userId}/video.mp4`,
    contentType: file.type,
    cacheControl: "3600",
  },
  chunkSize: 6 * 1024 * 1024,
  onError: console.error,
  onProgress: (bytesUploaded, bytesTotal) => {
    const pct = ((bytesUploaded / bytesTotal) * 100).toFixed(1)
    console.log(`${pct}%`)
  },
  onSuccess: () => console.log("done"),
})
upload.start()

ダウンロード / URL 取得

Public bucket

const { data } = supabase.storage
  .from("avatars")
  .getPublicUrl(`${userId}/profile.png`)

console.log(data.publicUrl)
// https://xxxx.supabase.co/storage/v1/object/public/avatars/uuid/profile.png

Private bucket(署名付き URL)

const { data, error } = await supabase.storage
  .from("documents")
  .createSignedUrl(`${userId}/contract.pdf`, 3600)  // 1 時間有効

console.log(data.signedUrl)

署名付き URL(書き込み用)

// クライアントに直接アップロードさせるが、サーバ側でパスを発行
const { data } = await supabase.storage
  .from("uploads")
  .createSignedUploadUrl(`${userId}/${filename}`)

// data.signedUrl, data.token, data.path
// クライアントでこの URL に PUT

Blob としてダウンロード

const { data, error } = await supabase.storage
  .from("documents")
  .download(`${userId}/contract.pdf`)

const url = URL.createObjectURL(data)

一覧取得

const { data } = await supabase.storage
  .from("avatars")
  .list(userId, {
    limit: 100,
    offset: 0,
    sortBy: { column: "created_at", order: "desc" },
  })

削除 / 移動 / コピー

// 削除
await supabase.storage.from("avatars").remove([
  `${userId}/profile.png`,
  `${userId}/old.png`,
])

// 移動
await supabase.storage.from("avatars").move(
  `${userId}/old.png`,
  `${userId}/archive/old.png`,
)

// コピー
await supabase.storage.from("avatars").copy(
  `${userId}/profile.png`,
  `${userId}/profile-backup.png`,
)

RLS(重要)

Storage の権限はstorage.objects テーブルに対する RLSで表現する。

例: 「自分のフォルダだけ読み書きできる」

-- storage.objects.name は "userId/filename.png" の形式
create policy "Users can manage their own folder"
on storage.objects
for all
to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = auth.uid()::text
)
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = auth.uid()::text
);

例: 公開バケットだが認証ユーザだけ書ける

create policy "Public read"
on storage.objects for select
using (bucket_id = 'avatars');

create policy "Authenticated upload"
on storage.objects for insert
to authenticated
with check (bucket_id = 'avatars');

画像変換(Image Transformations)

Pro 以上でサーバ側で画像を変換できる。クエリ文字列で指定。

const { data } = supabase.storage
  .from("avatars")
  .getPublicUrl(`${userId}/profile.png`, {
    transform: {
      width: 200,
      height: 200,
      resize: "cover",      // "cover" | "contain" | "fill"
      quality: 80,
      format: "origin",     // 自動で WebP / AVIF
    },
  })

署名付き URL でも

await supabase.storage
  .from("private-images")
  .createSignedUrl("path", 3600, {
    transform: { width: 800, quality: 75 },
  })

キャッシュと CDN

SPA でのアバター更新パターン

async function uploadAvatar(file: File) {
  const userId = (await supabase.auth.getUser()).data.user!.id
  const path = `${userId}/avatar-${Date.now()}.png`

  const { error } = await supabase.storage
    .from("avatars")
    .upload(path, file, { upsert: true })
  if (error) throw error

  // profiles テーブルに URL を記録
  const { data } = supabase.storage.from("avatars").getPublicUrl(path)
  await supabase.from("profiles").update({ avatar_url: data.publicUrl })
    .eq("id", userId)
}

容量と料金

S3 互換 API

Storage はS3 互換のエンドポイントを持つ。aws-sdk / s3cmd / rclone がそのまま使える。

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"

const s3 = new S3Client({
  region: "ap-northeast-1",
  endpoint: `${URL}/storage/v1/s3`,
  credentials: { accessKeyId: ID, secretAccessKey: KEY },  // Storage の S3 access key
  forcePathStyle: true,
})

await s3.send(new PutObjectCommand({
  Bucket: "avatars",
  Key: "path/to/file.png",
  Body: buffer,
}))

失敗パターン

症状原因
403 / RLS violationstorage.objects の RLS ポリシー不足
「The resource was not found」バケットが無い / パス間違い
大ファイルで失敗tus.io(resumable)に切替
Public URL なのに 404バケットを Public にし忘れ / RLS 干渉
画像変換が効かないPro 以上 / 元画像が透明 PNG なら format 注意
cache が効かないcacheControl 設定 / クエリ毎回ユニーク

セキュリティ Tips

フォルダ命名のおすすめ

{userId}/{type}/{timestamp}-{filename} の形が安全。 RLS で「先頭フォルダ = auth.uid()」とすれば自動的に他ユーザを締め出せる。