背景
このサイトの画像アップロードは、これまで Cloudinary 一択でした。つぶやきに添える写真、ブログ記事の挿絵、リアクション用のカスタム絵文字 — どれも Cloudinary へ送られます。
問題は、その処理が共通化されていなかったことです。コードを調べたところ、Cloudinary の API を直接叩いている箇所が15箇所あり、そのすべてが同じ署名生成関数をコピー&ペーストで持っていました。
アップロード処理を持つ箇所: 15
共通ヘルパー: 0
署名生成関数の重複定義: 8
この状態だと、「保存先を変えたい」と思ったときに15箇所すべてを書き換える必要があります。無料枠の残量やコストの都合で保存先を移したくなっても、現実的に動かせません。
そこで、次の3点を目標に整理しました。
- アップロード処理を1つの共通モジュールにまとめる
- 管理画面から保存先を切り替えられるようにする
- すでにアップロード済みの画像は、これまでどおり表示され続ける
設計
保存先の選択はデータベース、鍵はシークレット
「管理画面から設定できるように」という要件ですが、何をデータベースに置くかは分けて考える必要がありました。
データベースに保存するのは、秘密ではない3つの値だけです。
- どちらに保存するか(
cloudinaryまたはr2) - R2 の公開URLのベース
- R2 のバケット名
一方、R2 のアクセスキーとシークレットアクセスキーはデータベースに一切書きません。これらは Cloudflare のシークレットとして登録し、実行時に環境変数として読み込みます。
理由は単純で、シークレットアクセスキーを平文でデータベースに置くと、データベースを読めるあらゆる経路 — バックアップ、デバッグ用のエンドポイント、将来の SQL の不具合 — が鍵の漏洩経路になるからです。既存の Cloudinary の認証情報もシークレット管理なので、そちらに揃えました。
管理画面には「設定済み / 未設定」のバッジだけを表示し、値そのものは絶対に画面へ出しません。未設定のものがあれば、登録用のコマンドを案内として表示します。
R2 への接続方式
R2 への接続には2つの選択肢がありました。
| 方式 | 長所 | 短所 |
|---|---|---|
| バインディング | コードが数行で済む | ホスティング側の管理画面で手動設定が必要 |
| S3互換API | 設定変更なしで動く | 署名生成を自前で書く必要がある |
今回は S3互換API + AWS SigV4署名 を選びました。デプロイ手順を変えずに済むこと、ローカル開発でも同じコードが動くことが決め手です。
署名生成に外部ライブラリは使わず、Web Crypto API だけで組み立てています。SigV4 の署名鍵は、秘密鍵を起点に「日付 → リージョン → サービス → 固定文字列」の順で4回 HMAC-SHA256 をかけて導出する、決まった手順です。
async function deriveSigningKey(secretAccessKey, dateStamp, region, service) {
const initial = new TextEncoder().encode(`AWS4${secretAccessKey}`);
const kDate = await hmacSha256(initial, dateStamp);
const kRegion = await hmacSha256(kDate, region);
const kService = await hmacSha256(kRegion, service);
return hmacSha256(kService, 'aws4_request');
}
署名処理は間違えても「なんとなく動く」ということがなく、かといって一度動けば気づきにくい種類のコードです。そこで、実装後に AWS が公開しているテストベクタと突き合わせて検証しました。署名鍵の導出結果、および正規化リクエストのハッシュ値が、どちらも公開されている期待値と完全に一致することを確認しています。さらに、Web Crypto 版と別実装の HMAC で同じ署名が出ることも確認しました。
実装
共通モジュール
アップロードに関する処理を1つのモジュールに集約しました。外から使うのは実質3つの関数だけです。
// 今選ばれている保存先へアップロードして公開URLを返す
uploadImage(env, db, source, options)
// URLのホスト名を見て、その画像が置かれている先から削除する
deleteImage(env, db, url)
// R2への疎通確認(管理画面の「接続テスト」から呼ぶ)
testR2Connection(env, db)
呼び出し側の15箇所は画像の渡し方がばらばらだったので、3種類の入力を受け付けるようにしました。
- フォームから受け取ったファイル
- メモリ上で生成した生バイト列(OGP画像やAI生成画像)
- よそのサーバーにある画像のURL
3つ目が少し厄介で、Cloudinary は URL を渡すだけで向こうが取得してくれますが、S3互換API にはその機能がありません。R2 の場合は自分で取得してからアップロードします。結果的に、取得した画像の形式とサイズを自分で検証するようになったので、以前より安全になりました。
削除はURLから判定する
保存先を切り替えると、「古い画像は Cloudinary、新しい画像は R2」という混在状態が必ず起きます。投稿を削除するときに、どちらから消すべきかを判断しなければなりません。
これは画像URLのホスト名で判定しています。設定された R2 の公開URLと一致すれば R2 から、Cloudinary のホストなら Cloudinary から削除します。どちらにも当てはまらないURLは、自分の管理外なので何もしません。
ホスト名は完全一致でしか受け付けません。部分一致にすると pub-abc123.r2.dev.example.com のようなホストを通してしまい、意図しない削除リクエストを飛ばす余地が生まれます。
オブジェクトキーはサーバーが決める
R2 に保存するときのファイル名(オブジェクトキー)には、ユーザーが送ってきたファイル名を一切使いません。
images/2026/08/3f9a2c1b8e7d4056a1b2c3d4e5f60718.png
年月のフォルダ分けと、暗号学的に安全な乱数16バイトを組み合わせた名前です。拡張子は、検証済みの MIME タイプから引きます。これで、上位ディレクトリへ抜けようとする名前も、既存ファイルを上書きしようとする名前も入り込みません。
表示側の許可ホスト
実装中に見落としかけた点がありました。画像を表示する側のコードが、複数箇所で「Cloudinary のホストのURLだけを表示する」という検証を持っていたのです。
この検証はそのままで正しいのですが、R2 に保存した画像のURLは当然この条件を満たしません。放置すると、アップロード自体は成功しているのに画像が表示されない、という分かりにくい不具合になります。
そこで、許可ホストの判定も共通の関数に切り出しました。この関数はデータベースにも環境変数にもアクセスしない純粋な関数なので、サーバー側でもブラウザ側のスクリプトでも同じものを import して使えます。
isAllowedImageUrl(url, r2PublicBaseUrl?)
設定を読める場面では、設定された公開URLのホスト名と完全一致するものだけを通します。設定を読めないブラウザ側のスクリプトでは、Cloudflare が管理する公開ドメインのみを許可します。
この関数は、次のような偽装パターンを拒否することを個別に確認しました。
https://res.cloudinary.com.evil.example/a.png(サブドメイン偽装)https://pub-abc123.r2.dev.evil.example/a.png(同上)https://evil.example/pub.r2.dev/a.png(パスに紛れ込ませる)http://の平文URLdata:やjavascript:などのURL
切り替えないもの
1箇所だけ、設定に従わず必ず Cloudinary へ送る処理を残しました。ブログのOGP画像に日本語テキストを合成する処理です。
これは Cloudinary の URL にパラメータを書くとテキストを画像に重ねてくれる機能を使っており、事前にアップロードしたフォントファイルを参照しています。単なるファイル置き場ではなく画像変換エンジンとしての機能なので、R2 には置き換えられません。コードにその旨をコメントとして残しています。
動作確認
ローカル環境で次を確認しました。
- 初期値のままなら、これまでとまったく同じ動作になること(切り替え機能を入れただけでは何も壊れない)
- 保存先の切り替えと、設定値の保存・読み込み
- 公開URLの末尾スラッシュが自動で除去されること
- 未認証のアクセスが弾かれること
入力の検証については、次のケースが保存されず、かつ画面に理由が表示されることを確認しました。
| 入力 | 結果 |
|---|---|
| R2 を選んだが公開URLとバケット名が空 | 拒否・理由を表示 |
公開URLが平文の http:// |
拒否・理由を表示 |
| バケット名に大文字や記号 | 拒否・理由を表示 |
| 公開URLにクエリ文字列 | 拒否・理由を表示 |
最後に、意図的に不完全な設定のまま画像を投稿してみました。このとき、エラーになることが正しい動作です。設定が足りないときに黙って Cloudinary へ保存してしまうと、「どちらに保存されたのか分からない画像」が生まれ、後から削除も移行もできなくなります。実際にエラーが返り、不足しているシークレットの名前が具体的に示されることを確認しました。
学び
一番の収穫は、同じコードが15箇所にコピーされている状態は、機能追加ではなく機能凍結だったと気づけたことです。保存先を変えるという要望が来て初めて、その重複がコストとして表面化しました。
もうひとつは、切り替え機能を作るときは「切り替えたあとの読み取り側」まで見ないと不完全だということです。今回は表示側の許可ホスト判定という形で現れました。書き込む処理だけを差し替えて満足していたら、画像が表示されない状態でリリースしていたはずです。
署名処理のように「間違っていても静かに失敗する」コードは、公開されているテストベクタと突き合わせるのが確実です。実際に接続できる環境が手元になくても、実装の正しさはそこまで詰められます。