ハコヅミを作る #12:ギアに画像を付ける(R2、ブラウザでの縮小、URL からの取り込み)

前回(#11)で画面の見た目を整えたので、今回から機能を足していきます。 最初は、ギアに画像を付けられるようにしました。

製品名だけだと、似た名前のギア(「ギガパワーストーブ地」と「ギガパワーBFストーブ」など)がどれだったか、ぱっと分からないことがあります。写真があれば一目で分かります。


2つの Issue に分けた

画像まわりは、次の2つの Issue に分けました。

Issue 内容
#49 ギアの登録・編集で、画像のファイルを1枚付ける
#50 画像の URL や、商品ページの URL を貼るだけで取り込む

最初は #49 だけのつもりでしたが、下書きを見ながら「画像ファイルを用意しなくても、Web の画像の URL を貼れば保存できないか」と Claude Code に聞いてみました。できるけれど仕組みが少し違うので、別の Issue にしたほうがよい、という答えだったので、分けています。

ギアの登録画面の画像の欄。選んだ画像(テントの絵)のプレビューと、ファイルを選ぶボタン、URL の入力欄と「取り込む」ボタンが並んでいる

画像の欄。ファイルを選ぶか、URL を貼って取り込む(画像は記事用のサンプル)

画面は、この記事の時点のコードを手元で動かして撮ったものです。データは#7でNotionから取り込んだ、実際のギアを入れています。画像は記事用に描いたサンプルです。 手元のテスト用のブラウザが英語の設定なので、ボタンは「Choose File」と出ていますが、日本語のブラウザでは「ファイルを選択」です。


R2 を初めて使う

画像は、Cloudflare の R2(ファイルの置き場所)に置きます。技術スタックの方針で決めていたものの、使うのは今回が初めてです。

準備は次のとおりでした。

  • 開発用・本番用のアカウントに、それぞれバケット(hakozumi-images)をダッシュボードで作る(人が行う)
  • wrangler.jsonc に、R2 のバインディングを開発用と本番用の両方に書く(#2で気づいたとおり、バインディングは本番用の設定に引き継がれない)
  • gears テーブルに、画像の置き場所(キー)を入れる列 image_key を1つ足す

保管パスは「利用者ごと」にまとめる

R2 の中で、画像をどういう名前(キー)で置くかも決めました。最初の案は、ギアごとにフォルダを分ける形でした。

users/<user_id>/gears/<gear_id>/<ランダムな ID>.webp

これを見て、次の2点が気になりました。

  • ギアの数だけフォルダができて、乱立する
  • 将来、ある利用者のデータを消すとき(退会やサービス終了のとき)に、その人の画像をまとめて消しやすくしておきたい

そこで、利用者ごとにまとめて、ギアごとのフォルダは作らない形に直しました。

users/<user_id>/gears/<gear_id>-<ランダムな ID>.webp

どのギアの画像かは、ファイル名の先頭で分かります。1人分の画像はすべて users/<user_id>/ の下に入るので、まとめて探したり消したりできます。 末尾の「ランダムな ID」は、画像を差し替えるたびに変わります。名前が変わるので、ブラウザに古い画像のキャッシュが残って見える心配がありません。


アップロードの前に、ブラウザで縮小する

スマホの写真は、1枚で数 MB あります。そのまま保存すると、置き場所も通信量も無駄になります。 方針どおり、アップロードの前にブラウザで縮小します。

  • 長い辺を 1200px までにする(小さい画像は大きくしない)
  • WebP にして送る
  • 縮小後の幅と高さを決める計算は、関数に切り出してユニットテストする
// 長い辺が max 以下になるように、縦横比を保って縮める。小さい画像は大きくしない
export function fitWithin(width: number, height: number, max = 1200) {
	const scale = Math.min(1, max / Math.max(width, height));
	return { width: Math.round(width * scale), height: Math.round(height * scale) };
}

縮小した画像は、ファイルを選ぶ欄の中身と入れ替えて、そのままフォームで送ります。DataTransfer を使うと、ファイルの欄に別のファイルを入れられます。

const resized = await resizeImage(file);
const transfer = new DataTransfer();
transfer.items.add(resized);
input.files = transfer.files; // 送られるのは縮小したファイルになる

こうすると、サーバー側は「フォームで送られてきたファイルを R2 に置く」だけで済みます。

Safari では WebP で書き出せない

実装してみて分かったのが、ブラウザによっては(Safari など)、canvas から WebP で書き出せないことです。対応していない形式を指定すると、黙って PNG で返ってきます。PNG のままだと写真は 1 MB を超えやすいので、そのときは JPEG で作り直すようにしました。Issue からの変更として、変更履歴に残しています。


受入確認で見つかったこと

開発環境で触ってみて、3つの指摘がありました。

1. 拡張子だけ .jpg にした zip が、保存できてしまった

一番大きかったのがこれです。zip ファイルの拡張子を .jpg に変えて選ぶと、エラーにならずに保存され、崩れた画像として表示されました。

原因は、ブラウザが付けるファイルの種類(File.type)が、拡張子から決まるだけだったことです。.jpg なら中身に関係なく image/jpeg になり、サーバーもそれを信じていました。

そこで、**ファイルの先頭の数バイト(マジックナンバー)**を見て、本当に JPEG・PNG・WebP かを判定するようにしました。

export function detectImageType(head: Uint8Array) {
	const startsWith = (bytes: number[], offset = 0) =>
		bytes.every((byte, index) => head[offset + index] === byte);
	if (startsWith([0xff, 0xd8, 0xff])) return 'image/jpeg';
	if (startsWith([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
	if (startsWith([0x52, 0x49, 0x46, 0x46]) && startsWith([0x57, 0x45, 0x42, 0x50], 8)) {
		return 'image/webp'; // "RIFF" .... "WEBP"
	}
	return null;
}

画面でも、画像として読み込めなかったら、その場でエラーを出すようにしました。

画像の欄の下に「画像として読み込めませんでした。別のファイルを選んでください」と赤字で表示されている

拡張子だけ .jpg にしたファイルを選んだところ

利用者から送られてきたものの「種類」を、名前(拡張子)で信じない。 当たり前のことですが、受入確認で実際に試してもらって初めて気づきました。

2. 「1 MB を超えるとエラー」が確かめられない

ACには「1 MB を超えるファイルは、保存されずエラーになる」と書いていました。ところが、大きな写真を選んでも、ブラウザで縮小されて小さくなるので、エラーになりません。

これは不具合ではなく、意図どおりの動きです。1 MB の上限がかかるのは、縮小しても大きい場合や、JavaScript が動かずに縮小されないまま送られた場合だけです。 画面を触っても確かめようがないので、このACは「(自動テストで確認)」に書き直しました。テストでは、画面を通さずに 1 MB を 1 バイト超えるファイルを直接送り、保存されないことを確かめています。

3. 画像が2つ並ぶ、ファイルの選択が見づらい

編集画面で別の画像を選ぶと、今の画像と、選んだ画像のプレビューが2つ並んでいました。選んだ時点で画面の画像を差し替え、「保存すると、この画像になります」と添える形にしました。 ただし、R2 の古い画像を消すのは保存したときです。選んだあとにキャンセルすることもあるので、画面の表示と保存は分けています。

「ファイルを選択」のボタンも、ブラウザ標準のままだと目立たなかったので、保存ボタンと同じ緑にしました。Tailwind の file: を使うと、ボタンの部分だけに色を付けられます。

編集画面の画像の欄。今の画像、緑の「ファイルを選択」ボタン、URL の入力欄、「画像を削除する」のチェックボックス

編集画面の画像の欄。差し替え・削除ができる


URL から取り込む(#50)

2つ目の Issue は、URL を貼るだけで画像を取り込む機能です。

ブラウザだけではできない

最初に思いつくのは、ブラウザで URL の画像を読み込んで、そのまま縮小する方法です。ところが、他のサイトの画像は、ブラウザの制限でたいてい中身を読み取れません。表示はできても、canvas に描いて書き出そうとすると拒否されます。

そこで、次の流れにしました。

  1. 画面で URL を貼って「取り込む」を押す
  2. ハコヅミのサーバー(Worker)が、その URL から画像を取ってきて、そのまま返す
  3. 画面は、返ってきた画像をファイルを選んだときと同じように縮小して、プレビューを出す
  4. 保存すると R2 に置く

3と4は、ファイルを選ぶときの仕組みをそのまま使えました。

商品ページの URL でもよい

画像そのものの URL を探すのは、意外と手間です。そこで、商品ページの URL を貼っても取り込めるようにしました。 多くのメーカーや通販サイトのページには、SNS で共有されたときに出す代表の画像が og:image という情報で書かれています。サーバーがページを読んで、それを取り出します。

取り出しには、Cloudflare Workers に最初から入っている HTMLRewriter を使いました。依存パッケージを増やさずに、HTML の meta タグを読めます。

取りに行く先を制限する

利用者が貼った URL に、サーバーが取りに行く仕組みです。どこにでも取りに行けると、悪用のおそれがあります。そこで、次の制限を付けました。

制限 内容
行き先 https の URL だけ。ユーザー名やパスワード入りの URL は不可
転送(リダイレクト) 自分でたどって、転送先も https か確かめる。3回まで
大きさ 画像は 5 MB を超えたら途中でやめる。商品ページは先頭の 1 MB だけ読む
時間 10 秒で打ち切る
中身 #49 と同じく、先頭のバイトで画像かどうかを判定する

読み込みを断るサイトや、ボット対策のあるサイトからは取れません。そのときは「画像を取得できませんでした。ファイルで登録してください」と出すだけにして、回避はしていません。

外部のサイトに頼らずにテストする

CI から実際のサイトにアクセスすると、相手のサイトの都合でテストが落ちることがあります。そこで、自動テストでは外部のサイトにアクセスしないことにしました。

  • 取得の処理は、fetch を差し替えられる形にして、偽の fetch でユニットテストする(画像を返す、商品ページを返す、転送する、404 を返す、など)
  • 画面の E2E では、.invalid のドメイン(名前が引けないと決まっているドメイン)を使って、「取得できないときのエラー」だけを確かめる
  • 実際に取り込めるかは、受入確認でメーカーや通販サイトの URL を試す

E2E テストが、たまに落ちた

最後に、テストでつまずいた話を2つ。

時間切れ

画像の E2E は、R2 に画像があるか・消えたかを確かめるたびに、wrangler のコマンドを起動します。1回に数秒かかるので、1つのテストが既定の上限(30 秒)を超えることがありました。 何回か続けて流して、落ちるのが時間切れだけだと確かめてから、このファイルだけ上限を 60 秒に延ばしています。

画像の読み込みを待っていなかった

本番リリースの PR の CI で、1件だけ落ちました。編集画面の画像の幅が、800 のはずが 0 になっていたのです。

原因は、画像が表示されたことだけを確かめて、すぐに幅を読んでいたことでした。CI では画像がまだ読み込み中のことがあり、幅が 0 になります。手元では読み込みが速く、たまたま通っていました。 img.decode() で読み込みが終わるのを待ってから読むように直しています。

#6でも書いたとおり、「手元で通る」と「いつでも通る」は別物です。


おわりに

  • 画像は R2 に、users/<user_id>/ の下にまとめて置く(利用者ごとに消しやすく)
  • アップロードの前にブラウザで縮小する。Safari は WebP で書き出せないので JPEG にする
  • ファイルの種類は拡張子で信じず、中身で判定する
  • URL からの取り込みはサーバーで取ってきて、あとはファイルと同じ流れに乗せる

これで、ギアに写真を付けられるようになりました。次回は、同じタイミングで作った「おすすめ度」の話です。

← トップページに戻る