はじめに
@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
となっています.それぞれに対応させて rawurlencode
と urlencode
を使い分ける,あるいは一貫して rawurlencode
のみを使うようにしてください.