LoginSignup
20
22

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-02-23

はじめに

@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 のみを使うようにしてください.

20
22
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
22