User-Agent分岐無しに日本語ファイル名でファイルをダウンロードさせる (完全版)

# はじめに

@suin さんにインスパイアされて，そこそこ実用的に使えそうな感じのレベルに引き上げてみました．

- [[日本語ファイル名対応] 簡単にファイルをダウンロードさせられる関数](http://qiita.com/mpyw/items/3838819d4af75c84b564) by @mpyw
- [User-Agent分岐無しに日本語ファイル名でファイルをダウンロードさせる (理論編)](http://qiita.com/suin/items/d1fc9e1ae3e255ae3b37) by @suin

`Content-Disposition: attachment; filename="XXX"` のマルチバイト対応は標準化されていないため，Webブラウザごとに実装がバラバラ
↓
`filename="XXX"`を指定しなくても`$_SERVER['PATH_INFO']`にあたる部分にファイル名埋め込んでおけばWebブラウザはその名前でダウンロードしてくれるよ！ただのURLエンコードなのでブラウザ別対応必要ないよ！

# 実装例

- HTTP経由でのアクセスと直接のアクセスに両対応しました．
- セキュリティとパフォーマンスを考慮しました．
- **拡張子は`Content-Type`に応じてWebブラウザ側で付与されます．**

## コード

```php:index.php
<?php

/* 異常終了用関数 */
function http_exit($msg, $status) {
    header('Content-Type: text/plain; charset=UTF-8', true, $status);
    exit("$msg\n");
}

/* 設定 */
$hostname = 'localhost'; // ホスト名
$directory = __DIR__; // 直接アクセスの場合に利用するディレクトリ

/* パラメータの受け取り */
$recursion         = (bool)filter_input(INPUT_SERVER, 'HTTP_X_SELF_RECURSION');
$download_filename = ltrim(filter_input(INPUT_SERVER, 'PATH_INFO'), '/');
$real_filename     = (string)filter_input(INPUT_GET, 'filename');

// ダウンロードファイル名の検証
if ($download_filename === '' || $real_filename === '') {
    http_exit('Insufficient parameters', 400);
}

// ファイルへの直接アクセスとHTTP経由でのアクセスを分岐
if (preg_match('@\Ahttps?://' . preg_quote($hostname, '@') . '/@', $real_filename)) {
    
    /**
     * HTTP経由でのアクセスの場合 (非推奨)
     */
    
    // このファイル自身へのリクエストを検知
    // (このエラーメッセージそのものがユーザに見えることはない)
    if ($recursion) {
        http_exit('Self recursion detected', 400);
    }
    
    // レスポンスヘッダの配列およびレスポンスボディへのストリームを取得する
    // (「このファイル自身へのリクエストを検知」のためにヘッダを付与する，ただしビルトインサーバはシングルプロセスなので無意味)
    $fp = @fopen($real_filename, 'rb', false, stream_context_create([
        'http' => ['header' => 'X-Self-Recursion: 1'],
    ]));
    
    // 扱いやすいようにヘッダを結合
    $h = implode("\n", isset($http_response_header) ? $http_response_header : []);
    
    // HTTPステータスエラーを検証
    if (!$fp) {
        http_exit(
            error_get_last()['message'],
            preg_match('@\AHTTP/\S++ \K\d{3}@i', $h, $m) ? (int)$m[0] : 500
        );
    }
    
    // Content-Lengthをチェックし，あれば出力
    if (preg_match('/^Content-Length: \S++$/im', $h, $m)) {
        header($m[0]);
    }
    
    // Content-Typeをチェックし，あればそれを出力
    // 無ければ application/octet-stream とする
    header(preg_match('/^Content-Type: \S++$/im', $h, $m) ? $m[0] : 'application/octet-stream');
    
} else {
    
    /**
     * 直接アクセスの場合 (推奨)
     */
    
    // - ディレクトリトラバーサルの防止
    // - ダウンロードされるとマズい拡張子の検証 
    //   (ここではドットファイルやPHPファイルを禁止しています)
    // - ファイルの存在確認
    if (!preg_match('/\A(?!\.)[\w.-]++(?<!\.)(?<!\.php)\z/i', $real_filename) || !is_file($real_filename)) {
        http_exit('File not found', 404);
    }
    
    // コンテンツへのストリームを取得
    $fp = fopen($real_filename, 'rb');
    
    // ファイルサイズをContent-Lengthとして出力
    header('Content-Length: ' . filesize($real_filename));
    
    // Content-Typeをチェックして出力
    header('Content-Type: ' . mime_content_type($real_filename));
    
}

// ダウンロード用ヘッダおよびバッファの送出
// (ファイルサイズが大きくなっても無問題)
header('Content-Disposition: attachment');
while (ob_get_level()) {
    ob_end_clean();
}
fpassthru($fp);
```

## 動作確認

```text:成功&#32;("画像ファイル.png"&#32;としてダウンロードされる)
http://localhost/index.php/画像ファイル?filename=image.png
```

```text:成功&#32;("画像ファイル.png"&#32;としてダウンロードされる)
http://localhost/index.php/画像ファイル?filename=http://localhost/image.png
```

```text:失敗
http://localhost/index.php/別のPHPファイル?filename=other.php
```

```text:成功&#32;("別のPHPファイル.html"&#32;としてダウンロードされる)
http://localhost/index.php/別のPHPファイル?filename=http://localhost/other.php
```


```text:失敗
http://localhost/index.php/このPHPファイル?filename=index.php
```

```text:失敗
http://localhost/index.php/このPHPファイル?filename=http://localhost/index.php
```

# 注意事項

- `$_SERVER['PATH_INFO']` のデコード → **`rawurldecode`**
- `$_GET` `$_POST` `$_COOKIE` のデコード → `urldecode`

となっています．それぞれに対応させて **`rawurlencode`** と `urlencode` を使い分ける，あるいは一貫して **`rawurlencode`** のみを使うようにしてください．