PHP

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

More than 3 years have passed since last update.


はじめに

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

Content-Disposition: attachment; filename="XXX" のマルチバイト対応は標準化されていないため,Webブラウザごとに実装がバラバラ



filename="XXX"を指定しなくても$_SERVER['PATH_INFO']にあたる部分にファイル名埋め込んでおけばWebブラウザはその名前でダウンロードしてくれるよ!ただのURLエンコードなのでブラウザ別対応必要ないよ!


実装例


  • HTTP経由でのアクセスと直接のアクセスに両対応しました.

  • セキュリティとパフォーマンスを考慮しました.

  • 拡張子はContent-Typeに応じてWebブラウザ側で付与されます.


コード


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);



動作確認


成功 ("画像ファイル.png" としてダウンロードされる)

http://localhost/index.php/画像ファイル?filename=image.png



成功 ("画像ファイル.png" としてダウンロードされる)

http://localhost/index.php/画像ファイル?filename=http://localhost/image.png



失敗

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



成功 ("別のPHPファイル.html" としてダウンロードされる)

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



失敗

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



失敗

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



注意事項



  • $_SERVER['PATH_INFO'] のデコード → rawurldecode


  • $_GET $_POST $_COOKIE のデコード → urldecode

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