画像生成サービスがテキスト入力だけを扱う間、入力データは比較的単純です。
しかし、画像から画像への編集、画像から動画への生成、参照画像を使った生成、動画編集まで対応すると、ユーザーは画像・動画・音声をアップロードするようになります。
このとき、アップロード機能を単なる「ファイルを受け取ってURLを返すAPI」として実装すると、後から多くの問題が発生します。
- 誰のファイルなのか分からない
- HTMLなど想定外の形式が保存される
- モデルが受け付けないメディアが生成APIまで届く
- 元のファイル名がストレージキーに混ざる
- 動画の長さが分からず、生成コストを計算できない
- 管理画面用の画像とユーザー素材が同じ権限で保存される
AI Image Editor で画像・動画の入力を扱う際は、アップロードをストレージの補助機能ではなく、生成ワークフローの独立したセキュリティ境界として考えています。
この記事では、その境界をどのように分けるかを整理します。
まず「用途」を受け取る
同じ画像ファイルでも、用途によって必要なルールは異なります。
たとえば、ユーザーがモデルに渡す参照画像と、管理者がブログ記事に設定するカバー画像では、保存先・サイズ制限・権限が同じである必要はありません。
そこで、アップロードリクエストにはファイルだけでなく、明示的な purpose を含めます。
type UploadPurpose =
| 'generation-source'
| 'blog-cover'
| 'blog-inline-image'
サーバーは purpose ごとに次の項目を決定します。
- 誰がアップロードできるか
- 許可するContent-Type
- 最大ファイルサイズ
- R2の保存プレフィックス
- 返却する公開URL
用途をファイル名や画面URLから推測しないことが重要です。リクエストの意味を入力として明示し、サーバー側で列挙値として検証します。
認証と権限を保存前に確認する
アップロードAPIは、生成APIとは別の入口です。
生成リクエスト側でログインを確認していても、アップロードAPI側でも改めてセッションを確認する必要があります。そうしなければ、ストレージだけが匿名ユーザーに開放される可能性があります。
基本的な順序は次のようになります。
Read session
|
v
Check account restrictions
|
v
Validate upload purpose
|
v
Check purpose-specific role
|
v
Validate file
|
v
Write to object storage
管理画面用の blog-cover や blog-inline-image では、通常のログイン確認に加えて管理者ロールを要求します。
また、利用停止中のアカウントがファイルだけを増やし続けないように、アカウント制限も保存前に適用します。
拡張子ではなくContent-Typeを許可リストで検証する
ブラウザから届いたファイル名だけで形式を判断するのは危険です。
photo.png.html のような名前も作れますし、拡張子とContent-Typeが一致しない場合もあります。
実装では、用途ごとに許可するContent-Typeを列挙します。
const GENERATION_SOURCE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'video/mp4',
'video/webm',
'video/quicktime',
'audio/mpeg',
'audio/wav',
])
ここで大切なのは、「モデルプロバイダーが受け付けるかもしれない形式」をすべて通すことではありません。
アプリケーションとして保管・配信・モデレーションできる形式だけを許可します。Providerの仕様が広くても、プロダクト側の境界は狭くて構いません。
さらに、ファイルサイズ制限も用途ごとに設定します。生成素材は動画を含むため比較的大きく、ブログ画像はより小さく制限できます。
ストレージキーにユーザーのファイル名を使わない
ユーザーが送ったファイル名を、そのままオブジェクトキーに使うのは避けた方が安全です。
ファイル名には空白、Unicode、パスとして解釈される文字、個人情報が含まれる可能性があります。また、同名ファイルによる上書きも考慮する必要があります。
安定したキーは、次のような構造で生成できます。
uploads/{userId}/{purpose}/{uploadId}.{extension}
例:
uploads/user_123/generation-source/9c20...e1.png
ここでは、元のファイル名はキーの識別子として使いません。拡張子だけを正規化し、IDはサーバー側で生成します。
この構造にはいくつか利点があります。
- ユーザー単位でオブジェクトを追跡しやすい
- 同名ファイルが衝突しない
- 用途ごとのライフサイクルルールを設定しやすい
- 公開URLから元のローカルファイル名が漏れない
URLではなく所有関係を信頼する
アップロード後、クライアントはストレージURLを生成リクエストに含めます。
しかし、「正しいCDNドメインのURLである」ことと、「そのユーザーが所有する素材である」ことは同じではありません。
ユーザーが別アカウントのURLを知っている場合、それを入力素材として再送できてしまう可能性があります。
そのため、本番設計では次のどちらかが必要です。
- アップロード時にメディアレコードを作成し、生成時に
mediaAssetIdと所有者を検証する - ストレージキーのユーザー領域とセッションユーザーをサーバー側で照合する
単にURLのprefixだけを見るより、データベース上の所有レコードを確認する方が拡張しやすいです。削除、利用履歴、監査にも同じレコードを使えます。
モデルの制限はアップロード制限と分ける
ここは混同しやすいポイントです。
アップロードAPIが動画を受け付けるからといって、すべてのモデルが動画を入力に使えるわけではありません。
アップロード境界は「このアプリケーションが安全に保存できるか」を判断します。モデル境界は「選択したモデルとモードが、この素材を使えるか」を判断します。
Upload validation:
Can the application store this media?
Model validation:
Can this model use this media in this mode?
たとえば、画像から動画へのモードでは画像だけを許可し、動画編集モードでは動画を必須にできます。参照素材の最大数もモデルごとに異なります。
二つの境界を分けることで、新しいモデルを追加するたびにアップロードAPIの許可形式を書き換える必要がなくなります。
動画メタデータは早い段階で取得する
動画生成の価格や制限は、入力動画の長さに依存することがあります。
ブラウザでは、アップロードと並行して <video> 要素からメタデータを読み取り、秒数を取得できます。
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.onloadedmetadata = () => {
const durationSeconds = video.duration
URL.revokeObjectURL(objectUrl)
}
video.src = objectUrl
これにより、アップロード待ち時間を増やさず、UIで概算コストを表示できます。
ただし、クライアントが送る秒数は課金の最終根拠にしない方が安全です。信頼性が必要な場合は、サーバー側またはメディア処理基盤でメタデータを再取得します。
クライアント値はUXのため、検証済み値は課金と制限のため、と役割を分けます。
公開URLとストレージ内部表現を分離する
オブジェクトストレージの内部URLをそのまま永続化すると、後でCDNドメインを変更したときに移行が難しくなります。
アプリケーションが管理するCDNベースURLと、安定したストレージパスを組み合わせて公開URLを作る方が扱いやすくなります。
function buildMediaUrl(baseUrl: string, storagePath: string) {
return new URL(storagePath, `${baseUrl}/`).toString()
}
ストレージは保存を担当し、CDNは配信を担当し、データベースは所有関係と用途を担当します。
この三つを同じ「URL文字列」として扱わないことが、後の変更を楽にします。
まとめ
AI画像・動画サービスのアップロード機能は、フォームの付属機能ではありません。
安全で拡張しやすい境界にするには、次の点が重要です。
- アップロード用途を明示する
- 保存前に認証、アカウント状態、ロールを確認する
- Content-Typeとサイズを許可リストで検証する
- ストレージキーをサーバー側で生成する
- URLではなく所有レコードを検証する
- アップロード制限とモデル制限を分離する
- 動画メタデータをUX用と課金用に分けて扱う
- ストレージパスと公開CDN URLを分離する
モデルは入力素材を処理しますが、その素材を安全に受け取る責任はアプリケーション側にあります。
アップロードを明確な境界として設計すると、新しいモデルや入力形式を追加しても、ストレージと権限のルールを壊さずに拡張できるようになります。