MW WP Formで、「画像添付を必須にしているのに、画像が届かないまま送信が完了してしまう」という報告を受けた。調べてみると原因はフォームの設定ミスでもバグでもなく、iPhone特有のファイルの渡され方にあった。同じ落とし穴にハマる人がいるかもしれないので、最終的な対策をまとめておく。
症状:必須は効いているのに、中身が空
問題のフォームは MW WP Form で作ったフォームで、身分証明書の画像添付を必須にしていた。クライアントからの報告はこうだった。
画像添付しないと先に進めないはずなのに、あるお客様の申込みで身分証明書が添付されていなかった。
こちらでも試すと、確かに「ファイルを未選択のままでは確認画面に進めない」。必須バリデーションは正しく効いている。にもかかわらず、実際の申込データには画像が入っていなかった。

この時点で分かるのは、「確認画面まではファイルが存在する扱いになったが、最終的に中身が届いていない」ということ。ファイルの有無だけを見る必須チェックはすり抜けている。
外れた仮説を先に潰す
いくつか思いついた原因を、順に検証して消していった。
「拡張子を非表示にしていたから」ではない
まず疑ったのが、利用者がOS側でファイルの拡張子を非表示にしていて、拡張子なしで送られたのでは、という説。これは違った。確認画面ではファイル名に拡張子が表示されていた。OSの「拡張子を非表示」はあくまで画面表示の設定で、実際にアップロードされるファイル名からは拡張子は消えない。
「変な形式のファイルを添付したから」でもない
次に、mp4やtxt、あるいは拡張子のないファイルを添付したのでは、という説。これも症状と噛み合わない。当時はファイル形式の制限をかけていなかったので、仮にそうした非画像ファイルを添付したなら、中身のあるファイルがそのまま届くはず。開けるかどうかは別として、「空」にはならない。実際の症状は「中身が空」なので、これも原因ではない。
「サイズ超過で無言落ち」も可能性は低い
大きな写真がPHPのpost_max_sizeを超えて$_FILESが空になった、という線も考えた。ただ、サーバー(ヘテムル)のアップロード上限は100MBあり、身分証明書の写真程度で超えることはまずない。加えて、超過していれば確認画面に進めず必須エラーで止まるはずで、完了までいっている事実と合わない。
真因:iCloud未取得画像の「名前だけ」通過
残ったのが、次の可能性だった。
iPhoneで、iCloud上にあってまだ端末に取り込まれていない写真を選択した結果、実体が0バイト(または不完全)のまま、ファイル名だけがフォームを通過した。
MW WP Form の必須チェックは「ファイル名(=選択の有無)」で判定するため、中身が0バイトでも通ってしまう。これが症状にきれいに一致する。
再現も取れた。0バイトのtest.jpgを作って添付すると、入力画面は通過し、確認画面ではファイルが表示されない。まさに報告どおりの挙動だった。

対策の試行錯誤
原因が見えたので、0バイト・不完全なファイルを確実に弾く方法を探した。ここが一番手こずったので、うまくいかなかった案も含めて残しておく。
案1:拡張子制限(効果は限定的)
MW WP Form のバリデーションルールには「拡張子制限」がある。まず画像形式に絞った。
- 拡張子制限に
jpg,jpeg,png,gifを指定
注意点として、この欄はカンマの後にスペースを入れないこと。jpg, jpeg のように書くと、環境によっては先頭スペースごと比較されて正しい画像まで弾かれることがある。
これで「非画像・拡張子なし」は弾けるようになった。ただし、今回の本丸である「0バイトの.jpg」は拡張子が正しいので通ってしまう。これだけでは足りない。

案2:$_FILESを見る自作バリデーション(効かなかった)
$_FILESのサイズとエラーコードを見て0バイトを弾く独自バリデーションルールを書いてみた。ところがこれが効かない。
理由は MW WP Form の処理順にあった。MW WP Form はバリデーションが走る前に、アップロードファイルを一時ディレクトリへ移動して$_FILESを消費してしまう。そのため、バリデーション時点では$_FILES['certify']はすでに空で、$_FILESを直接参照するアプローチ自体が成立しなかった。ここは MW WP Form の実装をちゃんと追わずに書いてしまった反省点。
案3:最小画像サイズ(画像だけなら有効)
画像に限れば、バリデーションルールの「最小画像サイズ」が効いた。
- 最小画像サイズに
100 × 100を指定
これが有効なのは、MW WP Form が内部でgetimagesize()を使い、アップロードされた実ファイルの中身から縦横のピクセル数を判定するため。0バイトや壊れたファイル、非画像はgetimagesize()が失敗して寸法0扱いになり、確実に弾かれる。$_FILESに依存しないので、案2の問題も回避できる。本物の身分証明書写真は必ず数百〜数千pxあるので、100×100で誤ってブロックする心配はない。
0バイトのtest.jpgで再テストすると、今度はきちんと止まった。

PDFも受け付けたい、という追加要件
ここで追加要望が来た。
パソコンから申し込む人はPDFで送ってくることもあるので、PDFも受け付けたい。
ところが、PDFだと「最小画像サイズ」が壁になる。getimagesize()はPDFを画像として読めないため、最小画像サイズを有効にしているとPDFは寸法0扱いで弾かれてしまう。画像の0バイト対策とPDF受け入れが、この設定では両立しない。
最終方針:最小画像サイズを外し、0バイト対策はJSへ
PDFを通すには最小画像サイズを外すしかない。ただし外すと0バイト対策も一緒に消える。そこで、0バイト検出を画像・PDF共通で効くクライアント側チェックに置き換えた。
案2でサーバー側の$_FILES検証が効かなかったのに対し、選択した瞬間にJavaScriptでファイルサイズを見る方式なら、画像でもPDFでも同じように0バイトを止められる。しかもその場でユーザーに知らせられるので、iCloud未取得のケースにはむしろこちらの方が親切だ。
// 身分証アップロード:0バイト・空ファイルを選択時点で弾く(画像・PDF共通)
$('input[type="file"][name="certify"]').on('change', function () {
var file = this.files && this.files[0];
if (!file) return;
// 100バイト未満は中身が無い/壊れていると判断(正規の写真・PDFは必ずこれ以上)
if (file.size < 100) {
alert('ファイルの中身が読み取れませんでした。\niPhoneの場合、iCloud上の写真が端末にダウンロードされていない可能性があります。\n写真アプリで一度その画像を開いてから、もう一度選択してください。');
$(this).val('');
return;
}
});閾値は100バイト。正規の身分証写真やPDFがこれを下回ることはないので誤ブロックはなく、0バイトだけでなく途中で切れた不完全ファイルも捕まえられる。

PDFは問題なく通過し、データにもcertify-〜.pdfとして保存され、中身も開ける。
最終的な設定
MW WP Form のバリデーションルール(身分証フィールドcertify)は次のとおり。
- 拡張子制限:
jpg,jpeg,png,gif,pdf(スペースなし) - サイズ制限:
31457280(30MB) - 最小画像サイズ:空(未設定) ← PDFを通すため
- 加えて、上記のJSで0バイト・不完全ファイルを選択時に弾く
この構成で「画像もPDFも受け付ける/0バイト・不完全ファイルは弾く/非対応形式は拡張子で弾く/巨大ファイルはサイズで弾く」が揃う。案3で得られたgetimagesize()による実体チェックは失うが、PDFを受ける以上そこは割り切り、0バイト対策はJSでカバーする形にした。
まとめ
今回の学びを整理すると、こうなる。
MW WP Form の必須チェックは「ファイル名の有無」しか見ないため、0バイトや不完全なファイルはすり抜ける。iPhoneではiCloud未取得の写真を選ぶとこれが起きうる。バリデーション前にファイルが一時ディレクトリへ移されるため、$_FILESを見る自作検証は効かない。画像だけならgetimagesize()ベースの最小画像サイズが確実だが、PDFも受けるならこれは使えず、0バイト対策はJSに寄せる――この流れが、実際に手を動かして分かったことだ。
「必須にしたから大丈夫」と思っていたフォームほど、実体が空のファイルには無防備だったりする。ファイルアップロードを扱うなら、有無だけでなく中身まで見ておきたい。
