Laravel:「.jpeg」のアップロードの罠

0
109

画像ファイル(JPEG,PNG,GIF)のアップロードおよびその後の参照を行うシステムにおいて、PNGやGIF、JPEGで拡張子が「.jpg」であれば問題なく「.jpeg」の場合はアップロードした画像が発見できない(Not Foundとなる)という問題があったので、その件に関して書きたいと思います。

なお、「罠」とか言ってますが、単純にプログラムミスなのですが…

JPEGの拡張子

Wikipedia先生によるとJPEGの拡張子には以下のものが用いられる可能性がある模様。

.jpeg, .jpg, .jpe, .jfif, .jfi, .jif

Wikipedia先生がおっしゃるように最も一般的なのは「.jpg」ですが、「.jpeg」も時々見かけます。「.jpe」以降の4つはあまり見たことはありませんが…

同様にWikipedia先生に確認したところ、PNGは「.png」、GIFは「.gif」と1つしか拡張子がありません。
なぜJPEGだけこれほど拡張子が多岐に渡るのか謎です。

問題となった処理

画像のアップロード自体とその保存(永続化)は往々にして別アクションに分かれます。例えばフォームからの画像を含む登録を考えた場合、POSTの結果としてそのまま当該画像を保存してしまうことは稀で、多くの場合は確認画面での入力結果の確認を経て改めて正式に保存されるような段取りになります。

この時、アップロードされた画像ファイルは一旦一時保管場所に保持しておくことになりますが、Laravelではこのような一時ファイル保存に有効なメソッドが用意されています。

$path = $request->file('target')->store('tmp', 'private');

上記でfileメソッドの引数は対象となるPOSTパラメータ名、storeメソッドの第一引数は当該ファイルの格納ディレクトリ、第二引数は同ディレクトリが存在するディスク(省略可)です。その実行結果、保存されたパスが返されますが、ファイル名を適当かつ一意に決定してくれる点が特徴です。ファイル名にこだわりのない一時ファイル作成には大変便利な機能です。

なお、画像ファイルの管理においては本来の画像ファイル名も保持しておきたいので、それは以下の操作で取得できます。

$name = $request->file('target')->getClientOriginalName();

上記パスとファイル名に関してはセッション等に保持しておいて、アップロードを含むPOSTのアクション自体は終了します。
その後、確認画面からの正式登録要求のアクションなどにおいて改めて当該ファイルを保存(永続化)しますが、今回は以下のような方法を用いました。

$DataModel = new Data;
$DataModel->img_name = $name;
$DataModel->save();

$storePath = 'data/'.$DataModel->id.'/img.'.pathinfo($path)['extension'];
Storage::disk('private')->move($path, $storePath);

かなり端折って書いていますが、POSTされたデータを格納するDB上のテーブルが存在する前提で、そのORマッパ(Eloquent)にファイル名を含む入力データを反映した上で保存(save)し、一方ファイルの実体はStorage管理下にファイルの形で保存します。
この時、保存されるファイルのパスは「data/{レコードID}/img.{$pathの拡張子}」となるようにしています。ファイル名ではなくディレクトリ名の方で一意性を保証し、かつDBのレコードとファイルの実体を紐付け易いよう、DBに登録された当該レコードのIDをディレクトリ名としている訳です。

上記のように保存(永続化)したファイルを参照する場合は以下のように行っていました。

$storePath = 'data/'.$DataModel->id.'/img.'.pathinfo($DataModel->img_name)['extension'];
$img = Storage::disk('private')->get($storePath);

保存してあるファイル名は「data/{レコードID}/img.{拡張子}」と言うパスになっていました。レコードIDはORマッパ(Eloquent)から取得できますし、拡張子はレコードに保持してある元ファイル名から取得できる、と言う想定だったのですが…

何が問題だったのか?

上記でPNG,GIFは問題なく処理できます。JPEGでも拡張子が「.jpg」であれば問題ありません。
しかし「.jpeg」が問題でした。

盲点だったのは一時ファイルの保存に使用したstoreメソッドです。勝手に名前も決めてくれる便利な機能です。
しかし改めて考えてみると、拡張子はどのように決定したのでしょう?

この点はLaravelの公式サイトに明確に書かれていました。
「ファイルの拡張子は、MIMEタイプの検査により決まります」だそうです。元ファイル名とかではないんですね。

JPEGのMIMEタイプは「image/jpeg」です。拡張子が「.jpg」でも「.jpeg」でもMIMEタイプは同じです。
で、storeメソッドはMIMEタイプが「image/jpeg」の場合は拡張子を「.jpg」とするようです。

以上を踏まえて先の処理内容を見てみると、保存(永続化)時は一時ファイルの拡張子をそのまま引き継ぐ形になっています。つまり保存されるファイルの拡張子は「.jpg」です。
一方、参照時の拡張子はDBのレコードとして保持してあった元ファイル名から取得してきています。つまり「.jpeg」です。保存されたファイルの拡張子と一致しません。よって、該当するファイルを発見できない(Not Found)というエラーになる訳です。

改修方法としては、一時保存時にstoreではなくstoreAsのようにファイル名を明示するメソッドを使用する、永続化時にstoreが保存したパスからではなく元ファイル名の拡張子を使用するようにする、あるいは元ファイル名とは別に保存しておくファイル名もDBのレコードに保持しておくなど、色々と考えられます。
今回は既存処理の構造上最も改修内容が少なくて済む、一時ファイルの保存時にstoreAsを用いる方法を採用しました。

総括

storeメソッドがよろしくやってくれていることに甘んじて、拡張子も元ファイルと同じにしてくれるものと思い込んでいました。
実際のところPNG,GIFおよび「.jpg」では結果的に上記認識通りになるので、なかなかこの認識の間違いに気づきませんでした。
仕様はしっかりと把握して利用しないといけませんね(と分かっていても、勘と経験で乗り切ろうとすることが今後もありそうな予感がひしひしと…)。