【PHP】正しいダウンロード処理の書き方

ワリと需要のある処理だと思いますが、改めてググってみるとあまりマネして欲しくないコードが散見されたため、この記事を書いてみました。

- [検索結果上位のページ](https://www.google.co.jp/search?q=PHP+%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB+%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89&ie=UTF-8)のコードをコピペで使ってる人
- `application/force-download` は、いわゆる「バッドノウハウ」だという事を知らない人
- `readfile()` の正しい使い方を知らない人（特にファイルサイズの大きなファイルに対して使う場合）

などに参考にして頂ければ幸いです。

--------------------------------------------------------------------------------

## コード

```php
function download($pPath, $pMimeType = null)
{
    //-- ファイルが読めない時はエラー(もっときちんと書いた方が良いが今回は割愛)
    if (!is_readable($pPath)) { die($pPath); }

    //-- Content-Typeとして送信するMIMEタイプ(第2引数を渡さない場合は自動判定) ※詳細は後述
    $mimeType = (isset($pMimeType)) ? $pMimeType
                                    : (new finfo(FILEINFO_MIME_TYPE))->file($pPath);

    //-- 適切なMIMEタイプが得られない時は、未知のファイルを示すapplication/octet-streamとする
    if (!preg_match('/\A\S+?\/\S+/', $mimeType)) {
        $mimeType = 'application/octet-stream';
    }

    //-- Content-Type
    header('Content-Type: ' . $mimeType);

    //-- ウェブブラウザが独自にMIMEタイプを判断する処理を抑止する
    header('X-Content-Type-Options: nosniff');

    //-- ダウンロードファイルのサイズ
    header('Content-Length: ' . filesize($pPath));

    //-- ダウンロード時のファイル名
    header('Content-Disposition: attachment; filename="' . basename($pPath) . '"');

    //-- keep-aliveを無効にする
    header('Connection: close');

    //-- readfile()の前に出力バッファリングを無効化する ※詳細は後述
    while (ob_get_level()) { ob_end_clean(); }

    //-- 出力
    readfile($pPath);

	//-- 最後に終了させるのを忘れない
    exit;
}
```


### 使い方

```php
download('path/to/file.zip');

//-- Content-Typeを自動判定せずに指定する時
download('path/to/file.zip', 'application/zip');
```

--------------------------------------------------------------------------------

### Internet Explorer を相手にする時

IE を使っている人にはエラーを出し、新しいウェブブラウザの案内をしてあげた方が、世の中の色んな人が幸せになると思いますが…。

```php
//-- Internet Explorer
if (preg_match('/MSIE (\d{1,2})\.\d;/', getenv('HTTP_USER_AGENT'), $matchAry)) {
    //-- IEでダウンロードしたファイルを直接開く操作を抑止する
    header('X-Download-Options: noopen');

    //-- 未対策のIE8以下且つSSL環境では以下の2行が必要(IEのバグ対策)
    //-- https://support.microsoft.com/ja-jp/help/323308/internet-explorer-file-downloads-over-ssl-do-not-work-with-the-cache-c
    if (getenv('HTTPS') && (int) $matchAry[1] <= 8) {
        header('Cache-Control: public');
        header('Pragma: public');
    }

    //-- application/force-downloadという存在しないContent-Typeを指定する仕方なしのバッドノウハウ(後述)
    download('path/to/file.zip', 'application/force-download');
}
```

コードを見るだけでゲンナリしますね…。

--------------------------------------------------------------------------------

## Content-Type について

Content-Type に `application/force-download` を指定すると書いているページをよく見かけます。

いかにもそれっぽい名前ですが、**`application/force-download` という MIME タイプは存在しません。**

- 参考：[IANA で管理されている公式な MIME タイプ一覧](http://www.iana.org/assignments/media-types/media-types.xhtml)

ウェブブラウザが、解釈できない Content-Type を受け取った際に、ダウンロード処理になるという挙動（その保証は無いが）を利用しているだけで、`application/qiita-banzai` でも同じ事です。

**ファイルをダウンロードさせる際は、`Content-Disposition: attachment` を送信すれば、ダウンロード処理になる**のが「普通のブラウザ」の挙動です。[^1]

[^1]: Internet Explorer のような「普通ではないブラウザ」があるからこその苦肉の策だったと言えますが、もうそろそろこういうバッドノウハウは止めた方が良いと思います。

--------------------------------------------------------------------------------

## readfile() の正しい使い方

- [PHP マニュアル：readfile](https://www.php.net/manual/ja/function.readfile.php)

> readfile() 自体にはメモリに関する問題はなく、 巨大なファイルを送ってもかまいません。
> out of memoryエラーが出る場合は、 ob_get_level() で出力バッファリングを無効にしてください。

と書いてあるにも関わらず、`readfile()` はメモリを食うから、`fopen()` してから `fread()` を使おう…のように説明しているページが散見されます。もちろん、それは間違いです。

`readfile()` がメモリを大量に食ってしまう時は、出力バッファリング[^2]が影響しています。それを無効化するのが、以下のコードです。

[^2]: 出力内容を出力せずにメモリ内に溜め込んでおき、後から吐き出す方法のこと。

```php
while (ob_get_level()) { ob_end_clean(); }
```

PHP の出力バッファリング機構はネストできるため、ネストしている場合でもそれらを全部無効化するために、このようなコードとなっています。


### 「破棄」と「フラッシュ」の違いに注意

今回のように、ファイルの内容を単純に出力する前に用いるのは、`ob_end_flush()` ではなく `ob_end_clean()` です。

- [PHP マニュアル：ob_end_clean](https://www.php.net/manual/ja/function.ob-end-clean.php)
    - 出力バッファを「破棄」してから、出力のバッファリングをオフにする。

- [PHP マニュアル：ob_end_flush](https://www.php.net/manual/ja/function.ob-end-flush.php)
    - 出力バッファを「フラッシュ（出力）」してから、出力のバッファリングをオフにする。


もしダウンロード処理までに出力バッファがあった場合、`ob_end_flush()` を使うと、そのバッファの中身が出力されます。

そうすると、出力された中身の分だけ Content-Length の値と辻褄が合わなくなってしまい、ファイルを全てダウンロードする前にダウンロード処理が終了してしまう事になりますので、`ob_end_clean()` を使うのが正しいです。

この点についても、間違って説明しているページがあるようですので、注意してください。