3D間取り 入力画像のクライアント直アップロード化(presigned URL)+クライアント圧縮

done

セッション開始:2026/06/09 02:07

planning developing done

目的 / なぜ必要か

3D間取りの新規作成で、入力画像(間取り図)を サーバーアクション経由でアップロードしていた方式から、 クライアントから S3 へ直接 PUT(presigned URL 方式)へ移行する。 サーバーアクションのリクエストボディに base64 画像を載せて送る構造をやめ、画像の往復をクライアント↔S3に閉じることでサーバーアクションの負荷とペイロードを削減する。 あわせて、アップロード前にクライアント側で画像を圧縮し、アップロード量と後段の生成入力サイズを安定させる。

スコープ

やること

  • 直接アップロード化:作成フォームで presigned URL を取得し、S3 へ直接 PUT。完成した inputImageUri のみを createSolidFloorPlan に渡す
  • サーバーアクション側の簡素化createSolidFloorPlan の入力を inputImage(base64)→ inputImageUri(文字列)へ。アップロード・ハッシュ計算をアクションから除去
  • getUploadUrl の共通化need-annotation-in-image-switch/_actions/ 配下にあった専用アクションを apps/user/actions/s3/get-upload-url.ts へ集約し、引数を { key, image: { mimeType } } に統一
  • クライアント画像圧縮PhotoFieldbrowser-image-compression を導入(maxSizeMB: 1 / maxWidthOrHeight: 1024 / fileType: "image/jpeg"
  • 命名修正:作成フォームを ReplacementCreateFormSolidFloorPlanCreateForm へ改名

やらないこと

  • カメラ撮影での入力PhotoField から撮影経路(ImageCapture 等)を削除。今回はファイル選択のみ
  • getUploadUrlkey 検証:今回は未対応(今後対応したい)→ 課題へ切り出し
  • PNG 透過の維持:圧縮で image/jpeg へ統一するため透過は非対応(画像サイズ安定を優先・許容)
  • サーバー側 lib/aws/s3/upload(base64→バイナリ保存)の削除(他用途で温存)

アップロードフロー(変更後)

  1. PhotoField でファイル選択 → browser-image-compression で JPEG へ圧縮 → base64 文字列として ImageData{ mimeType, data } を state に保持
  2. 作成フォームで key = solid-floor-plans/${MD5(data)}.${ext} を計算(コンテンツアドレス)
  3. getUploadUrl({ key, image: { mimeType } }) で presigned PUT URL を取得(有効期限 5分)
  4. base64 を Blob にデコードして S3 へ PUTcontent-type は presigned の ContentType と一致させる)
  5. inputImageUri = ${AWS_CLOUDFRONT_ENDPOINT}/${key} を組み立て、createSolidFloorPlan({ inputImageUri }) でレコード作成

レビューでの指摘・修正

🔴 重大バグ:PUT のボディが base64 テキストのまま(修正済み)

body: inputImage.data は base64 文字列であり、S3 は base64 を自動デコードしないため、保存される実体が「JPEG バイナリ」ではなく「base64 テキスト」になっていた。 旧サーバー実装 lib/aws/s3/uploadBuffer.from(data, "base64") でデコードしてから保存していた点が落ちていた。

誤検知に注意:作成フォーム内のプレビューはメモリ上の base64 を直接表示するため、S3 の実体が壊れていても正常に見える。 またファイル名がコンテンツアドレス(MD5(data)のため、旧実装が保存した正常な JPEG と新実装の壊れたオブジェクトがキー衝突し、S3 の中身を確認しても旧オブジェクトを見てしまう罠があった。 実際に aws s3 cp ... - | head -c 32 | xxd で先頭バイトを確認し、新規キーが 2f 39 6a 2f(/9j/...)=base64 テキストになっていることで確定した。

修正body: await (await fetch(`data:${mimeType};base64,${data}`)).blob() で base64 を Blob にデコードしてから PUT。再アップロード後、先頭が ff d8 ff e0 ... JFIF =正常な JPEG になることを確認。

🟠 カメラ撮影は圧縮対象外だった → 撮影経路ごと削除

圧縮はファイル選択経路のみに入っており、カメラ撮影経路は無圧縮だった。指摘を受け、今回は撮影経路自体を削除して入力をファイル選択に一本化した。

🟡 getUploadUrl がクライアント指定の key を無検証で受理

認証済みユーザーならバケット内の任意キーへ presigned PUT URL を取得でき、任意オブジェクトを上書きし得る。今回は対応せず、今後の対応として課題化(S3 アップロードURLの key 無検証)。

現状維持と判断した点

  • presigned URL の有効期限を 5分へ短縮(system-manager 側に合わせる意図)。
  • 圧縮で常に image/jpeg 化(画像サイズ安定のため。透過は非対応で許容)。
  • await putFileResponse.status の不要な await は除去済み。

実装内容(影響範囲)

新規 apps/user/actions/s3/get-upload-url.ts

action() ラッパーで認証+presigned URL 取得を集約。引数 { key, image: { mimeType } }

削除 apps/user/components/need-annotation-in-image-switch/_actions/get-upload-url.ts

共通化に伴い削除。参照を @/actions/s3/get-upload-url へ差し替え。

変更 apps/user/lib/aws/s3/get-upload-url.ts

引数を mimeTypeimage: { mimeType } に統一。expiresIn60*6060*5(5分)へ。

変更 apps/user/actions/solid-floor-plan/create-solid-floor-plan.ts

入力を inputImage(base64)→ inputImageUri(文字列)へ。アップロード・MD5 計算を除去し、レコード作成のみを担当。

変更 .../new/_components/solid-floor-plan-create-form/index.tsx

直接アップロード処理を追加(presigned URL → base64 を Blob 化して PUT → CloudFront URL 組み立て)。awsCloudFrontEndpoint を props で受領。コンポーネント名を SolidFloorPlanCreateForm へ改名。

変更 .../new/page.tsx

getAwsCloudfrontEndpoint() をサーバー側で解決し、フォームへ渡す。

変更 apps/user/components/photo-field/index.tsx

browser-image-compression で圧縮(JPEG・最大1MB・長辺1024)。onImageChangePromise<void> | void 許容に。カメラ撮影経路を削除。

変更 apps/user/components/need-annotation-in-image-switch/index.tsx

共通 getUploadUrl を参照し、引数を image: { mimeType } 形へ更新。

変更 apps/system-manager/lib/aws/s3/get-upload-url.ts

エラーログに key / mimeType を追加(観測性向上のみ)。

依存 apps/user/package.json

browser-image-compression 2.0.2 を追加。

検証

新規作成で画像をアップロード後、CloudFront/S3 の実体を aws s3 cp s3://.../solid-floor-plans/<md5>.jpg - | head -c 32 | xxd で確認。 修正後は先頭が ff d8 ff e0 ... 4a46 4946(JFIF) となり、正常な JPEG バイナリが保存されることを確認した。