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

うちのビジネスモデルキャンバス作成ツールは、作ったキャンバスを画像にして共有できます。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です。認証は要りません。バケットへの認証はサーバー側が代わりに行います。
公開しているのは「このアプリ経由で取得できること」だけで、バケットそのものは閉じたままです。将来「共有していない画像は配信しない」といった条件を足したくなったとき、判定を挟む場所がすでにあるのも利点です。
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}`
}
..や/を弾く)「非公開バケットにしたから安全」ではありません。配信口を作った時点で、その配信口が何を読めるかは自分で縛る必要があります。パス・トラバーサルを許せば、バケット内の別のオブジェクトを読ませることになりかねません。
非公開バケットの定番といえば**署名付きURL(Signed URL)**です。今回は採用しませんでした。
理由は有効期限です。
さらに、発行のたびに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になりました。「本番では動くのにプレビューだと壊れている」という状態は、レビューのときに一番いらない混乱を招きます。
1回目は確かに遅くなります。バケットから取ってから返すためです。
ただs-maxageを効かせておけば、2回目以降はCDNが返します。 OGP画像は同じURLに繰り返しアクセスが来る性質なので、キャッシュがよく効きます。実測でも体感できる遅さにはなりませんでした。
オンにしています。オブジェクト単位のACLを使わない構成なので、むしろオンのほうが事故りません。
オフにしていると、うっかり1オブジェクトだけ公開してしまう操作ができてしまいます。「公開しない」と決めたなら、公開する手段自体を塞いでおくほうが安全です。
ASIA-NORTHEAST1(東京)です。読み出すのはVercel上の関数なので、関数の実行リージョンと合わせるのが基本になります。
関数の実行リージョンについては別記事に書きました。
戻すのは簡単ですが、一度公開したら取り消せません。 公開中に取得されたURLは、非公開に戻したあとも「誰かの手元にある」状態です。
だから最初の設計で閉じておくほうが安いと考えました。開けるのはいつでもできます。
HEADを通し、Cache-ControlでCDNに持たせるrequire(変数)ではなくfsで読む「公開ACLを付ける」以外の選択肢を最初に検討しておくと、後から締め直す作業をせずに済みます。
この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!
Share Me!