画像付きCMSで難しいのは、アップロードボタンを置くことではありません。
認証、変換、一時オブジェクト、記事保存、参照管理、配信、表示確認までが一続きで成功して、初めて公開できたと言えます。
このブログはNext.js 16のAPIの範囲をvinextでVite/Cloudflare Workers上へ載せています。
本文画像はCloudflare Images binding(Cloudflare Imagesのバインディング)でWebPへ正規化し、Codex App Server生成またはアップロードしたカバー画像はPNGのまま、非公開のR2バケットへ保存します。
D1は記事だけでなくメディアのメタデータと記事からの参照も管理し、ブラウザへは同一オリジンのルートから配信します。
ビルド時のアセットとCMSメディアを分ける
画像の置き場所は、更新主体で分けると判断しやすくなります。
public/: ソースと一緒にレビューし、デプロイ時に確定する記事内図解- R2: 管理画面から追加・生成する本文画像とPNGのカバー画像
- 外部URL: 画像の配信元ではなく、引用元を示すリンクとして保持
public/はGit差分とデプロイ成果物を一致させやすい一方、画像1枚の追加にもビルドとデプロイが必要です。
R2はWorkerのバインディングから保存できるため、CMS操作に向いています。
外部画像の直リンクは削除、規約、速度、追跡の影響を受けるため、この実装では採用しません。
public・R2・外部URLの使い分け比較図のExternalは「出典リンクを残す」という意味です。
このブログのレンダラーは外部hostは画像srcとして許可しません(外部ホストの画像URLは許可しません)。
掲載権利を確認した画像は自分のR2へ取り込み、出典は通常のリンクとして別に残します。
アップロードルートは本文を読む前に認証する
入口は本文画像用のPOST /api/media/uploadとカバー画像用のPOST /api/thumbnails/uploadです。
両ルートは最初に管理者認証を完了してからformData()を読みます。
未認証リクエストの大きな本文を先にparseしないことで、無駄なCPU・memory・R2書き込みを避けます。
Content-Lengthは任意の事前確認用のシグナルとして扱います。
この実装では5 MiBへ64 KiBのmultipart framing allowance(multipartの枠分の余裕)を加えたリクエスト上限で、明らかに大きい本文だけをformData()より前に拒否します。
ヘッダーが無いだけでは拒否せず、ヘッダーを偽っても通らないよう、最終的な判定は実ファイルのバイト長で行います。
最終判断はアップロードされた実byte(実バイト)を読み、その長さが5 MiB以下かで行います。
元画像と変換後画像の両方を上限確認し、空ファイルも拒否します。
本文画像のinputはJPEG、PNG、WebP、GIFを許可し、最大幅1600pxへscale-downしてWebP・quality 82へ正規化します。
cover(カバー画像)はSVG・JPEG・WebP・GIFを受けず、Codex App Serverのgpt-image-2またはPNG アップロードだけを許可します。Cloudflare Imagesのバインディングで1200×675へカバー画像の切り抜きした後もimage/pngを維持します。
MIME labelだけは信用せず、変換前後のPNG/JPEG/WebP/GIF signatureとレスポンス Content-Typeを照合します。カバー画像参照はsite-relativeな.pngだけをpost create・patch・backup restore(バックアップからの復元)で受け入れます。
Imagesのバインディングがない環境では原画像をそのまま保存せず、503相当で失敗時は拒否にします。
一時オブジェクトを先に作る
変換後メディアにはSHA-256を計算します。
ただしアップロード直後はまだ記事へ紐づいていないため、最初のkeyは次の形です。
text
tmp/uploaded/<uuid>.webp # 本文画像
tmp/<generated|uploaded>/<uuid>.png # AI生成またはuploadしたcover
オブジェクト メタデータは保存形式に合わせてContent-Type: image/webpまたはimage/pngとし、どちらもCache-Control: private, no-storeです。
/api/thumbnails/tmp/...のGETには管理者認証が必要で、temporary URL(一時URL)を一般公開cacheへ載せません。
同時にD1のmedia_storage_reservationsで容量を予約し、media_assetsへtemporary rowを登録します。
予約消費とメタデータ作成はD1 batchへ入れ、片方だけ成功した状態を拒否します。
R2 putが失敗した場合は一時 メタデータと予約をcleanupするため、quota ledgerにghost usageを残しません。
記事保存時に内容ハッシュキーへ昇格する
アップロード成功だけでは公開メディアではありません。
記事保存時にprepareMediaForPostが本文とカバー画像のmanaged URLを抽出し、一時 assetを次の永続 keyへ昇格します。
text
media/<sha256>.webp # 本文画像
media/<sha256>.png # cover
同じcontentとMIMEなら同じkeyになり、PNGはPNG、WebPはWebPのまま昇格します。
永続 オブジェクトにはCache-Control: public, max-age=31536000, immutableを設定します。
内容が変わればhashとURLも変わるため、古いブラウザ/CDN cacheのpurgeに依存しません。
公開URLはR2バケットのpublic domainではなく、同一オリジンの次の形です。
text
/api/thumbnails/<key>
Markdownへ保存される例はこうなります。
markdown

altはファイル名ではなく図が伝える内容を書きます。
image-01より「R2から記事へ画像を配信する構成図」の方が、画像を見られない読者にも意味が残ります。
D1でメディアの状態と参照を守る
D1にはmedia_assets、post_media_refs、ストレージ reservationを持たせます。
media_assetsはkey、SHA-256、MIME、size、ソース、temporary・promoting・attaching・attachedなどの状態を記録します。
post_media_refsはどの記事がどのオブジェクトを使うかを保持し、使用中メディアの削除を拒否します。
記事更新ではクライアントが読んだupdatedAtをpreconditionにします。
メディアをattachingへclaimし、記事、bigram 索引、参照row、メディア状態を同じD1 batchで更新します。
競合で記事書き込みが0件ならclaimを解放し、古いエディター タブが新しい記事とメディア参照を上書きしないようにします。
記事から外れた永続 メディアはすぐ削除せずorphanedへ移します。
cleanup jobは猶予 period後に参照を再確認してから削除し、R2 delete失敗はdelete_failedとして再試行可能にします。
同一オリジンのルートで非公開のR2を配信する
GET /api/thumbnails/<key>はR2 バインディングからオブジェクトを取得し、保存時のHTTP メタデータをレスポンスへ書き戻します。
tmp/で始まるkeyだけは管理者認証とprivate, no-storeを強制します。
永続 media/ keyはcontent hashで不変なので、public, max-age=31536000, immutableを返せます。
バケット自体をpublicにしないため、random keyを知っているだけでは一時オブジェクトへ直接到達できません。
公開・非公開の境界はR2設定とルートの両方で管理します。
next/imageとvinextの境界
本文レンダラーはMarkdownの画像URLをsafeLocalImageSrcへ通し、サイト origin以外を拒否します。
next/imageへwidth={1200}、height={675}、responsiveなsizesを渡し、読み込み前からaspect ratioを確保します。
vinextはNext.js APIをVite上で再実装し、Cloudflareではimage optimizationをImagesのバインディングへ接続します。
このブログではtemporary画像だけ(一時画像だけ)unoptimizedにします。
一時 GETにはadmin セッションが必要なため、optimizerを経由させるとcredential境界が変わるからです。
永続 同一オリジン メディアとpublic/ assetは通常のoptimization パスを使います。
公開を6段階で確認する
画像付き記事を公開する検証フロー確認順は次です。
- admin アップロードまたはCodex App Server生成が成功し、一時 URLが返る
- 認証済みブラウザで一時 URLを直接GETし、
200と、本文ならimage/webp・カバー画像ならimage/pngを確認する - alt付きMarkdownまたはカバー画像 previewで比率と余白を確認する
- 記事save後、URLが
tmp/からMIMEを保ったmedia/<sha256>.webpまたは.pngへ置換されたことを確認する - 永続 URLを直接GETし、
200、Content-Type、public immutable cacheを確認する - 公開記事をPC/モバイルで開き、404、CSP、画像最適化エラーがないことを確認する
ルートのアップロード レスポンスが200でも、記事保存時のpromotionが失敗すれば公開は未完了です。
途中で失敗したらpublishしないか、本文だけ読める状態へ戻します。
success toastだけで完了判定しません。
よくある危険な短縮
- 未認証本文をparseしてからauthする
Content-Lengthだけを信用し、実バイトを測らない- 原画像を検証せずそのままR2へ保存する
- 一時 URLを公開記事へ残す
- D1へ記事を保存した後でメディア参照を別transactionで更新する
- 同じkeyを上書きしながら
immutableを付ける - 使用中オブジェクトをreference checkなしで削除する
- 外部ホストをwildcardで許可する
- direct GETとmobile表示を確認せず完了にする
画像配信はUI widgetではなく、認証・ストレージ・データベース・cacheをまたぐ小さなtransaction systemです。
一時と永続を分け、content hashとD1 referenceを使い、最後にHTTP レスポンスまで確認すると、画像が増えても壊れにくいCMSになります。
参考資料
関連記事