iOS&Androidスマートフォン開発(Swift/Kotlin/React-Native) および Next.js/LaravelをはじめとしたWeb開発ならば、ぜひ創業13年の弊社にお任せください。ワンストップでお届けします。お問合せはこちらから

非公開のCloud StorageバケットからOGP画像を配信する。公開ACLを付けずにクローラーへ見せる構成

  2026年09月12日 14:10 / - PV

yuku_tasのアイコン

こんにちは、Webサービス開発者のyuku_tasです。

簡単なプロフィール: MoldSpoon Inc.代表。開業から15年、業界に携わって20年。Webサイト/iOS/Androidアプリ開発から、データ分析業務・コンサル業務、ベンチャーから保険会社まで様々な案件を個人会社の形で請け負ってまいりました。詳しくはこちら

はじめに

うちのビジネスモデルキャンバス作成ツールは、作ったキャンバスを画像にして共有できます。SNSに貼ったときにOGP画像として出したいので、その画像をどこかに置く必要があります。

もともとは外部に立てたAPIサーバー(EC2上のLaravel)がローカルディスクへ保存し、Apacheが静的配信していました。ところが証明書が失効して、その仕組みが丸ごと死にました。

作り直すにあたって決めたのが、バケットは非公開のままにするという方針です。この記事はそのための構成の話です。

何が悩みどころなのか

素直にやるなら、Cloud Storageのバケットに公開読み取りを付けて、そのURLをog:imageに書けば終わりです。1行で済みます。

問題は、そこに入っているのがユーザーが作ったコンテンツだということです。

バケットを公開すると、URLさえ分かれば誰でも直接取得できます。オブジェクト名を推測されれば、共有していないキャンバスまで見られる可能性があります。実際にはFirestoreのドキュメントIDを使っていて推測は困難ですが、「推測しにくい」は「保護されている」とは違います。

一方で、SNSやGoogleのクローラーは認証できません。 ログインさせることも、トークンを渡すこともできません。

つまり、

  • バケットを公開したくない
  • しかしクローラーには見えないといけない

という、一見矛盾する要件になります。

構成:配信口をアプリ側に置く

答えは単純で、バケットは非公開のまま、アプリケーションを配信の入口にします。

SNSのクローラー
  ↓ (認証なしのGET)
https://example.com/api/bmc-image/{id}.png     ← Next.jsのAPI Route
  ↓ (サービスアカウントで認証)
gs://your-bucket/upload/bmc_image/{id}.png     ← 非公開バケット

クローラーから見れば、ただの画像URLです。認証は要りません。バケットへの認証はサーバー側が代わりに行います。

公開しているのは「このアプリ経由で取得できること」だけで、バケットそのものは閉じたままです。将来「共有していない画像は配信しない」といった条件を足したくなったとき、判定を挟む場所がすでにあるのも利点です。

実装

pages/api/bmc-image/[file].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { BmcStorageError, readBmcImage } from 'lib/bmcStorage'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    res.setHeader('Allow', 'GET, HEAD')
    return res.status(405).json({ message: 'GET のみ対応しています。' })
  }

  const raw = Array.isArray(req.query.file) ? req.query.file[0] : req.query.file
  if (!raw) return res.status(400).json({ message: 'ファイル名が指定されていません。' })

  const dot = raw.lastIndexOf('.')
  if (dot <= 0) return res.status(400).json({ message: 'ファイル名の形式が不正です。' })

  const id = raw.slice(0, dot)
  const imageType = raw.slice(dot + 1)

  try {
    const image = await readBmcImage(id, imageType)
    if (!image) return res.status(404).json({ message: '画像が見つかりません。' })

    res.setHeader('Content-Type', image.contentType)
    res.setHeader('Content-Length', image.data.length)
    // 作り直されることは稀なので、そこそこ長めにキャッシュさせる
    res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400')
    return res.status(200).send(image.data)
  } catch (error) {
    if (error instanceof BmcStorageError) {
      return res.status(400).json({ message: error.message })
    }
    console.error('画像の取得に失敗しました:', error)
    return res.status(500).json({ message: '画像の取得に失敗しました。' })
  }
}

HEADも通しておくのがポイントです。クローラーによっては、本体を取る前にHEADで存在確認やContent-Typeの確認をします。

Cache-Controlも忘れずに。これが無いと、アクセスのたびにバケットへ取りに行きます。 OGP画像は同じURLが何度も叩かれるので、CDN側(s-maxage)で持たせる価値が大きいです。

入力の検証は必須

URLの一部がそのままオブジェクト名になる構成なので、ここは必ず検証します。

/** html2canvas の toDataURL が返しうる形式だけ許可する */
export const ALLOWED_IMAGE_TYPES = ['png', 'jpeg', 'gif'] as const

export function objectNameFor(id: string, imageType: string): string {
  if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
    throw new BmcStorageError('IDの形式が不正です。')
  }
  if (!ALLOWED_IMAGE_TYPES.includes(imageType as BmcImageType)) {
    throw new BmcStorageError('対応していない画像形式です。')
  }
  return `upload/bmc_image/${id}.${imageType}`
}
  • IDは英数字とハイフン・アンダースコアのみにする(../を弾く)
  • 拡張子はホワイトリストで受ける

「非公開バケットにしたから安全」ではありません。配信口を作った時点で、その配信口が何を読めるかは自分で縛る必要があります。パス・トラバーサルを許せば、バケット内の別のオブジェクトを読ませることになりかねません。

署名付きURLを選ばなかった理由

非公開バケットの定番といえば**署名付きURL(Signed URL)**です。今回は採用しませんでした。

理由は有効期限です。

  • 署名付きURLは必ず期限切れになる
  • OGP画像のURLは、SNSやクローラーのキャッシュに長く残る
  • 期限が切れると、あとから見た人には画像が出ない

さらに、発行のたびにURLが変わるので、同じ画像に対して安定したURLを持てません。 OGPのog:imageは「そのページの画像はこれ」と示すものなので、URLが揺れるのは相性が悪いです。

短命なダウンロードリンク(管理画面からのエクスポートなど)には署名付きURLが最適ですが、恒久的に公開したい1枚には向きません。

環境変数まわりの落とし穴

ローカルと本番で認証情報の渡し方が違う

本番(Vercel)は環境変数に入れます。JSONそのままだと改行の扱いで壊れやすいので、base64も受け付けるようにしておくと事故が減ります。

const raw = process.env.GCP_SERVICE_ACCOUNT_JSON
if (raw) {
  const json = raw.trim().startsWith('{')
    ? raw
    : Buffer.from(raw, 'base64').toString('utf8')
  return JSON.parse(json)
}

貼り付ける値はこう作ります。

base64 -i service-account.json 

ローカルは鍵ファイルのパスを.env.localに置いて読みます。

require(変数)はwebpackに潰される

ここでハマりました。ローカル用に鍵ファイルを読もうとして、こう書くと動きません。

// ✗ webpack が解決できない
const serviceAccount = require(process.env.GCP_SERVICE_ACCOUNT_FILE)

requireの引数が変数だと、webpackはビルド時に対象を決められず、バンドルの過程で壊れます。実行時に読むならfsを使います。

// ✓
import { readFileSync } from 'fs'
return JSON.parse(readFileSync(localPath, 'utf8'))

同じ「動的に解決できない」問題は、動的importでも起きます。

プレビュー環境にも環境変数を入れる

Production / Preview / Development の全部に設定してください。

うちはこれを忘れて、プレビューデプロイで画像まわりが軒並み500になりました。「本番では動くのにプレビューだと壊れている」という状態は、レビューのときに一番いらない混乱を招きます。

よくある質問(FAQ)

Q. API Routeを挟むと遅くなりませんか?

1回目は確かに遅くなります。バケットから取ってから返すためです。

ただs-maxageを効かせておけば、2回目以降はCDNが返します。 OGP画像は同じURLに繰り返しアクセスが来る性質なので、キャッシュがよく効きます。実測でも体感できる遅さにはなりませんでした。

Q. 均一アクセス制御(Uniform bucket-level access)はオンでいいですか?

オンにしています。オブジェクト単位のACLを使わない構成なので、むしろオンのほうが事故りません。

オフにしていると、うっかり1オブジェクトだけ公開してしまう操作ができてしまいます。「公開しない」と決めたなら、公開する手段自体を塞いでおくほうが安全です。

Q. バケットのリージョンはどこにしましたか?

ASIA-NORTHEAST1(東京)です。読み出すのはVercel上の関数なので、関数の実行リージョンと合わせるのが基本になります。

関数の実行リージョンについては別記事に書きました。

Q. あとから公開バケットに変えたくなったら?

戻すのは簡単ですが、一度公開したら取り消せません。 公開中に取得されたURLは、非公開に戻したあとも「誰かの手元にある」状態です。

だから最初の設計で閉じておくほうが安いと考えました。開けるのはいつでもできます。

まとめ

  • バケットは非公開のまま、アプリのAPI Routeを配信口にする
  • クローラーは認証できないが、サーバーが代わりに認証すれば済む
  • 配信口ではIDの形式検証と拡張子のホワイトリストを必ず入れる
  • HEADを通し、Cache-ControlでCDNに持たせる
  • OGPに署名付きURLは向かない(有効期限とURLの不安定さ)
  • 認証情報はbase64も受け付けるrequire(変数)ではなくfsで読む
  • 環境変数はPreview環境にも入れる

「公開ACLを付ける」以外の選択肢を最初に検討しておくと、後から締め直す作業をせずに済みます。


この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!

Share Me!