ASP.NET MVC でのファイルダウンロードをする場合の IE への対応には SSL への考慮が必要

IE 自体も Microsoft の製品な訳で、本当にこんな面倒な方法を取らないと IE での動作が上手くいかないのかは分からないのですが、メモがてら。

ちなみに私が仕事柄 IE8 以下を相手にするので、IE9 に関しては未検証の事ばかりです。。

IE でのファイルダウンロードのよくある問題

IE のファイルダウンロード時の問題としてよくあるもので日本語のような ASCII では無い文字をファイル名として扱う場合のものがあります。

2 つ目のサイトが詳しいです。ASP.NET MVC では FileContentResult を使う事で Content-Disposition の付加等も勝手にやってくれます。

ただ、それでも IE に関しては Chrome, Firefox とは違って url encode と空白に対する対処はする必要があります。

return File(ms.ToArray(), "application/octet-stream"
  , HttpUtility.UrlEncode("ファイル test.csv").Replace("+", "%20"));

でもこんなもんです、WebForm の頃は Shift-Jis でエンコードしたりしなければいけなかったのを考えると、だいぶ楽になりました。

続いて現れた SSL 接続時の問題

そう、単に楽になったなーと思ってたら、実はそうで無いケースもありました。それが SSL 接続時の問題です。

http://blogs.msdn.com/b/ieinternals/archive/2009/10/02/internet-explorer-cannot-download-over-https-when-no-cache.aspx

上記のサイトが詳しいですが、例えば ASP.NET MVC で HomeController に Download のようなアクションを定義していたとしましょう。
その時には https://hogehoge.com/Home/Download に対してリクエストをしますよね。その時に IE のダウンロードダイアログに出てくるのが、FileContentResult に設定したファイル名ではなく Download になってしまうのです。
それでも一応保存ボタンは有効なのですが、保存は失敗します。開くを選択すると、ブラウザにそのまま表示されるため一応中をみる事ができるのが救いといえば救いです。

サイトに記載してある通りで、どうやらこれは Cacheに絡んだ問題のようです。

対応方法

サイトにも記載してある通りで、Cache の制御をプログラム側でも行なう事で解決ができます。もっとも雑にやれば以下のような方法でしょう。

Response.AddHeader("cache-control", "no-store, no-cache, must-revalidate"); // ID だけでいいので分岐してあげてもいい
return File(・・・・・

実際これでも十分は十分なので FileContentResult を継承して以下のようなクラスを作ってあげてもいいと思います。

public class FileContentResultWrapper : FileContentResult
{
    public FileContentResultWrapper(byte fileContents, string contentType, string fileDownloadName)
        : base(fileContents, contentType)
    {
        base.FileDownloadName = fileDownloadName;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        // IE 以外では基底の処理に任せる
        if (context.HttpContext.Request.Browser.Browser != "IE")
        {
            base.ExecuteResult(context);
        }
        else
        {
            this.FileDownloadName = HttpUtility.UrlEncode(FileDownloadName).Replace("+", "%20");
            var response = context.HttpContext.Response;
            response.AddHeader("cache-control", "no-store, no-cache, must-revalidate");
            base.ExecuteResult(context);
        }
    }
}

Chrome frame への対応

実は上記の場合だと Chrome frame が IE 以外の方に入ります。それで良いようにも思いますが、結局ブラウザ本体は IE なので同様の問題が出てしまいました。
よって、以下のように分岐メソッドを作ってあげる必要があります。(さすがに Chromeframe は Browser みたいなところからは取得できず UserAgent から取りました。)

// IE 以外では基底の処理に任せる
if (context.HttpContext.Request.Browser.Browser != "IE" && !IsChromeframe(context.HttpContext.Request))

・・・・

private bool IsChromeframe(HttpRequestBase request)
{
    return request.Browser.Browser == "Chrome" && request.UserAgent.IndexOf("ChromeFrame", System.StringComparison.OrdinalIgnoreCase) > 1;
}

他の方法

結局は Cache の制御の部分なので、既存の OutputCache 属性の置き換えとして新たに属性を作るという手法を取っている方もいらっしゃいました。
Request.IsSecureConnection というプロパティあるんですね。これも使ったほうが良さそう。

http://stackoverflow.com/questions/13119340/ie6-8-unable-to-download-file-from-https-site

このコードの分岐条件を見る限りでは IE9 では同様の事象は起こらないみたいですね。

最終形

こんな感じで FileDownloadResult という名前にしてみました。ややこしい。
実は以下のコードでは SSL 環境での検証をしてないので、利用の際はご注意を。 response.AddHeader("cache-control", "no-store, no-cache, must-revalidate") をつけての検証まではしてるので大丈夫だろうとは思いますが。

public class FileDownloadResult : FileContentResult
{
    public FileDownloadResult(byte fileContents, string contentType, string fileDownloadName)
        : base(fileContents, contentType)
    {
        base.FileDownloadName = fileDownloadName;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        // IE 以外では基底の処理に任せる
        if (context.HttpContext.Request.Browser.Browser != "IE" && !IsChromeframe(context.HttpContext.Request))
        {
            base.ExecuteResult(context);
        }
        else
        {
            this.FileDownloadName = HttpUtility.UrlEncode(FileDownloadName).Replace("+", "%20");
            if (context.HttpContext.Request.IsSecureConnection)
            {
                var response = context.HttpContext.Response;
                response.AddHeader("cache-control", "no-store, no-cache, must-revalidate");
            }
            base.ExecuteResult(context);
        }
    }

    private bool IsChromeframe(HttpRequestBase request)
    {
        return request.Browser.Browser == "Chrome" && request.UserAgent.IndexOf("ChromeFrame", System.StringComparison.OrdinalIgnoreCase) > 1;
    }
}